参数高效微调
上一章,我们动了模型里的全部参数。对只有几万个参数的小模型,这很轻松;如果模型大得多,而你只想让它学会一种输出格式,是否也必须准备一整套梯度和优化器状态?
参数高效微调试着换一种做法:保留大部分基础权重,只更新较少的参数。少动一些数,可能让训练和保存更省,也可能限制适应能力。到底省了哪里、效果怎样,我们仍用上一章的提取任务来比较。
1. 四种做法,先摆在一起
| 做法 | 主要更新什么 | 需要额外留意什么 |
|---|---|---|
| 全量微调 | 原模型的全部或大部分权重 | 梯度、优化器状态和任务版本较大 |
| Adapter | 插入网络的小模块 | 多出计算路径,部署需要相应结构 |
| LoRA | 若干权重矩阵旁的低秩增量 | 注入位置、秩和缩放都影响行为 |
| 量化基础权重加 LoRA | 低精度存储的基础权重保持冻结,训练增量 | 计算精度、量化误差、算子与硬件支持 |
“参数高效”主要描述需要训练的参数少,不能直接翻译成“任何时候都更快、更准、显存只剩同样比例”。基础模型仍然要参与前向计算,许多激活也仍然需要保留。
2. Adapter:在原网络里加一小段可训练路径
假设某一层输出一个 维向量 。一种瓶颈 Adapter 先把它压到 维,再经过非线性变换、升回 维,并加回原输入:
这里 的形状是 , 是 ;为突出结构,公式省略偏置。当 比 小得多时,这一小段只有约 个权重。
Houlsby 等人的 Adapter 研究 展示了冻结基础网络、为不同任务保留少量新增模块的思路。不同方法把模块插在不同位置,具体实现不只有这一种。
非线性模块通常不能简单地合并成原来某个线性矩阵,所以服务时也要执行它。是否值得,要结合任务效果、请求量与部署方式判断。
3. LoRA:把一个大增量拆成两个小矩阵
从全量更新说起
一个线性层原来计算:
的形状是 ,输入 有 个分量,输出有 个分量。全量微调可以把每个权重都改掉,得到 。
LoRA 让基础权重 保持不变,把增量写成两个小矩阵的乘积:
是秩的上限, 是缩放系数。所谓“低秩”,可以先理解成:输入先经过一个只有 个通道的中间空间,再组合成输出增量。因此, 最多提供 个独立方向的变化。
需要训练的参数从一个完整矩阵的 个,变成:
例如,、,完整矩阵有 1,048,576 个权重,两个小矩阵合计 16,384 个。基础矩阵依然存在,只是不再为它准备训练梯度和 Adam 历史状态。
图 1:冻结的是基础参数,输出依然由基础路径与增量路径共同决定。图中省略已有偏置,实际实现会保留它。
为什么一开始只把一个矩阵设为零
我们把 随机初始化,把 设为零。于是最初 ,新增模块不会立刻改变基础模型的输出。
如果 和 都设为零,情况反而不妙。增量对 的梯度依赖 ,对 的梯度依赖 ,两边都可能从零开始卡住。只让 为零时,第一步可以先更新 ,随后两边一起学习。
这并不是把全部梯度关闭。冻结权重通常用 requires_grad_(False);如果把整个模型前向包在 no_grad() 中,LoRA 参数需要的梯度路径也会一起被切断。
行向量批次里的写法
PyTorch 把一批输入放在最后一维为 的张量中,线性层存放的权重形状是 。本例计算:
base(x) + (x @ A.T @ B.T) * (alpha / rank)
这与前面的列向量公式是同一件事,只是批次组织方式不同。
LoRA 原始论文 研究了这种低秩适应。实际使用时,秩不是唯一选择:更新注意力的哪些投影,是否包括前馈层与输出层,也会改变参数量和效果。
4. 保存与合并:小文件也有自己的依赖
训练后,可以只保存 、、缩放和注入位置;加载时仍需配套的基础权重、词表与网络配置。
“都是同一个系列的模型”还不够。基础版本换了、词表顺序改了、适配器注入位置变了,增量就可能接到错误的地方。因此,本例在适配器文件里保存基础权重指纹和配置,加载时检查是否一致。
推理时,如果基础层是普通线性层,可以先合并:
然后用一个线性层计算,不必再走两条分支。实数运算下两者等价;浮点加法顺序不同,结果可能存在很小的舍入差异。
如果基础权重是量化存储,合并与重新量化还会引入误差,并涉及相应格式,不能照搬这里的双精度合并保证。多个适配器同时组合也需要重新评估,不能因为每个单独工作正常,就假定相加以后仍然正常。
5. 动手:同一个任务,换成 LoRA
本实验沿用上一章的基础模型、192 条指令记录、验证与测试划分。比较两组:全量微调,以及秩为 4、 的 LoRA。
为便于检查,我们给网络中所有 9 个线性层加 LoRA:两层 Transformer 各有合并的 QKV 投影、注意力输出投影和两层前馈投影,再加最后的词表投影。嵌入、位置表和归一化参数都冻结。合并的 QKV 层共享一个低秩输入路径,与分别给 Q、K、V 设置适配器并不完全相同。
两组使用同样的 160 次更新、指令批次、0.001 学习率和优化器设置。没有为 LoRA 另外搜索最优秩、学习率或训练长度。因此,这是明确设置下的比较,不是对两种方法各自最好成绩的评比。
运行:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/low_rank_adaptation.py
默认会重新训练第 26 章的基础模型。如果已经保存该章快照,可以复用并留下适配器:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/low_rank_adaptation.py --base runs/instruction-base.pt --save-adapter runs/extraction-lora.pt --out runs/lora.json
单独下载时,需要 low_rank_adaptation.py 与上一章的三个脚本:instruction_tuning.py、tiny_transformer.py、small_language_model.py。它们放在同一目录。
默认输出如下:
基础参数=28,992;两组均用 160 次更新、相同指令批次
全量微调:可训练参数=28,992;验证回答损失=0.002;测试回答=64/64;旧续写=0/32
权重=226.5 KiB;梯度=226.5 KiB;优化器=453.1 KiB;合计=906.1 KiB
LoRA rank=4:可训练参数=4,396;验证回答损失=0.373;测试回答=46/64;旧续写=0/32
权重=260.8 KiB;梯度=34.3 KiB;优化器=68.8 KiB;合计=363.9 KiB
初始输出与基础模型完全相同:True
训练后冻结参数完全不变:True
保存/加载适配器后输出完全相同:True
合并后输出误差小于 1e-12:True
本例的可训练参数降到约 15.2%,状态张量总量降到全量方案的约 40.2%。基础权重仍要保存,另外多了 LoRA 权重,所以“权重”一项反而略大;主要节省来自梯度与优化器状态。
图 2:按脚本中的双精度 CPU 张量统计,单位为 KiB。不是 GPU 峰值显存,也不包括激活、工作区与运行时开销。
同样的更新次数不代表完全相同的训练 FLOPs。LoRA 增加了小矩阵路径,省掉某些冻结权重的梯度计算,但仍需把梯度穿过相关运算。模型大小、注入位置和硬件利用率都会影响真实耗时。
省下状态,效果也需要重新检查
本次 LoRA 测试回答为 46/64,低于全量微调的 64/64。更大的秩、不同目标层或学习率可能改变结果,但那需要新实验。我们不能把“参数少”直接当作“效果相同”。
更值得留意的是旧续写:两组都是 0/32。LoRA 的基础权重确实一项没动,加上增量后的整个函数仍然改变了,旧行为照样可能受影响。
若停用适配器并重新使用保留的基础模型,当然可以回到原权重对应的行为;但那并不说明带着适配器时同时保住了新旧任务。想让两者一起表现好,需要继续评价,例如加入上一章的旧文本混合训练。
展开完整代码:注入、训练、保存、加载与合并
"""第 27 章:手写 LoRA,比较训练状态,并保存、加载和合并低秩增量。"""
import argparse
from copy import deepcopy
from hashlib import sha256
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn
from tiny_transformer import TinyTransformer
from small_language_model import corpus
from instruction_tuning import (prepare, setup, vocabulary, instruction_records,
finetune, metrics, supervised_data)
class LoRALinear(nn.Module):
def __init__(self, base, rank=4, alpha=4):
super().__init__()
if not 1 <= rank <= min(base.in_features, base.out_features):
raise ValueError("示例要求 rank 不超过矩阵较短的一边")
self.base = base
self.base.requires_grad_(False)
self.rank, self.alpha = rank, alpha
self.A = nn.Parameter(torch.randn(rank, base.in_features, dtype=base.weight.dtype)*.02)
self.B = nn.Parameter(torch.zeros(base.out_features, rank, dtype=base.weight.dtype))
def forward(self, x):
return self.base(x)+(x @ self.A.T @ self.B.T)*(self.alpha/self.rank)
def merged(self):
result = deepcopy(self.base)
with torch.no_grad():
result.weight.add_(self.B @ self.A*(self.alpha/self.rank))
return result
def attach_lora(base, rank=4, alpha=4):
model = deepcopy(base)
model.zero_grad(set_to_none=True)
model.requires_grad_(False)
torch.manual_seed(2030)
for module in list(model.modules()):
for name, child in list(module.named_children()):
if isinstance(child, nn.Linear):
setattr(module, name, LoRALinear(child, rank, alpha))
return model
def merge_lora(model):
result = deepcopy(model)
for module in list(result.modules()):
for name, child in list(module.named_children()):
if isinstance(child, LoRALinear):
setattr(module, name, child.merged())
return result
def state_fingerprint(model):
digest = sha256()
for name, tensor in sorted(model.state_dict().items()):
digest.update(name.encode())
digest.update(tensor.detach().cpu().contiguous().numpy().tobytes())
return digest.hexdigest()
def save_adapter(path, model, base, vocab):
adapters = {name: p.detach().cpu() for name, p in model.named_parameters() if p.requires_grad}
modules = {name: dict(rank=module.rank, alpha=module.alpha)
for name, module in model.named_modules() if isinstance(module, LoRALinear)}
path.parent.mkdir(parents=True, exist_ok=True)
torch.save(dict(adapters=adapters, modules=modules, vocab=vocab,
base_sha256=state_fingerprint(base), base_config=base.config), path)
def load_adapter(path, base, vocab):
saved = torch.load(path, map_location="cpu", weights_only=True)
if (saved["base_sha256"] != state_fingerprint(base) or saved["vocab"] != vocab
or saved["base_config"] != base.config):
raise ValueError("基础权重或词表与适配器不匹配")
settings = list(saved["modules"].values())
if not settings or any(item != settings[0] for item in settings):
raise ValueError("本例要求所有适配器采用同一配置")
model = attach_lora(base, **settings[0])
current_modules = {name for name, module in model.named_modules() if isinstance(module, LoRALinear)}
if current_modules != set(saved["modules"]):
raise ValueError("LoRA 注入位置不同")
state = model.state_dict()
expected = {name for name, p in model.named_parameters() if p.requires_grad}
if expected != set(saved["adapters"]):
raise ValueError("适配器参数缺失或多余")
state.update(saved["adapters"])
model.load_state_dict(state)
return model
def run(base_path=None, adapter_path=None):
setup()
if base_path:
train, validation, test = corpus()
vocab = vocabulary(train)
saved = torch.load(base_path, map_location="cpu", weights_only=True)
if (saved["experiment"] != "instruction-base-v1" or saved["vocab"] != vocab
or saved["train_sha256"] != sha256("\n".join(train).encode()).hexdigest()):
raise ValueError("需要第 26 章生成的基础模型")
base = TinyTransformer(**saved["config"]).double()
base.load_state_dict(saved["state"])
else:
base, (train, validation, test), vocab = prepare()
records = instruction_records(train[:96])
full, full_cost = finetune(base, records, train, vocab)
initial = attach_lora(base)
x, _ = supervised_data(instruction_records(validation), vocab)
with torch.no_grad():
initially_equal = torch.equal(base(x), initial(x))
adapted, adapter_cost = finetune(initial, records, train, vocab)
if adapter_path:
save_adapter(adapter_path, adapted, base, vocab)
frozen_equal = all(torch.equal(p, initial.state_dict()[name])
for name, p in adapted.named_parameters() if not p.requires_grad)
with TemporaryDirectory() as directory:
path = Path(directory)/"adapter.pt"
save_adapter(path, adapted, base, vocab)
restored = load_adapter(path, base, vocab)
merged = merge_lora(restored)
with torch.no_grad():
reference = adapted(x)
restored_equal = torch.equal(reference, restored(x))
merge_error = (reference-merged(x)).abs().max().item()
results = []
for name, model, cost in [("全量微调", full, full_cost), ("LoRA rank=4", adapted, adapter_cost)]:
results.append(dict(name=name, cost=cost,
validation=metrics(model, instruction_records(validation), validation, vocab),
test=metrics(model, instruction_records(test), test, vocab)))
return dict(results=results, initially_equal=initially_equal, frozen_equal=frozen_equal,
restored_equal=restored_equal, merge_error=merge_error,
base_parameters=sum(p.numel() for p in base.parameters()))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", type=Path, help="可选:复用第 26 章的 --save-base 文件")
parser.add_argument("--out", type=Path)
parser.add_argument("--save-adapter", type=Path)
args = parser.parse_args()
report = run(args.base, args.save_adapter)
print(f"基础参数={report['base_parameters']:,};两组均用 160 次更新、相同指令批次")
for result in report["results"]:
cost, val, test = result["cost"], result["validation"], result["test"]
print(f"{result['name']}:可训练参数={cost['trainable']:,};"
f"验证回答损失={val['answer_loss']:.3f};测试回答={test['correct']}/64;"
f"旧续写={test['old_exact']}/32")
state = cost["state_bytes"]
print(f" 权重={state['weights']/1024:.1f} KiB;梯度={state['gradients']/1024:.1f} KiB;"
f"优化器={state['optimizer']/1024:.1f} KiB;合计={sum(state.values())/1024:.1f} KiB")
print(f"初始输出与基础模型完全相同:{report['initially_equal']}")
print(f"训练后冻结参数完全不变:{report['frozen_equal']}")
print(f"保存/加载适配器后输出完全相同:{report['restored_equal']}")
print(f"合并后输出误差小于 1e-12:{report['merge_error'] < 1e-12}")
if args.save_adapter:
print(f"适配器文件:{args.save_adapter}")
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f"实验记录:{args.out}")
if __name__ == "__main__":
main()
6. 再省一点:量化基础权重与 QLoRA
LoRA 省掉大量训练状态,基础权重本身却仍可能很大。量化会用更少的位数近似存储这些数,进一步缩小基础模型。
QLoRA 将冻结的 4 位量化基础权重与可训练低秩适配器结合。论文还使用 NF4 格式、量化缩放参数以及分页优化器等设计,处理不同来源的内存开销。
这里有三个容易误会的地方:
- 4 位存储不等于全部计算都是 4 位。 参与运算时通常需要按相应方案恢复到计算精度,激活和适配器也有自己的数据类型。
- 基础权重冻结,不等于前向时没有量化误差。 模型使用的是权重的近似表示,需要评估这种变化是否可接受。
- 省了权重,不等于没有其他瓶颈。 长序列激活、临时工作区和数据搬运仍可能占据主要成本。
本章没有在 CPU 双精度实验里冒充实现了 QLoRA。理解机制之后,实际量化微调还需选择支持的格式、算子和设备,并测量峰值内存、吞吐和任务表现。第八篇会继续讨论量化与系统成本。
7. 怎样选择,也怎样维护
如果模型足够小、任务差异很大,全量微调可能最直接。如果需要给同一基础模型维护许多任务,适配器的小增量文件会更方便;代价是部署必须管理基础版本、任务版本、加载组合与测试结果。
LoRA 适配器能否合并、多个任务是否要频繁切换、是否需要量化基础权重,都属于选择的一部分。小文件只是交付物的一个属性,稳定完成任务才是使用目标。
先在与你的部署条件接近的模型、数据与上下文长度上做一组比较。报告可训练参数、实际内存口径、训练计算或时间、推理延迟与新旧任务结果,比只报告“节省了百分之多少参数”更有帮助。
8. 停下来试一试
- 一个 的矩阵,使用秩 8 的 LoRA,需要训练多少新增权重?
- 为什么 LoRA 的权重总字节数可能比未加适配器时更大?
- 如果 、 都从零开始,为什么可能无法启动学习?
- 冻结基础层与给整个前向加
no_grad()有什么区别? - LoRA 的基础权重没变,为什么旧任务仍可能退化?
- 为什么适配器文件需要记录基础模型与注入位置?
- 把秩从 4 改为 8,先预测参数量、状态内存和效果。哪些能直接计算,哪些必须运行?
- 比较全量、LoRA 和量化 LoRA 时,只看可训练参数数目会漏掉哪些成本?
参考答案与提示
- 个;原矩阵有 131,072 个权重,仍需保留。
- 基础权重仍在,旁边又加了 、;减少的主要是冻结参数对应的梯度和优化器状态。
- 两个因子相乘,某一因子的梯度依赖另一因子;都为零时两侧可能都得不到启动信号。
- 冻结指定参数仍允许其他参数所需的梯度传递;关闭整个前向的梯度记录会切断 LoRA 的学习路径。
- 实际输出由基础路径加增量决定,权重冻结不等于函数不变。
- 增量只对配套的权重、形状和词表约定有意义。
- 本例目标层不变时,新增参数翻倍为 8,792。状态字节可按数据类型和优化器估算;激活峰值、速度和效果需测量。
- 基础权重、激活、量化元数据、临时工作区、算子效率、额外路径与版本维护,也需要考虑。
9. 继续阅读
- Houlsby 等,Parameter-Efficient Transfer Learning for NLP:瓶颈 Adapter 与多任务参数共享。
- Hu 等,LoRA:低秩增量、注入位置与合并。
- Dettmers 等,QLoRA:量化基础权重与低秩训练的结合。
前两章都有一份明确的示范答案。接下来换一种反馈:不直接告诉模型每一步该写什么,而是在它行动以后给出奖励。这需要先认识强化学习。