minimind/course/19_lora_training_flow.md

993 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第 19 课:LoRA 训练流程
这一课只解决一个问题:MiniMind 的 `train_lora.py` 如何把第 18 课的 LoRA 核心模块接进 SFT 训练,只更新低秩 adapter,并把 adapter 单独保存下来。
## 目录
- [0. 本节主线](#l19-mainline)
- [1. 原理讲解](#l19-principle)
- [2. 源码阅读顺序图](#l19-reading-order)
- [3. MiniMind 源码走读](#l19-source-walkthrough)
- [4. 本节必须会写 / 暂时不要求](#l19-must-write)
- [5. 手写模块](#l19-handwrite)
- [6. 对齐测试](#l19-alignment-test)
- [7. 阶段组装](#l19-stage-assembly)
- [8. 本节检查](#l19-check)
- [9. 下一课](#l19-next)
<a id="l19-mainline"></a>
## 0. 本节主线
LoRA 训练流程是:
```text
加载 full_sft 基座模型
-> 给目标 Linear 注入 LoRA 分支
-> 统计总参数和 LoRA 参数
-> 冻结 base 参数,只保留 LoRA A/B 可训练
-> 继续使用 SFTDataset 构造 input_ids/labels
-> optimizer 只接收 lora_params
-> forward/loss/backward 仍走普通 SFT 训练循环
-> 保存时只保存 LoRA adapter 权重
-> 推理时先加载 base,再加载 LoRA adapter
```
第 18 课解决的是:
```text
一个 Linear 旁边怎么加 BA 低秩分支
```
第 19 课解决的是:
```text
整棵模型什么时候注入 LoRA,怎么只训练 LoRA,怎么保存和推理
```
一句话:
```text
LoRA 训练不是换一种 loss,而是换一种可训练参数集合。
```
SFT 的数据、prompt、labels、causal LM loss 都沿用第 16-17 课;变化只在模型参数和保存方式。
<a id="l19-principle"></a>
## 1. 原理讲解
### 1.1 LoRA 训练为什么从 full_sft 开始
LoRA 微调通常不是从随机初始化或纯 pretrain 权重开始。它要在一个已经会对话的基座上学习少量新行为,例如身份、领域知识、格式习惯。
在 MiniMind 里,LoRA 脚本默认:
```text
from_weight = full_sft
```
这表示:
```text
先加载已经 SFT 过的完整模型
再注入 LoRA
然后只训练 LoRA adapter
```
如果基座还不会按 chat template 回复,LoRA adapter 需要同时学会基础对话和新任务,这就不再是轻量适配。
### 1.2 为什么要先注入 LoRA,再冻结参数
冻结参数的目标是:
```text
base 权重不更新
LoRA A/B 更新
```
所以顺序很重要:
```text
先 apply_lora(model)
再遍历 model.named_parameters()
根据参数名或模块类型决定 requires_grad
```
如果先冻结,再注入 LoRA,后注入的 LoRA 参数默认仍可能是可训练的,但统计和 optimizer 收集容易漏掉;如果先建 optimizer,再注入 LoRA,optimizer 根本拿不到新参数。
正确心智模型是:
```text
模型结构最终长什么样
-> 哪些参数可训练
-> optimizer 管哪些参数
```
### 1.3 LoRA 训练仍然是 SFT 训练
LoRA 不是新的训练目标。MiniMind 的 LoRA 训练仍然读取 SFT 数据:
```text
conversations
-> chat template
-> input_ids
-> assistant labels
-> causal LM loss
```
所以第 5、16、17 课讲过的 SFT labels 规则继续成立:
```text
user/system/tool prompt 区域 label = -100
assistant 回复区域 label = token id
```
LoRA 改变的是:
```text
哪些参数会被 optimizer 更新
```
不是:
```text
数据格式
loss 公式
forward 输出
```
### 1.4 为什么 optimizer 只接收 LoRA 参数
即使 base 参数 `requires_grad=False`,更清晰的做法仍然是:
```text
optimizer = AdamW(lora_params, lr=...)
```
这让训练边界很明确:
```text
backward 可以经过整棵模型
optimizer.step 只更新 LoRA A/B
```
梯度裁剪也只对 `lora_params` 做:
```text
clip_grad_norm_(lora_params, grad_clip)
```
这样参数统计、显存、保存和调试都围绕 adapter 展开。
### 1.5 为什么 LoRA 只保存 adapter
LoRA 训练的产物不是完整模型,而是低秩增量。
保存完整模型会浪费空间,也会模糊基座和 adapter 的关系。MiniMind 的做法是:
```text
base 权重:full_sft_768.pth
LoRA 权重:lora_medical_768.pth
```
推理时组合:
```text
加载 base
-> 注入同样结构的 LoRA 分支
-> 加载 adapter 权重
```
所以 LoRA 文件不能单独推理。它只是一组增量参数,必须挂到同结构的基座模型上。
### 1.6 为什么原脚本关闭 torch.compile
MiniMind 原版 `apply_lora` 是 monkey-patch 写法:它把某些 `nn.Linear` 的 `forward` 函数替换成 `forward_with_lora`。
这种运行时改 `forward` 的方式直观,但和 `torch.compile` 的图捕获不稳定,所以原训练脚本检测到 `use_compile=1` 时会关闭它。
教学版建议用包装模块:
```text
nn.Linear -> LoRALinear(base_linear)
```
这种方式比 monkey-patch 更容易测试,也更适合课程里做模块替换练习。理解原项目时要知道:原项目是给 Linear 挂 `module.lora` 并改 forward;教学版是把目标 Linear 换成 `LoRALinear`。
<a id="l19-reading-order"></a>
## 2. 源码阅读顺序图
先读第 18 课的 LoRA 核心,再读训练脚本:
```text
model/model_lora.py
LoRA
apply_lora
save_lora
load_lora
trainer/train_lora.py
argparse 默认参数
init_model 加载 full_sft
apply_lora 注入 adapter
参数统计
冻结非 LoRA 参数
SFTDataset / optimizer
train_epoch
save_lora
eval_llm.py
apply_lora
load_lora
```
建议按这个顺序读:
```text
1. train_lora.py:79-101 看 LoRA 训练默认参数
2. trainer_utils.py:119-131 看 from_weight 如何加载基座
3. train_lora.py:128-146 看注入、统计、冻结
4. train_lora.py:148-152 看数据集和 optimizer
5. train_lora.py:25-76 看训练循环和保存
6. model_lora.py:21-32 看 apply_lora 如何改模型结构
7. model_lora.py:45-53 看 save_lora 只保存 adapter
8. eval_llm.py:24-26 看推理如何加载 LoRA
```
<a id="l19-source-walkthrough"></a>
## 3. MiniMind 源码走读
### 3.1 LoRA 训练脚本默认从 full_sft 加载
#### 源码证据 A:LoRA 训练参数
文件:`trainer/train_lora.py:79-101`
看它是为了理解:LoRA 训练的默认基座、数据、学习率和保存名。
代码摘录:
```python
parser.add_argument("--lora_name", type=str, default="lora_medical")
parser.add_argument("--learning_rate", type=float, default=1e-4)
parser.add_argument("--data_path", type=str, default="../dataset/lora_medical.jsonl")
parser.add_argument('--from_weight', default='full_sft', type=str)
```
这段代码说明:
- LoRA 默认产物名是 `lora_medical`。
- LoRA 默认学习率比 full SFT 的 `1e-5` 更大,是 `1e-4`。
- 数据仍然是 JSONL 对话数据。
- 默认基座权重是 `full_sft`,不是 `pretrain`。
#### 源码证据 B:init_model 加载基座权重
文件:`trainer/trainer_utils.py:119-131`
看它是为了理解:`from_weight='full_sft'` 最终如何变成一个本地权重文件。
代码摘录:
```python
def init_model(lm_config, from_weight='pretrain', tokenizer_path='../model', save_dir='../out', device='cuda'):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
model = MiniMindForCausalLM(lm_config)
if from_weight!= 'none':
moe_suffix = '_moe' if lm_config.use_moe else ''
weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
```
这段代码说明:
- LoRA 脚本不是从 Transformers 格式模型开始,而是构建 MiniMind 原生模型。
- `from_weight` 决定加载 `../out/{from_weight}_{hidden_size}.pth`。
- dense 默认会找 `../out/full_sft_768.pth`。
- MoE 会多一个 `_moe` 后缀。
理解到这一步就够:
```text
LoRA 训练之前,模型已经有一套完整 base 权重。
```
暂时不要看:
```text
AutoTokenizer 内部加载细节
strict=False 对每个 key 的兼容细节
```
### 3.2 apply_lora 必须在冻结和 optimizer 之前
#### 源码证据 A:训练脚本中的调用顺序
文件:`trainer/train_lora.py:128-146`
看它是为了理解:模型结构、可训练参数和 optimizer 参数集合的顺序关系。
代码摘录:
```python
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
apply_lora(model)
total_params = sum(p.numel() for p in model.parameters())
lora_params_count = sum(p.numel() for name, p in model.named_parameters() if 'lora' in name)
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
```
这段代码说明:
- `apply_lora(model)` 发生在参数统计之前。
- 统计 LoRA 参数依赖参数名里出现 `lora`。
- 冻结规则也依赖参数名里出现 `lora`。
- `lora_params` 是后面 optimizer 和 grad clip 的唯一参数集合。
#### 源码证据 B:原项目如何注入 LoRA
文件:`model/model_lora.py:21-32`
看它是为了理解:MiniMind 原项目的 `apply_lora` 实际改变了什么。
代码摘录:
```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
```
这段代码说明:
- MiniMind 只给方阵 `Linear` 注入 LoRA。
- 每个命中的 Linear 会得到一个 `module.lora` 子模块。
- 原始 forward 被保留下来,新的 forward 返回 `base(x) + lora(x)`。
- 因为 `LoRA.B` 初始化为 0,刚注入时模型输出不应该改变。
教学版和原项目的差异:
```text
原项目:给 Linear 挂 module.lora,并 monkey-patch forward。
教学版:把目标 Linear 替换成 LoRALinear(base)。
```
两者要对齐的行为是:
```text
刚注入时输出不变;
训练时只有 A/B 更新;
保存时只保存 A/B。
```
### 3.3 LoRA 数据仍然走 SFTDataset
#### 源码证据 A:LoRA 训练复用 SFTDataset
文件:`trainer/train_lora.py:148-152`
看它是为了理解:LoRA 没有专门的数据集或 loss。
代码摘录:
```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)
```
这段代码说明:
- LoRA 训练数据仍然交给 `SFTDataset`。
- `SFTDataset` 负责 prompt 和 labels。
- optimizer 只接收 `lora_params`。
- scaler 和混合精度机制沿用普通训练脚本。
#### 源码证据 B:SFTDataset 的 labels 规则
文件:`dataset/lm_dataset.py:88-119`
看它是为了理解:LoRA 训练为什么还是 assistant-only loss。
代码摘录:
```python
labels = [-100] * len(input_ids)
...
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
...
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
...
return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
```
这段代码说明:
- 初始 labels 全部是 `-100`。
- 只有 assistant 回复段被改成真实 token id。
- cross entropy 会忽略 `-100` 区域。
- LoRA 训练目标仍然是让 assistant 回复 token 更可能出现。
理解到这一步就够:
```text
LoRA 的“参数高效”来自参数冻结,不来自改 SFT labels。
```
### 3.4 train_epoch 和普通 SFT 训练几乎一样
#### 源码证据 A:forward/loss/backward 没变
文件:`trainer/train_lora.py:25-49`
看它是为了理解:LoRA 训练循环仍然是 causal LM 训练。
代码摘录:
```python
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
loss = loss / args.accumulation_steps
scaler.scale(loss).backward()
if step % args.accumulation_steps == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(lora_params, args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
```
这段代码说明:
- forward 仍然调用 `model(input_ids, labels=labels)`。
- loss 仍然是 logits loss 加 MoE aux loss。
- 梯度累积仍然要除以 `accumulation_steps`。
- 梯度裁剪只裁剪 `lora_params`。
- optimizer step 只更新 LoRA 参数,因为 optimizer 只管理 LoRA 参数。
#### 源码证据 B:epoch 末尾 flush 残余梯度
文件:`trainer/train_lora.py:71-76`
看它是为了理解:LoRA 训练也继承了第 14 课的 gradient accumulation 尾批处理。
代码摘录:
```python
if last_step > start_step and last_step % args.accumulation_steps != 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(lora_params, args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
```
这段代码说明:
- 如果最后一组 micro-batch 不满 `accumulation_steps`,也要 step 一次。
- 这个逻辑和普通 pretrain/full_sft 一致。
- 区别仍然只是裁剪和更新的参数集合是 `lora_params`。
### 3.5 LoRA 保存和 resume 是两条线
#### 源码证据 A:训练中保存 adapter
文件:`trainer/train_lora.py:60-67`
看它是为了理解:普通保存和 LoRA adapter 保存有什么不同。
代码摘录:
```python
lora_save_path = f'{args.save_dir}/{args.lora_name}_{lm_config.hidden_size}{moe_suffix}.pth'
save_lora(model, lora_save_path)
lm_checkpoint(lm_config, weight=args.lora_name, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
```
这段代码说明:
- `save_lora(...)` 保存给推理使用的 LoRA adapter。
- `lm_checkpoint(...)` 保存断点续训需要的模型、optimizer、scaler、epoch、step。
- 推理主要用 `../out/lora_name_768.pth`。
- 断点续训主要用 `../checkpoints/lora_name_768_resume.pth`。
#### 源码证据 B:save_lora 只收集 module.lora
文件:`model/model_lora.py:45-53`
看它是为了理解:LoRA 权重文件为什么很小。
代码摘录:
```python
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)
```
这段代码说明:
- 只有带 `module.lora` 的模块会被保存。
- 保存内容来自 `module.lora.state_dict()`。
- base 模型权重不会进入 LoRA 文件。
- DDP 下可能出现的 `module.` 前缀会被处理。
教学版的 `lora_state_dict` key 可以不完全照原项目的 `.lora.` 命名,但必须满足:
```text
只保存 LoRALinear 里的 A.weight 和 B.weight
不保存 base.weight
```
### 3.6 推理时必须 base + adapter 一起加载
#### 源码证据:eval_llm 加载 LoRA
文件:`eval_llm.py:12-30`
看它是为了理解:训练保存的 LoRA 文件如何在推理入口被使用。
代码摘录:
```python
model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True)
if args.lora_weight != 'None':
apply_lora(model)
load_lora(model, f'./{args.save_dir}/{args.lora_weight}_{args.hidden_size}.pth')
...
return model.half().eval().to(args.device), tokenizer
```
这段代码说明:
- 推理先加载 base 权重,例如 `full_sft_768.pth`。
- 如果传了 `--lora_weight`,再注入 LoRA 结构。
- LoRA adapter 文件只负责填充注入后的 A/B。
- 最后模型进入 half、eval,并移动到设备。
因此推理命令的心智模型是:
```bash
python eval_llm.py --weight full_sft --lora_weight lora_medical
```
含义不是“只加载 lora_medical”,而是:
```text
加载 full_sft base
再叠加 lora_medical adapter
```
<a id="l19-must-write"></a>
## 4. 本节必须会写 / 暂时不要求
必须会写:
```text
1. 说明 LoRA 训练和 Full SFT 的共同点:
- 都用 SFTDataset
- 都用 causal LM loss
- 都有 lr / grad accumulation / scaler / grad clip
2. 说明 LoRA 训练和 Full SFT 的不同点:
- 加载 full_sft 作为 base
- 训练前注入 LoRA
- 只让 LoRA A/B requires_grad=True
- optimizer 只接收 lora_params
- 保存 adapter,而不是保存完整模型
3. 手写 apply_lora_to_linear_layers:
- 遍历模型子模块
- 找到目标 nn.Linear
- 用 LoRALinear 包装或替换它
- 返回注入数量
4. 手写最小 train_lora_impl.py 的主流程:
- parse args
- load tokenizer / model
- apply LoRA
- mark only LoRA trainable
- build SFT dataset
- build AdamW(lora_params)
- run short train loop
- save lora_state_dict
```
暂时不要求:
```text
1. 真实医疗数据训练效果
2. 多 target module 配置
3. LoRA 权重合并导出
4. HuggingFace / llama.cpp / ollama 格式转换
5. DDP 下的完整恢复细节
6. rank / alpha / dropout 系统调参
```
<a id="l19-handwrite"></a>
## 5. 手写模块
本节涉及两个文件:
```text
course/impl/core/lora.py
course/impl/train_lora_impl.py
```
### 5.1 补 `apply_lora_to_linear_layers`
接口:
```python
def apply_lora_to_linear_layers(
model: nn.Module,
rank: int = 16,
alpha: float | None = None,
dropout: float = 0.0,
square_only: bool = True,
) -> int:
...
```
它要对齐的源码:
```text
model/model_lora.py::apply_lora
```
教学版建议行为:
```text
遍历 model.named_children()
-> 如果 child 是 nn.Linear 且满足筛选条件
-> 用 LoRALinear(child, rank, alpha, dropout) 替换这个 child
-> 如果 child 不是目标 Linear,则递归处理它的子模块
-> 返回被替换的 Linear 数量
```
筛选规则:
```text
square_only=True:
只替换 in_features == out_features 的 Linear
square_only=False:
替换所有 Linear
```
`alpha` 的默认值建议:
```text
alpha = rank
```
这样教学版默认与 MiniMind 原源码一致:
```text
base(x) + B(A(x))
```
而不是:
```text
base(x) + alpha/r * B(A(x)) # alpha != rank 时才有额外缩放
```
注意:
```text
不要直接遍历 named_modules 后 setattr 当前 module 自己。
```
原因是 `named_modules()` 会递归返回所有后代模块,但替换子模块需要知道它的父模块。教学版更适合用 `named_children()` 递归,因为 `setattr(parent, child_name, wrapped_child)` 更清楚。
### 5.2 保留第 18 课的冻结函数
接口:
```python
def mark_only_lora_as_trainable(model: nn.Module) -> list[nn.Parameter]:
...
```
它在第 19 课中的位置是:
```text
apply_lora_to_linear_layers(model)
-> mark_only_lora_as_trainable(model)
-> optimizer = AdamW(lora_params, lr=...)
```
验收点:
```text
model.named_parameters() 里只有 A.weight / B.weight requires_grad=True
optimizer 的参数就是这两个集合
base.weight 不会被 optimizer 更新
```
### 5.3 保留第 18 课的保存函数
接口:
```python
def lora_state_dict(model: nn.Module) -> dict[str, torch.Tensor]:
...
```
它在第 19 课中的位置是:
```text
训练结束或 save_interval 到达
-> state = lora_state_dict(model)
-> torch.save(state, adapter_path)
```
验收点:
```text
state_dict 只包含 LoRA A/B
不包含 base.weight
保存出来的文件可以和 base 模型重新组合
```
### 5.4 组装 `train_lora_impl.py`
`course/impl/train_lora_impl.py` 是教学版脚本。它不需要复制原项目所有工程能力,只要跑通 LoRA SFT 的最小闭环。
建议参数:
```text
--tokenizer_path model
--data_path course/labs/tiny_sft.jsonl
--from_weight course_sft
--save_weight course_lora
--save_dir course/impl/out
--hidden_size 64
--num_hidden_layers 2
--max_seq_len 128
--rank 4
--alpha 4
--epochs 1
--batch_size 1
--learning_rate 1e-4
--device cpu
```
教学版主流程:
```text
1. 读取 args
2. 加载 tokenizer
3. 构造 CourseMiniMindForCausalLM 或复用 MiniMindForCausalLM
4. 加载 from_weight 对应的 base 权重
5. apply_lora_to_linear_layers(model, rank, alpha)
6. lora_params = mark_only_lora_as_trainable(model)
7. train_ds = CourseSFTDataset 或 SFTDataset
8. optimizer = AdamW(lora_params, lr)
9. 复用 train loop 跑少量 step
10. torch.save(lora_state_dict(model), adapter_path)
```
如果前面课程的教学版 CausalLM / train loop 还没补完,可以先只完成第 19 课的轻量注入测试,不强行跑完整 `train_lora_impl.py`。
<a id="l19-alignment-test"></a>
## 6. 对齐测试
本节新增测试:
```text
course/impl/tests/test_lora_injection.py
```
运行命令:
```bash
cd /home/sun/minimind
python course/impl/tests/test_lora_core.py
python course/impl/tests/test_lora_injection.py
```
第一个测试来自第 18 课,检查单个 `LoRALinear`、冻结函数和保存函数。
第二个测试检查第 19 课新增能力:
```text
1. apply_lora_to_linear_layers 能把目标 Linear 替换成 LoRALinear。
2. square_only=True 时只替换方阵 Linear。
3. 因为 B 初始化为 0,注入前后输出一致。
4. mark_only_lora_as_trainable 后只有 A/B 可训练。
5. lora_state_dict 只导出 A/B。
```
还没实现 `apply_lora_to_linear_layers` 时,测试会提示:
```text
TODO not implemented yet: Implement in the LoRA lesson.
```
补完后应该看到类似:
```text
injected_lora_layers=2
initial_output_max_abs_diff=0.000000000000
trainable_names=['0.A.weight', '0.B.weight', '2.0.A.weight', '2.0.B.weight']
lora_state_keys=['0.A.weight', '0.B.weight', '2.0.A.weight', '2.0.B.weight']
lora_injection=passed
```
### 6.1 真实训练命令不是当前必跑验收
如果本地已经有:
```text
out/full_sft_768.pth
dataset/lora_medical.jsonl
```
可以跑原项目 LoRA 训练:
```bash
cd /home/sun/minimind/trainer
python train_lora.py \
--from_weight full_sft \
--lora_name lora_medical \
--data_path ../dataset/lora_medical.jsonl \
--epochs 1
```
当前课程环境更适合把这个命令当作“有数据和权重后的实战命令”,不是第 19 课的必跑验收。
原因:
```text
1. 默认 full_sft 权重可能不存在。
2. 默认 lora_medical 数据可能不存在。
3. CPU 环境跑完整训练没有学习必要。
4. 第 19 课的核心是参数注入、冻结和保存边界。
```
<a id="l19-stage-assembly"></a>
## 7. 阶段组装
LoRA 阶段现在有三层产物:
```text
第 18 课:
LoRALinear
mark_only_lora_as_trainable
lora_state_dict
第 19 课:
apply_lora_to_linear_layers
train_lora_impl.py 最小流程
原项目能力:
trainer/train_lora.py
model/model_lora.py
eval_llm.py --lora_weight
```
### 7.1 教学版和原项目差异
| 位置 | 原项目 | 教学版 |
|---|---|---|
| 注入方式 | 给 Linear 挂 `module.lora` 并替换 `forward` | 用 `LoRALinear(base)` 替换目标 Linear |
| 缩放 | 没有显式 `alpha/r` | 有 `alpha/r`,默认 `alpha=rank` |
| 目标模块 | 只处理方阵 Linear | 默认同样只处理方阵 Linear |
| 保存 key | `name.lora.A.weight` / `name.lora.B.weight` | `name.A.weight` / `name.B.weight` |
| 完整训练 | 支持 DDP、resume、swanlab | 先跑通 tiny/CPU 最小闭环 |
| compile | monkey-patch 下关闭 | 包装模块更容易测试,仍可先不讲 compile |
这些差异不是错误。课程目标是让你能解释核心机制,而不是复制所有工程外围。
### 7.2 阶段验收顺序
建议按这个顺序验收:
```bash
cd /home/sun/minimind
python course/impl/tests/test_lora_core.py
python course/impl/tests/test_lora_injection.py
```
如果前面教学版 SFT 阶段也已经完成,再跑:
```bash
python course/impl/train_lora_impl.py \
--tokenizer_path model \
--data_path course/labs/tiny_sft.jsonl \
--from_weight course_sft \
--save_weight course_lora \
--save_dir course/impl/out \
--hidden_size 64 \
--num_hidden_layers 2 \
--max_seq_len 128 \
--rank 4 \
--alpha 4 \
--epochs 1 \
--batch_size 1 \
--learning_rate 1e-4 \
--device cpu
```
如果原项目真实权重和数据齐备,再跑:
```bash
cd /home/sun/minimind/trainer
python train_lora.py --from_weight full_sft --lora_name lora_medical --epochs 1
```
### 7.3 Portfolio 记录
完成本节后,可以在 `course/portfolio/implementation.md` 记录:
```text
实现了教学版 LoRA 注入流程:
- 用 LoRALinear 包装目标 Linear。
- 支持 square_only 筛选。
- 验证 B=0 初始化使注入前后输出一致。
- 冻结 base 参数,只训练 LoRA A/B。
- 只导出 adapter state_dict。
```
在 `course/notes/mistakes.md` 记录容易错的点:
```text
1. 先建 optimizer 再注入 LoRA,导致 optimizer 没有 LoRA 参数。
2. 遍历 named_modules 时不知道父模块,无法正确 setattr。
3. 忘记 B 初始化为 0,导致注入后输出改变。
4. 保存 state_dict 时把 base.weight 也保存了。
5. 推理时只加载 LoRA 文件,忘记先加载 base。
```
<a id="l19-check"></a>
## 8. 本节检查
1. LoRA 训练为什么默认从 `full_sft` 权重开始,而不是从 `pretrain` 或 `none` 开始?
2. 为什么必须在构建 optimizer 之前调用 `apply_lora`?
3. LoRA 训练和 Full SFT 的数据、labels、loss 有什么相同点?
4. `clip_grad_norm_` 为什么只传入 `lora_params`?
5. `save_lora` 为什么不能保存完整 base 权重?
6. 推理时 `--weight full_sft --lora_weight lora_medical` 分别代表什么?
7. 教学版用 `LoRALinear(base)` 替换 Linear,和原项目 monkey-patch forward 的共同目标是什么?
<a id="l19-next"></a>
## 9. 下一课
第 20 课进入模型蒸馏训练链路。
下一课要解决:
```text
teacher/student 模型如何同时参与训练;
distillation loss 和普通 causal LM loss 如何组合;
teacher logits 为什么通常不需要反向传播;
MiniMind 的 train_distillation.py 如何组织数据、模型和 optimizer。
```