26 KiB
第 19 课:LoRA 训练流程
这一课只解决一个问题:MiniMind 的 train_lora.py 如何把第 18 课的 LoRA 核心模块接进 SFT 训练,只更新低秩 adapter,并把 adapter 单独保存下来。
目录
- 0. 本节主线
- 1. 原理讲解
- 2. 源码阅读顺序图
- 3. MiniMind 源码走读
- 4. 本节必须会写 / 暂时不要求
- 5. 手写模块
- 6. 对齐测试
- 7. 阶段组装
- 8. 本节检查
- 9. 下一课
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. 本节检查
- LoRA 训练为什么默认从
full_sft权重开始,而不是从pretrain或none开始? - 为什么必须在构建 optimizer 之前调用
apply_lora? - LoRA 训练和 Full SFT 的数据、labels、loss 有什么相同点?
clip_grad_norm_为什么只传入lora_params?save_lora为什么不能保存完整 base 权重?- 推理时
--weight full_sft --lora_weight lora_medical分别代表什么? - 教学版用
LoRALinear(base)替换 Linear,和原项目 monkey-patch forward 的共同目标是什么?
9. 下一课
第 20 课进入模型蒸馏训练链路。
下一课要解决:
teacher/student 模型如何同时参与训练;
distillation loss 和普通 causal LM loss 如何组合;
teacher logits 为什么通常不需要反向传播;
MiniMind 的 train_distillation.py 如何组织数据、模型和 optimizer。