# 第 18 课:LoRA 原理和手写实现 这一课只解决一个问题:LoRA 如何在不全量更新模型参数的情况下,让模型学到一个新的低秩增量。 ## 目录 - [0. 本节主线](#l18-mainline) - [1. 原理讲解](#l18-principle) - [2. 源码阅读顺序图](#l18-reading-order) - [3. MiniMind 源码走读](#l18-source-walkthrough) - [4. 本节必须会写 / 暂时不要求](#l18-must-write) - [5. 手写模块](#l18-handwrite) - [6. 对齐测试](#l18-alignment-test) - [7. 阶段组装](#l18-stage-assembly) - [8. 本节检查](#l18-check) - [9. 下一课](#l18-next) ## 0. 本节主线 LoRA 的核心是: ```text 冻结原来的 Linear 权重 W -> 旁边加一个低秩分支 BA -> forward 时输出 W x + BA x -> 训练时只更新 A 和 B -> 保存时只保存 A 和 B ``` 数学上可以写成: $$ \begin{aligned} y_{\text{base}} &= Wx \\ \Delta y &= BAx \\ y &= y_{\text{base}} + \Delta y \end{aligned} $$ 教学版常见写法会加缩放: $$ y = Wx + \frac{\alpha}{r}BAx $$ MiniMind 原源码没有显式写 `alpha / r`,等价于教学版里设置: $$ \alpha = r $$ 这一课先写 LoRA 核心模块。第 19 课再讲完整 `train_lora.py` 训练流程。 ## 1. 原理讲解 ### 1.1 LoRA 为什么能减少训练参数 普通全参数微调会更新原始线性层权重: $$ W \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}} $$ 参数量是: $$ d_{\text{out}} \cdot d_{\text{in}} $$ LoRA 不直接训练完整的 $\Delta W$,而是假设需要学习的增量可以用两个低秩矩阵近似: $$ \Delta W = BA $$ 其中: | 符号 | 形状 | 含义 | |---|---|---| | $W$ | `[out_features, in_features]` | 原始 Linear 权重,冻结 | | $A$ | `[rank, in_features]` | LoRA 下投影,训练 | | $B$ | `[out_features, rank]` | LoRA 上投影,训练 | | $r$ | 标量 | rank,远小于输入/输出维度 | | $\Delta W$ | `[out_features, in_features]` | LoRA 形成的权重增量 | LoRA 参数量是: $$ r \cdot d_{\text{in}} + d_{\text{out}} \cdot r = r(d_{\text{in}} + d_{\text{out}}) $$ 当 $r \ll d_{\text{in}}, d_{\text{out}}$ 时,训练参数会少很多。 例如一个方阵 Linear: $$ W \in \mathbb{R}^{768 \times 768} $$ 全参数量: $$ 768 \times 768 = 589824 $$ 如果 LoRA rank 是 16: $$ 16 \times 768 + 768 \times 16 = 24576 $$ 只相当于原层参数量的约 4.17%。 ### 1.2 LoRA forward 做了什么 普通 Linear 是: $$ y = Wx $$ LoRA Linear 是: $$ y = Wx + \frac{\alpha}{r}BAx $$ 变量形状: | 变量 | 形状 | 含义 | |---|---|---| | $x$ | `[B, S, d_in]` 或 `[N, d_in]` | 输入 hidden | | $Wx$ | `[B, S, d_out]` 或 `[N, d_out]` | 原始 Linear 输出 | | $Ax$ | `[B, S, r]` 或 `[N, r]` | 低秩中间表示 | | $BAx$ | `[B, S, d_out]` 或 `[N, d_out]` | LoRA 增量输出 | | $y$ | `[B, S, d_out]` 或 `[N, d_out]` | 最终输出 | 注意:LoRA 不改变 Linear 的输入输出 shape。它只在数值上加一个可训练增量。 ### 1.3 为什么 B 通常初始化为 0 MiniMind 的 LoRA 初始化是: ```text A: normal(0, 0.02) B: zero ``` 这样一开始: $$ B = 0 $$ 所以: $$ \Delta y = BAx = 0 $$ 也就是说,刚注入 LoRA 时,模型输出不变: $$ y = Wx $$ 这很重要。它保证你把 LoRA 插进去后,不会在训练开始前突然改变基座模型行为。训练过程中,$B$ 逐渐学到非零值,LoRA 分支才开始改变输出。 ### 1.4 MiniMind 把 LoRA 插到哪些 Linear 上 MiniMind 的 `apply_lora` 只给满足这个条件的 Linear 加 LoRA: ```text isinstance(module, nn.Linear) and module.in_features == module.out_features ``` 也就是只处理输入输出维度相同的 Linear。 这是一种简化策略。它通常会覆盖一部分方阵投影,例如 attention 或主干里的某些投影,但不会覆盖所有 Linear。 教学版也先照这个范围做: ```text square_only=True ``` 后面如果想扩展,可以再支持指定目标模块名,比如: ```text q_proj k_proj v_proj o_proj gate_proj up_proj down_proj ``` 但第 18 课先不做复杂 target module 配置。 ### 1.5 为什么训练时只更新 LoRA 参数 LoRA 的参数高效来自“冻结基座,只训练低秩分支”。 训练时要设置: ```text base model 参数: requires_grad = False LoRA A/B 参数: requires_grad = True ``` 优化器也只接收 LoRA 参数: ```text optimizer = AdamW(lora_params, lr=...) ``` 这样反向传播虽然会经过整个模型,但 optimizer step 只会更新 LoRA 参数。 可以把训练目标写成: $$ \min_{A,B} L\left(W + \frac{\alpha}{r}BA\right) $$ 这里 $W$ 固定不动,只有 $A$ 和 $B$ 参与优化。 ### 1.6 LoRA 保存的不是完整模型 Full SFT 保存的是完整模型权重: ```text full_sft_768.pth ``` LoRA 保存的只是 adapter 权重: ```text lora_medical_768.pth ``` 也就是只保存: ```text *.lora.A.weight *.lora.B.weight ``` 推理时需要: ```text 基础模型权重 + LoRA 权重 ``` 例如: ```bash python eval_llm.py --weight full_sft --lora_weight lora_medical ``` 意思是:先加载 `full_sft` 基座,再叠加 `lora_medical` adapter。 ## 2. 源码阅读顺序图 这节源码按这个顺序读: ```text model/model_lora.py::LoRA -> model/model_lora.py::apply_lora -> trainer/train_lora.py 冻结非 LoRA 参数 -> trainer/train_lora.py optimizer 只接 LoRA 参数 -> model/model_lora.py::save_lora -> model/model_lora.py::load_lora -> model/model_lora.py::merge_lora ``` 对应文件: ```text model/model_lora.py trainer/train_lora.py eval_llm.py ``` 本节重点看 `model_lora.py` 的核心实现。第 19 课再完整走 `train_lora.py` 的训练流程。 ## 3. MiniMind 源码走读 ### 第 1 步:LoRA 模块就是两个 Linear File: `model/model_lora.py:5-18` Read this to understand: MiniMind 的 LoRA 分支如何构成低秩增量。 Code/config/template excerpt: ```python class LoRA(nn.Module): def __init__(self, in_features, out_features, rank): super().__init__() self.rank = rank self.A = nn.Linear(in_features, rank, bias=False) self.B = nn.Linear(rank, out_features, bias=False) self.A.weight.data.normal_(mean=0.0, std=0.02) self.B.weight.data.zero_() def forward(self, x): return self.B(self.A(x)) ``` This code shows: - `A` 把 `in_features` 降到 `rank`。 - `B` 把 `rank` 升回 `out_features`。 - `B` 初始为 0,所以刚注入时 LoRA 分支输出为 0。 - MiniMind 源码里的 LoRA forward 没有显式 `alpha / rank` 缩放。 对应公式是: $$ \Delta y = BAx $$ ### 第 2 步:apply_lora 给方阵 Linear 注入 LoRA File: `model/model_lora.py:21-32` Read this to understand: MiniMind 没有替换整个模型结构,而是 monkey-patch Linear 的 forward。 Code/config/template excerpt: ```python def apply_lora(model, rank=16): for name, module in model.named_modules(): if isinstance(module, nn.Linear) and module.in_features == module.out_features: lora = LoRA(module.in_features, module.out_features, rank=rank).to(model.device) setattr(module, "lora", lora) original_forward = module.forward def forward_with_lora(x, layer1=original_forward, layer2=lora): return layer1(x) + layer2(x) module.forward = forward_with_lora ``` This code shows: - 只处理 `in_features == out_features` 的 Linear。 - 每个目标 Linear 多了一个 `module.lora` 子模块。 - `original_forward(x)` 是原始输出 $Wx$。 - `layer2(x)` 是 LoRA 增量 $BAx$。 - 最终输出是两者相加。 对应公式是: $$ y = Wx + BAx $$ ### 第 3 步:训练时冻结非 LoRA 参数 File: `trainer/train_lora.py:139-147` Read this to understand: LoRA 训练为什么只更新 adapter 参数。 Code/config/template excerpt: ```python lora_params = [] for name, param in model.named_parameters(): if 'lora' in name: param.requires_grad = True lora_params.append(param) else: param.requires_grad = False ``` This code shows: - 参数名里包含 `lora` 的参数会被训练。 - 其它参数全部冻结。 - `lora_params` 会传给 optimizer。 ### 第 4 步:optimizer 只接 LoRA 参数 File: `trainer/train_lora.py:148-152` Read this to understand: 冻结参数之外,optimizer 也只管理 LoRA 参数。 Code/config/template excerpt: ```python train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len) train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16')) optimizer = optim.AdamW(lora_params, lr=args.learning_rate) ``` This code shows: - LoRA 训练仍然使用 SFTDataset。 - optimizer 不是 `model.parameters()`,而是 `lora_params`。 - 这保证 optimizer step 只更新 LoRA A/B。 ### 第 5 步:保存时只保存 LoRA 权重 File: `model/model_lora.py:45-53` Read this to understand: LoRA 权重文件为什么很小。 Code/config/template excerpt: ```python def save_lora(model, path): raw_model = getattr(model, '_orig_mod', model) state_dict = {} for name, module in raw_model.named_modules(): if hasattr(module, 'lora'): clean_name = name[7:] if name.startswith("module.") else name lora_state = {f'{clean_name}.lora.{k}': v.cpu().half() for k, v in module.lora.state_dict().items()} state_dict.update(lora_state) torch.save(state_dict, path) ``` This code shows: - 只遍历带 `lora` 的模块。 - 只保存 `module.lora.state_dict()`。 - 不保存基座模型参数。 - 保存到独立的 LoRA 权重文件。 ### 第 6 步:加载时按模块名把 LoRA 权重装回去 File: `model/model_lora.py:35-42` Read this to understand: 加载 LoRA 前必须先对模型调用 `apply_lora`。 Code/config/template excerpt: ```python def load_lora(model, path): state_dict = torch.load(path, map_location=model.device) state_dict = {(k[7:] if k.startswith('module.') else k): v for k, v in state_dict.items()} for name, module in model.named_modules(): if hasattr(module, 'lora'): lora_state = {k.replace(f'{name}.lora.', ''): v for k, v in state_dict.items() if f'{name}.lora.' in k} module.lora.load_state_dict(lora_state) ``` This code shows: - LoRA 权重文件按模块名保存。 - 加载时要找到对应模块的 `module.lora`。 - 如果没有先 `apply_lora(model)`,模型里就没有 `module.lora` 可加载。 ### 第 7 步:合并 LoRA 到完整权重 File: `model/model_lora.py:56-65` Read this to understand: LoRA 可以在部署前合并回 base weight。 Code/config/template excerpt: ```python if isinstance(module, nn.Linear) and '.lora.' not in name: state_dict[f'{name}.weight'] = module.weight.data.clone().cpu().half() if hasattr(module, 'lora'): state_dict[f'{name}.weight'] += (module.lora.B.weight.data @ module.lora.A.weight.data).cpu().half() ``` This code shows: - 合并时把 $BA$ 加到原始 $W$ 上。 - 合并后的权重不再需要 LoRA 分支。 - MiniMind 源码合并时也没有显式 `alpha / rank`。 对应公式是: $$ W_{\text{merged}} = W + BA $$ ## 4. 本节必须会写 / 暂时不要求 必须会写: 1. LoRA 低秩分支: $$ \Delta y = BAx $$ 2. LoRA Linear 输出: $$ y = Wx + \frac{\alpha}{r}BAx $$ 3. MiniMind 源码等价形式: $$ y = Wx + BAx $$ 4. 参数量对比: $$ \begin{aligned} N_{\text{full}} &= d_{\text{out}}d_{\text{in}} \\ N_{\text{lora}} &= r(d_{\text{in}} + d_{\text{out}}) \end{aligned} $$ 5. 冻结规则: ```text base 参数 requires_grad=False LoRA A/B 参数 requires_grad=True optimizer 只接收 LoRA 参数 ``` 暂时不要求: ```text 1. LoRA 训练完整流程 2. LoRA 数据集选择 3. LoRA rank/alpha 搜参 4. 多 target module 配置 5. 合并后导出 HuggingFace 格式 ``` ## 5. 手写模块 本节你要补的是: ```text course/impl/core/lora.py ``` ### 5.1 补 `LoRALinear` 接口: ```python class LoRALinear(nn.Module): def __init__(self, base: nn.Linear, rank: int, alpha: float, dropout: float = 0.0): ... def forward(self, x: torch.Tensor) -> torch.Tensor: ... ``` 你要实现的行为: - 保存原始 `base` Linear。 - 创建 `A = nn.Linear(base.in_features, rank, bias=False)`。 - 创建 `B = nn.Linear(rank, base.out_features, bias=False)`。 - `A.weight` 用 normal 初始化。 - `B.weight` 用 0 初始化。 - `scaling = alpha / rank`。 - forward 返回: $$ y = \mathrm{base}(x) + \frac{\alpha}{r}B(A(x)) $$ 为了和 MiniMind 源码数值对齐,测试里会用: $$ \alpha = r $$ 这样: $$ \frac{\alpha}{r} = 1 $$ ### 5.2 补 `mark_only_lora_as_trainable` 接口: ```python def mark_only_lora_as_trainable(model: nn.Module) -> list[nn.Parameter]: ... ``` 你要实现的行为: - 遍历 `model.named_parameters()`。 - 只有名称中属于 LoRA 的 `A.weight` 和 `B.weight` 保持 `requires_grad=True`。 - 其它参数设为 `False`。 - 返回 LoRA 参数列表,供 optimizer 使用。 ### 5.3 补 `lora_state_dict` 接口: ```python def lora_state_dict(model: nn.Module) -> dict[str, torch.Tensor]: ... ``` 你要实现的行为: - 只返回 LoRA 参数。 - 不返回 base weight。 - key 保留模块路径,例如: ```text 0.A.weight 0.B.weight ``` 这对应 MiniMind `save_lora` 的思想:LoRA 文件只保存 adapter 权重。 ### 5.4 暂时不强制实现 `apply_lora_to_linear_layers` 这个接口已经放到骨架里: ```python def apply_lora_to_linear_layers(...): ... ``` 它用于后续把模型里的目标 Linear 批量换成 `LoRALinear`。第 18 课可以先理解和设计,第 19 课训练流程里再完整接入。 ## 6. 对齐测试 本节新增对齐测试: ```text course/impl/tests/test_lora_core.py ``` 运行命令: ```bash cd /home/sun/minimind python course/impl/tests/test_lora_core.py ``` 现在还没有实现时,这个测试会因为 `NotImplementedError` 或缺少 `A/B` 失败。等你补完后,它应该打印: ```text lora_linear_forward_diff=0.000000000000 trainable_names=['0.A.weight', '0.B.weight'] lora_state_keys=['0.A.weight', '0.B.weight'] lora_core=passed ``` 这个测试检查三件事: ```text 1. LoRALinear 的 forward 是否等于 base(x) + B(A(x))。 2. 冻结函数是否只让 A/B 可训练。 3. lora_state_dict 是否只导出 A/B。 ``` ## 7. 阶段组装 本节完成后,LoRA 阶段会多出核心模块: ```text course/impl/core/lora.py::LoRALinear course/impl/core/lora.py::apply_lora_to_linear_layers course/impl/core/lora.py::mark_only_lora_as_trainable course/impl/core/lora.py::lora_state_dict ``` 第 19 课会把这些接到: ```text course/impl/train_lora_impl.py ``` 完整 LoRA SFT 阶段应该是: ```text 加载 full_sft / course_sft 基座 -> 注入 LoRA -> 冻结 base 参数 -> SFTDataset -> optimizer 只接 LoRA 参数 -> 训练 -> 只保存 LoRA adapter 权重 ``` ## 8. 本节检查 1. LoRA 中 $A$、$B$、$W$ 的形状分别是什么? 2. 为什么 LoRA 的参数量比完整 Linear 小? 3. 为什么 MiniMind 把 `B.weight` 初始化为 0? 4. `apply_lora` 为什么只处理 `in_features == out_features` 的 Linear? 5. LoRA forward 为什么不改变输入输出 shape? 6. 为什么训练时 optimizer 只接收 LoRA 参数? 7. LoRA 权重文件为什么不能单独用于推理? 8. 合并 LoRA 到 base weight 的公式是什么? ## 9. 下一课 第 19 课讲 LoRA 训练流程。 下一课要解决: ```text train_lora.py 如何加载 full_sft 基座; apply_lora 在训练脚本里什么时候调用; 如何统计 LoRA 参数占比; 为什么 torch.compile 被关闭; LoRA 权重如何和 eval_llm.py 的 --lora_weight 对上。 ```