TT Lab
开始
学习 学习路径 课程

MiniMind — 亲手从头到尾训练一个小型语言模型

用 MiniMind 的 LoRA 只改风格,并确认基座权重未变

在 TT Lab 中继续学习

目标

用 MiniMind model_lora.py 的 apply_lora、save_lora、load_lora,在 SFT 基准模型上叠加 rank 8 的 LoRA。冻结基础权重,只训练 LoRA,把回答改成猫咪口吻(句末带猫叫语气词的说法),并确认基础权重是否一个比特都没变,以及合并后的模型是否给出相同的输出。

为什么重要

LoRA 有两个承诺——只训练很少的参数,基础模型保持不动。前者减少内存和存储,后者让多个 LoRA 可以在同一个基础模型上互相替换。但只要漏掉了冻结,这两个承诺就会悄悄地一起破裂。训练很顺利,口吻也变了,可是基础权重也动了,其他 LoRA 全都出错。 所以这个实验不只看结果(口吻),还要把训练之后的基础权重与原件逐个张量比较。MiniMind 的实现没有 alpha 倍率,并且只挂在维度相同的 Linear 上,这两点也用代码确认。

步骤

  1. 加载 /opt/mm/ref/sft.pth,执行 apply_lora(model, rank=8),把挂上了 LoRA 的模块名称以 rank、targets 写入 /root/mm/lora/targets.json。
  2. 把叠加之前和刚叠加之后的 logits 最大差,以 max_abs_diff 写入 /root/mm/lora/zero.json。
  3. 把名称里没有 lora 的参数全部冻结,把要训练的参数数、总数和比例写入 /root/mm/lora/params.json。
  4. 用 lora_nyang.jsonl 只训练 LoRA 150 步,用 save_lora 保存为 /root/mm/lora/lora.pth。
  5. 把训练结束后的模型中去掉 LoRA 的权重保存为 /root/mm/lora/base_after.pth。它必须与原来的 SFT 权重相同。
  6. 在留出的问题(lora_nyang_val.jsonl)上,分别测量不带 LoRA 和带 LoRA 时以猫咪语气词(相当于“喵”)收尾的回答比例,写入 /root/mm/lora/effect.json。
  7. 把用 W + B·A 合并后的权重保存为 /root/mm/lora/merged.pth。
  8. 在 /root/mm/lora/report.md 中写 ## 어디에 붙나、## 무엇이 바뀌나、## 합치기 三节(三个标题为韩文,依次意为“挂在哪里”“什么发生了变化”“合并”),并放入训练的参数数和叠加 LoRA 之后的猫咪语气词比例。

参考

LoRA 挂在哪里

用 mmkit.load_model("/opt/mm/ref/sft.pth") 加载,执行 apply_lora(model, rank=8) 之后,把出现了 lora 属性的模块名称按顺序,以 {"rank": 8, "targets": [...]} 写入 /root/mm/lora/targets.json。

遍历 model.named_modules(),把满足 hasattr(m, 'lora') 的汇总起来即可。apply_lora 只挂在 in_features == out_features 的 Linear 上——先猜猜在我们的模型里,哪些投影满足这个条件。

刚叠加上去时什么都没变

求出 /opt/mm/ref/val.npy 最前面 64 个令牌在 apply_lora 之前和刚叠加之后的 logits,把最大绝对差以 max_abs_diff 写入 /root/mm/lora/zero.json。

MiniMind 的 LoRA 把 A 初始化为正态分布,把 B 初始化为 0。B·A 为 0,所以旁支的输出为 0,训练是从 0 开始学习“该偏离基础模型多少”。

冻结成只训练 LoRA

开始编写训练脚本 /root/mm/lora/train_lora.py——在叠加了 LoRA 的模型中,名称里没有 lora 的参数设为 requires_grad=False,有的设为 True,并把要训练的参数数(trainable)、总数(total)和比例(ratio)写入 /root/mm/lora/params.json。

这就是 MiniMind 的 train_lora.py 所用的规则。每处是 A(8×128) + B(128×8),再乘以挂载的处数就行。总数中也包含 LoRA 参数。

用 LoRA 学会口吻

用 mmkit.sft_encode 转换 /opt/mm/data/lora_nyang.jsonl,只把 LoRA 参数交给 AdamW(lr 5e-3)训练 150 步(批次 16、随机种子 42),并用 save_lora(model, "/root/mm/lora/lora.pth") 保存为 /root/mm/lora/lora.pth。

save_lora 只把挂了 LoRA 的每个模块的 …lora.A.weight、…lora.B.weight 以 fp16 保存。拿文件大小与基础模型(4MB 多一点)比一比。评分器会检查键是否只有 LoRA,以及 B 有没有从 0 动起来。

基础权重还是原样吗

把训练结束后模型的 state_dict 中,去掉名称里含 .lora. 的部分,其余保存为 /root/mm/lora/base_after.pth。评分器会逐个张量与原来的 /opt/mm/ref/sft.pth 比较。

如果冻结做对了,就连一个比特都不会不同。如果没有关掉 requires_grad,就把所有参数交给了优化器,基础权重也会接收梯度而移动,就会在这里被抓到。PyTorch 的优化器会跳过没有梯度(None)的参数,所以只要冻结做对了,无论交给优化器什么,基础部分都不会变。

口吻变了多少

让 lora_nyang_val.jsonl 的 60 个问题用贪心解码回答,在基础模型(base_rate)和叠加了 LoRA 的模型(lora_rate)上,分别测量以猫咪语气词(相当于“喵”)收尾的回答比例,写入 /root/mm/lora/effect.json。

叠加了 LoRA 的模型,是加载基础模型,apply_lora(rank=8) 之后再 load_lora(model, 경로)(占位符为文件路径)得到的。留出的问题是 LoRA 训练时没见过的组合,要看的是口吻学成了“回答的方式”,而不是“提问的形式”。

合并,消除旁支

用 lora.pth 的 A、B,把 B @ A 加到对应模块的权重上(不带 alpha 倍率),把合并后的 state_dict 保存为 /root/mm/lora/merged.pth。评分器会把这份权重放进不带 LoRA 的模型,看它是否给出与叠加了 LoRA 的模型相同的 logits。

A 的形状是(rank,输入),B 是(输出,rank),所以 B @ A 是(输出,输入)——与原来的 weight 形状相同。MiniMind 的 merge_lora 做的也是同一件事,并以 fp16 保存。这里为了便于比较,以 fp32 保存。

确认 LoRA 承诺的记录

在 /root/mm/lora/report.md 中写 ## 어디에 붙나、## 무엇이 바뀌나、## 합치기 三节(三个标题为韩文,依次意为“挂在哪里”“什么发生了变化”“合并”),并以数字放入第 3 步的 trainable 和第 6 步的 lora_rate。

也请用一行写出 LoRA 文件是基础模型的几分之一,以及由于这个实现没有 alpha 倍率,改变 rank 时要重新看什么。