minimind/course/19_lora_training_flow.md

26 KiB
Raw Blame History

第 19 课:LoRA 训练流程

这一课只解决一个问题:MiniMind 的 train_lora.py 如何把第 18 课的 LoRA 核心模块接进 SFT 训练,只更新低秩 adapter,并把 adapter 单独保存下来。

目录

0. 本节主线

LoRA 训练流程是:

加载 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 课解决的是:

一个 Linear 旁边怎么加 BA 低秩分支

第 19 课解决的是:

整棵模型什么时候注入 LoRA,怎么只训练 LoRA,怎么保存和推理

一句话:

LoRA 训练不是换一种 loss,而是换一种可训练参数集合。

SFT 的数据、prompt、labels、causal LM loss 都沿用第 16-17 课;变化只在模型参数和保存方式。

1. 原理讲解

1.1 LoRA 训练为什么从 full_sft 开始

LoRA 微调通常不是从随机初始化或纯 pretrain 权重开始。它要在一个已经会对话的基座上学习少量新行为,例如身份、领域知识、格式习惯。

在 MiniMind 里,LoRA 脚本默认:

from_weight = full_sft

这表示:

先加载已经 SFT 过的完整模型
再注入 LoRA
然后只训练 LoRA adapter

如果基座还不会按 chat template 回复,LoRA adapter 需要同时学会基础对话和新任务,这就不再是轻量适配。

1.2 为什么要先注入 LoRA,再冻结参数

冻结参数的目标是:

base 权重不更新
LoRA A/B 更新

所以顺序很重要:

先 apply_lora(model)
再遍历 model.named_parameters()
根据参数名或模块类型决定 requires_grad

如果先冻结,再注入 LoRA,后注入的 LoRA 参数默认仍可能是可训练的,但统计和 optimizer 收集容易漏掉;如果先建 optimizer,再注入 LoRA,optimizer 根本拿不到新参数。

正确心智模型是:

模型结构最终长什么样
-> 哪些参数可训练
-> optimizer 管哪些参数

1.3 LoRA 训练仍然是 SFT 训练

LoRA 不是新的训练目标。MiniMind 的 LoRA 训练仍然读取 SFT 数据:

conversations
-> chat template
-> input_ids
-> assistant labels
-> causal LM loss

所以第 5、16、17 课讲过的 SFT labels 规则继续成立:

user/system/tool prompt 区域 label = -100
assistant 回复区域 label = token id

LoRA 改变的是:

哪些参数会被 optimizer 更新

不是:

数据格式
loss 公式
forward 输出

1.4 为什么 optimizer 只接收 LoRA 参数

即使 base 参数 requires_grad=False,更清晰的做法仍然是:

optimizer = AdamW(lora_params, lr=...)

这让训练边界很明确:

backward 可以经过整棵模型
optimizer.step 只更新 LoRA A/B

梯度裁剪也只对 lora_params 做:

clip_grad_norm_(lora_params, grad_clip)

这样参数统计、显存、保存和调试都围绕 adapter 展开。

1.5 为什么 LoRA 只保存 adapter

LoRA 训练的产物不是完整模型,而是低秩增量。

保存完整模型会浪费空间,也会模糊基座和 adapter 的关系。MiniMind 的做法是:

base 权重:full_sft_768.pth
LoRA 权重:lora_medical_768.pth

推理时组合:

加载 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 时会关闭它。

教学版建议用包装模块:

nn.Linear -> LoRALinear(base_linear)

这种方式比 monkey-patch 更容易测试,也更适合课程里做模块替换练习。理解原项目时要知道:原项目是给 Linear 挂 module.lora 并改 forward;教学版是把目标 Linear 换成 LoRALinear。

2. 源码阅读顺序图

先读第 18 课的 LoRA 核心,再读训练脚本:

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

建议按这个顺序读:

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

3. MiniMind 源码走读

3.1 LoRA 训练脚本默认从 full_sft 加载

源码证据 A:LoRA 训练参数

文件:trainer/train_lora.py:79-101

看它是为了理解:LoRA 训练的默认基座、数据、学习率和保存名。

代码摘录:

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' 最终如何变成一个本地权重文件。

代码摘录:

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 后缀。

理解到这一步就够:

LoRA 训练之前,模型已经有一套完整 base 权重。

暂时不要看:

AutoTokenizer 内部加载细节
strict=False 对每个 key 的兼容细节

3.2 apply_lora 必须在冻结和 optimizer 之前

源码证据 A:训练脚本中的调用顺序

文件:trainer/train_lora.py:128-146

看它是为了理解:模型结构、可训练参数和 optimizer 参数集合的顺序关系。

代码摘录:

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 实际改变了什么。

代码摘录:

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,刚注入时模型输出不应该改变。

教学版和原项目的差异:

原项目:给 Linear 挂 module.lora,并 monkey-patch forward。
教学版:把目标 Linear 替换成 LoRALinear(base)。

两者要对齐的行为是:

刚注入时输出不变;
训练时只有 A/B 更新;
保存时只保存 A/B。

3.3 LoRA 数据仍然走 SFTDataset

源码证据 A:LoRA 训练复用 SFTDataset

文件:trainer/train_lora.py:148-152

看它是为了理解:LoRA 没有专门的数据集或 loss。

代码摘录:

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。

代码摘录:

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 更可能出现。

理解到这一步就够:

LoRA 的“参数高效”来自参数冻结,不来自改 SFT labels。

3.4 train_epoch 和普通 SFT 训练几乎一样

源码证据 A:forward/loss/backward 没变

文件:trainer/train_lora.py:25-49

看它是为了理解:LoRA 训练循环仍然是 causal LM 训练。

代码摘录:

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 尾批处理。

代码摘录:

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 保存有什么不同。

代码摘录:

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 权重文件为什么很小。

代码摘录:

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. 命名,但必须满足:

只保存 LoRALinear 里的 A.weight 和 B.weight
不保存 base.weight

3.6 推理时必须 base + adapter 一起加载

源码证据:eval_llm 加载 LoRA

文件:eval_llm.py:12-30

看它是为了理解:训练保存的 LoRA 文件如何在推理入口被使用。

代码摘录:

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,并移动到设备。

因此推理命令的心智模型是:

python eval_llm.py --weight full_sft --lora_weight lora_medical

含义不是“只加载 lora_medical”,而是:

加载 full_sft base
再叠加 lora_medical adapter

4. 本节必须会写 / 暂时不要求

必须会写:

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

暂时不要求:

1. 真实医疗数据训练效果
2. 多 target module 配置
3. LoRA 权重合并导出
4. HuggingFace / llama.cpp / ollama 格式转换
5. DDP 下的完整恢复细节
6. rank / alpha / dropout 系统调参

5. 手写模块

本节涉及两个文件:

course/impl/core/lora.py
course/impl/train_lora_impl.py

5.1 补 apply_lora_to_linear_layers

接口:

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:
    ...

它要对齐的源码:

model/model_lora.py::apply_lora

教学版建议行为:

遍历 model.named_children()
-> 如果 child 是 nn.Linear 且满足筛选条件
-> 用 LoRALinear(child, rank, alpha, dropout) 替换这个 child
-> 如果 child 不是目标 Linear,则递归处理它的子模块
-> 返回被替换的 Linear 数量

筛选规则:

square_only=True:
  只替换 in_features == out_features 的 Linear

square_only=False:
  替换所有 Linear

alpha 的默认值建议:

alpha = rank

这样教学版默认与 MiniMind 原源码一致:

base(x) + B(A(x))

而不是:

base(x) + alpha/r * B(A(x))   # alpha != rank 时才有额外缩放

注意:

不要直接遍历 named_modules 后 setattr 当前 module 自己。

原因是 named_modules() 会递归返回所有后代模块,但替换子模块需要知道它的父模块。教学版更适合用 named_children() 递归,因为 setattr(parent, child_name, wrapped_child) 更清楚。

5.2 保留第 18 课的冻结函数

接口:

def mark_only_lora_as_trainable(model: nn.Module) -> list[nn.Parameter]:
    ...

它在第 19 课中的位置是:

apply_lora_to_linear_layers(model)
-> mark_only_lora_as_trainable(model)
-> optimizer = AdamW(lora_params, lr=...)

验收点:

model.named_parameters() 里只有 A.weight / B.weight requires_grad=True
optimizer 的参数就是这两个集合
base.weight 不会被 optimizer 更新

5.3 保留第 18 课的保存函数

接口:

def lora_state_dict(model: nn.Module) -> dict[str, torch.Tensor]:
    ...

它在第 19 课中的位置是:

训练结束或 save_interval 到达
-> state = lora_state_dict(model)
-> torch.save(state, adapter_path)

验收点:

state_dict 只包含 LoRA A/B
不包含 base.weight
保存出来的文件可以和 base 模型重新组合

5.4 组装 train_lora_impl.py

course/impl/train_lora_impl.py 是教学版脚本。它不需要复制原项目所有工程能力,只要跑通 LoRA SFT 的最小闭环。

建议参数:

--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

教学版主流程:

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。

6. 对齐测试

本节新增测试:

course/impl/tests/test_lora_injection.py

运行命令:

cd /home/sun/minimind
python course/impl/tests/test_lora_core.py
python course/impl/tests/test_lora_injection.py

第一个测试来自第 18 课,检查单个 LoRALinear、冻结函数和保存函数。

第二个测试检查第 19 课新增能力:

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 时,测试会提示:

TODO not implemented yet: Implement in the LoRA lesson.

补完后应该看到类似:

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 真实训练命令不是当前必跑验收

如果本地已经有:

out/full_sft_768.pth
dataset/lora_medical.jsonl

可以跑原项目 LoRA 训练:

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 课的必跑验收。

原因:

1. 默认 full_sft 权重可能不存在。
2. 默认 lora_medical 数据可能不存在。
3. CPU 环境跑完整训练没有学习必要。
4. 第 19 课的核心是参数注入、冻结和保存边界。

7. 阶段组装

LoRA 阶段现在有三层产物:

第 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 阶段验收顺序

建议按这个顺序验收:

cd /home/sun/minimind
python course/impl/tests/test_lora_core.py
python course/impl/tests/test_lora_injection.py

如果前面教学版 SFT 阶段也已经完成,再跑:

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

如果原项目真实权重和数据齐备,再跑:

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 记录:

实现了教学版 LoRA 注入流程:
- 用 LoRALinear 包装目标 Linear。
- 支持 square_only 筛选。
- 验证 B=0 初始化使注入前后输出一致。
- 冻结 base 参数,只训练 LoRA A/B。
- 只导出 adapter state_dict。

在 course/notes/mistakes.md 记录容易错的点:

1. 先建 optimizer 再注入 LoRA,导致 optimizer 没有 LoRA 参数。
2. 遍历 named_modules 时不知道父模块,无法正确 setattr。
3. 忘记 B 初始化为 0,导致注入后输出改变。
4. 保存 state_dict 时把 base.weight 也保存了。
5. 推理时只加载 LoRA 文件,忘记先加载 base。

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 的共同目标是什么?

9. 下一课

第 20 课进入模型蒸馏训练链路。

下一课要解决:

teacher/student 模型如何同时参与训练;
distillation loss 和普通 causal LM loss 如何组合;
teacher logits 为什么通常不需要反向传播;
MiniMind 的 train_distillation.py 如何组织数据、模型和 optimizer。