LoRA低秩微调实战:BERT+IMDB的显存、参数与工程账

发布时间:2026/10/26 4:00:34
LoRA低秩微调实战:BERT+IMDB的显存、参数与工程账

1. 先把问题摆清楚:全参数微调的三笔账

BERT 在 IMDB 上做情感二分类,这个组合几乎是所有 NLP 从业者的“入门第一课”。110M 参数、2.5 万条训练样本、二分类头,单卡跑起来毫不费力。也正因为太顺手,很多人从来没认真算过全参数微调的真实成本,直到把场景换成一个基座模型要服务十几个下游任务,或者把 BERT-base 换成参数量大两个数量级的模型,才发现账本上的数字突然变得刺眼。LoRA(Low-Rank Adaptation,低秩自适应)就是在这本账上做文章的技术:它不动基座模型的原始权重,只在旁路插入一小对低秩矩阵来学习“任务增量”,从而把可训练参数压到原来的千分之几。这一章先把痛点摊开,后面讲原理和代码才有落点。

1.1 显存账:优化器状态才是真正的大头

很多人对微调显存的直觉是“参数有多少,显存就占多少”,这个直觉偏差非常大。以 BERT-base 为例,1.1 亿参数用 fp32 存储是 440MB,梯度再 440MB,看着还好。但 Adam 系列优化器要为每个参数维护一阶动量和二阶动量,又是两份 440MB,光这三项就 1.76GB 打底。真正吃显存的是激活值:batch size 32、序列长度 256、12 层 Transformer,中间激活全都要留着做反向传播,这一块轻松吃掉 5GB 到 8GB。你要是再开 fp32 全精度训练、不打开梯度检查点,单卡 16GB 都可能吃紧。

LoRA 把可训练参数压到 30 万这个量级,优化器状态直接从 880MB 掉到 1MB 多一点,几乎可以忽略。激活值那部分省不掉,因为你还是要前向传播整张计算图,但整体显存占用能下降 30% 到 40%。这个降幅在 BERT 上不算震撼,可一旦换到参数量大几十倍的模型上,就是“能不能在现有卡上跑起来”的生死线。

我在一张 24GB 的卡上做过对比:BERT-base、max_len 256、batch 32、fp16 混合精度,全参数微调峰值显存约 15.8GB,挂上 LoRA 之后约 9.2GB。同一条命令,同一份数据,唯一的差别就是requires_grad打开的范围。

1.2 存储账与切换账:一个任务一份权重

第二笔账是存储。全参数微调做完,你要给每个下游任务保存一份完整的 440MB 权重文件。十个任务就是 4.4GB,一百个租户就是 44GB,而且每一份都包含了大量重复的、几乎没变的基座参数。从工程角度看,这非常浪费——99% 的字节是完全一样的,只是被复制了一百遍。

LoRA 的适配器权重是什么体量?r=8、只挂在 query 和 value 上,总共 294912 个参数,fp32 存下来约 1.2MB,fp16 约 0.6MB。一个任务一份,几百个任务也就几百兆。

第三笔账是切换成本。全参数微调的模型,切任务就得把整份权重重新加载进显存,一张卡上同时驻留三个任务的模型基本就别想了。LoRA 的做法是:基座只加载一份,常驻显存,任务切换时只换那个几 MB 的适配器,加载耗时从秒级降到毫秒级。做在线多租户推理、做 A/B 实验、做快速回滚,这个特性带来的工程便利远大于参数量的数字游戏。

1.3 低秩假设:为什么改一点点就够用

LoRA 的核心假设只有一句话:模型在适配某个下游任务时,权重矩阵的变化量具有很低的“内在秩”。换成生活化的说法:一栋大楼要改造成咖啡馆,你不需要把承重墙全部推倒重建,只要在几个关键位置加装隔断、灯光和吧台,就能彻底改变它的功能属性。改造量很小,但方向必须对。

这个假设不是拍脑袋。早期的实证观察发现,微调前后权重矩阵的差值 ΔW,其奇异值谱衰减得非常快——前几个奇异值就占了绝大部分能量,后面的几乎为零。也就是说,虽然 ΔW 表面上是个 768×768 的稠密矩阵,但它真正“有效”的自由度可能只有个位数到几十个。

所以 LoRA 直接把这个结构约束写进模型里:强制 ΔW 分解成两个瘦长矩阵的乘积 B·A,其中 B 是 768×r,A 是 r×768,r 取 4、8、16 这种小数字。参数总量从 768×768≈59 万降到 r×(768+768)=12288(r=8 时),压缩了 48 倍。而因为低秩假设成立,这么狠的压缩几乎没有损失表达能力。

注意:低秩假设在分类这类“任务偏移较小”的场景下非常稳,但如果下游任务和预训练目标差异极大(比如从通用语料切到高度专业的领域文本),r 设得太小会明显欠拟合,这时候要往上调。

1.4 为什么拿 BERT + IMDB 当验证节点

有人会问:LoRA 的价值明明在大模型上,为什么还要用 BERT 做实验?理由很实在。第一,成本低。一张消费级显卡半小时能跑完一轮对照实验,你可以放心地把 r 从 4 试到 32,把 target_modules 换三种组合,这在几十亿参数的模型上是不可能的。第二,变量干净。BERT 结构简单、社区熟悉,任何效果波动都能明确归因到超参数上,不像大模型那样一堆玄学。第三,IMDB 是个二分类任务,准确率这个指标直观、方差小,2.5 万条测试样本足够把 0.5 个百分点的差异区分出来。

把 BERT 上的这套流程跑通、把参数账算明白、把坑踩一遍,换到任何基座模型上,代码结构几乎不用改,只是模型名和模块名换个字符串。这就是我坚持从这个组合入手的原因。

2. LoRA 的数学骨架与代码骨架

原理部分我不想写成公式堆砌,但该有的推导必须有,否则调参时你不知道自己在调什么。这一章从矩阵分解讲到初始化,再落到二十行能跑的手写实现,最后讨论模块挂载位置的选择——这三点是 LoRA 落地时最容易被忽略、又最影响结果的地方。

2.1 从 ΔW 到 B·A:一次有约束的降维

标准全参数微调的更新式是 W = W₀ + ΔW,W₀ 是预训练权重,ΔW 是梯度下降学出来的增量,两者同维度,都是 d×k。LoRA 把 ΔW 换成一个受约束的形式:

ΔW = B · A · (alpha / r)

其中 A 的形状是 r×k,B 的形状是 d×r,r 远小于 min(d,k)。前向传播变成:

h = W₀x + (alpha / r) · B · A · x

注意这里 A 先把输入从 k 维压到 r 维,B 再把 r 维升回 d 维。整个旁路就是一个“瓶颈结构”,和自编码器的思路同源。原始权重 W₀ 全程冻结,只有 A 和 B 参与梯度更新。

参数量怎么算?单个矩阵是 r×k + d×r = r(d+k)。BERT-base 的 hidden 是 768,r=8 时单个矩阵 12288 个参数,对比原始的 589824,压缩比 48:1。这就是全部秘密,没有任何魔法。

2.2 初始化与缩放因子:B 置零这个细节很关键

初始化方式直接决定训练能否稳定起步。LoRA 的做法是:A 用高斯分布随机初始化,B 全部置零。这样训练开始时 B·A = 0,整个旁路输出为零,模型行为和原始预训练模型完全一致。这个设计很巧妙——你相当于从一个“已验证可用的解”出发做扰动,而不是从一个随机噪声点出发。

我见过有人把 B 也随机初始化,结果前几百步 loss 剧烈震荡,学习率还得推倒重调。这不是玄学,是起点被破坏了。

缩放因子alpha / r是另一个容易忽略的点。它的作用是让不同 r 下的学习动态保持一致:r 变大时,B·A 的数值规模会自然变大,除以 r 就把这个影响抵消掉了,调 r 的时候不用同步改学习率。经验上 alpha 取 2r(r=8 时 alpha=16)比较稳,也有人取 r 本身,效果差别不大,但 alpha 和 r 的比例一旦定下来就别乱动,否则超参数之间会互相干扰。

2.3 手写一个 LoRALinear,二十行看透本质

理解了公式,实现就是体力活。不想依赖任何封装库的话,可以这样写:

import torch import torch.nn as nn import math class LoRALinear(nn.Module): def __init__(self, base: nn.Linear, r=8, alpha=16, dropout=0.1): super().__init__() self.base = base for p in self.base.parameters(): p.requires_grad = False # 冻结原始权重 self.r = r self.scaling = alpha / r in_f, out_f = base.in_features, base.out_features self.lora_A = nn.Parameter(torch.empty(r, in_f)) self.lora_B = nn.Parameter(torch.zeros(out_f, r)) # B 置零 nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) self.dropout = nn.Dropout(dropout) def forward(self, x): base_out = self.base(x) # x: (..., in_f) -> A^T -> (..., r) -> B^T -> (..., out_f) lora_out = (self.dropout(x) @ self.lora_A.T) @ self.lora_B.T return base_out + lora_out * self.scaling

把它替换进 BERT 的注意力模块,遍历所有名为query和value的子模块换掉即可:

def inject_lora(module, r=8, alpha=16, targets=("query", "value")): for name, child in module.named_children(): if isinstance(child, nn.Linear) and name in targets: setattr(module, name, LoRALinear(child, r, alpha)) else: inject_lora(child, r, alpha, targets) inject_lora(model.bert)

这里有个细节值得说:nn.Linear的权重形状是(out_features, in_features),计算是y = x @ W.T + b。所以 LoRA 旁路必须是x @ A.T @ B.T,维度才对得上。写成x @ A @ B会直接报形状错误,这是新手最常见的翻车点。

提示:手写实现适合理解原理和小规模实验。生产环境建议用成熟的适配器库,它们在混合精度、梯度检查点、权重合并、多适配器热切换上都做了处理,自己造轮子容易在这些边角上出问题。

2.4 target_modules 挂哪里:q/v 是性价比之王

BERT 的每个 Transformer 层里,注意力部分有 query、key、value、dense 四个 768×768 矩阵,前馈网络有两个 768×3072 的矩阵。全挂上当然表达力最强,但参数量会上去,收益却未必线性增长。

我做过几组对照,结论是:只挂 query 和 value 的性价比最高。原因不难理解,query 决定“我要找什么”,value 决定“我拿什么内容回来”,这两者直接塑造了注意力的语义选择;key 更多是配合 query 做匹配,改动它的边际收益低;输出投影 dense 和前馈层更多是特征变换,挂上去提升有限但参数翻倍。

如果你的任务更复杂(比如需要细粒度语义改写、长文本推理),可以加上dense和intermediate.dense,r 相应调小一半。但如果只是做情感分类、意图识别这类任务,q/v 组合完全够用。

不同框架下模块的命名不一样,动手前务必打印一遍:

for name, _ in model.named_modules(): if "attention" in name: print(name)

你会看到类似bert.encoder.layer.0.attention.self.query的路径。用target_modules=["query", "value"]这种后缀匹配,比写全路径省事得多,也能自动覆盖所有层。

3. 动手前的准备:环境、数据与基线

真正决定实验能不能复现的,往往是这些不起眼的准备工作。版本不匹配、验证集没切、随机种子没固定,任何一项出问题,后面所有对比数字都不可信。这一章把我认为必须提前做对的事情列一遍。

3.1 依赖版本与硬件清单

我这套流程实测可用的组合是:Python 3.10、PyTorch 2.1 及以上、transformers 4.40 及以上、peft 0.10 及以上、datasets 2.x、scikit-learn。版本提示不是摆设——TrainingArguments里评估策略的参数名在 4.41 前后从evaluation_strategy改成了eval_strategy,老代码直接报unexpected keyword argument,很多人卡在这一步以为是环境坏了。

硬件方面,单卡 16GB 显存足够跑完全部实验;8GB 也能跑,但 batch size 要降到 8 并开梯度累积。没有 GPU 的话 CPU 也能跑通流程,只是三轮训练要几个小时,不建议用来做对照实验。

另外强烈建议固定随机种子,并把transformers.set_seed(42)放在脚本最前面。IMDB 只有二分类,不同种子之间的准确率波动能到 0.3 个百分点,不固定种子的话,你分不清效果差异是来自超参数还是来自噪声。

3.2 IMDB 的 tokenize 与验证集切分

IMDB 数据集只有 train 和 test 两个划分,各 25000 条,没有验证集。很多人直接把 test 当验证集用,边训练边看 test 准确率,这等于把测试集信息泄进模型选择过程,最终报告的指标是虚高的。正确做法是从 train 里切一部分出来:

from datasets import load_dataset from transformers import AutoTokenizer ds = load_dataset("imdb") tok = AutoTokenizer.from_pretrained("bert-base-uncased") def preprocess(ex): return tok(ex["text"], truncation=True, max_length=256) ds = ds.map(preprocess, batched=True) ds = ds.rename_column("label", "labels") ds = ds.set_format("torch", columns=["input_ids", "attention_mask", "labels"]) split = ds["train"].train_test_split(test_size=0.08, seed=42) train_ds, val_ds, test_ds = split["train"], split["test"], ds["test"]

两个细节。第一,max_length=256是权衡结果:IMDB 的评论长度中位数在 180 词左右,256 能覆盖八成以上样本,再往上加收益很小但显存和耗时线性增长。如果你在意长尾,可以统计一下 token 长度分布再定。第二,truncation=True默认从右侧截断,对情感分类来说评论开头往往信息量更大,实际上保留开头是合理的;如果做的是结论抽取类任务,就得考虑截断方向。

3.3 先跑一个全参数基线,别急着上 LoRA

这一步最容易被跳过,但它是整个实验的锚点。没有基线,你看到 LoRA 跑出 92.8% 的准确率,根本不知道这是好还是坏——也许全参数微调也就 93%,那 LoRA 的收益就只剩工程层面的;也许全参数能到 95%,那说明 LoRA 的配置还有优化空间。

基线配置很朴素:不冻任何参数,学习率 2e-5,batch 32,3 轮,warmup 比例 0.06,fp16。跑完之后记录三个数字:验证集准确率、峰值显存、单轮耗时。这三个数字是后面所有对照的参照系。

我在同一台机器上测到的基线是:验证准确率 93.6%,峰值显存 15.8GB,单轮约 17 分钟。这个成绩在 BERT-base + IMDB 上属于正常水平,网上流传的 95% 以上的结果多半动了数据划分或者做了额外技巧,不必强求对齐。

注意:跑基线时一定要用和 LoRA 实验完全一致的数据划分、tokenizer、随机种子。数据划分一变,两个数字就不可比了,这是我踩过的坑——切验证集时用了不同的 seed,结果两次实验差了 0.4 个点,排查了半天才发现是数据不同。

4. 把 LoRA 挂到 BERT 上:完整实操流程

准备工作做完,真正挂 LoRA 的代码量其实很少。关键在于每一步都要核对中间结果,尤其是参数统计那一环,它是判断“配置有没有生效”的唯一可靠依据。

4.1 构建 LoRA 模型并核对参数

用 peft 库,核心就几行:

from transformers import AutoModelForSequenceClassification from peft import LoraConfig, get_peft_model, TaskType base = AutoModelForSequenceClassification.from_pretrained( "bert-base-uncased", num_labels=2 ) cfg = LoraConfig( task_type=TaskType.SEQ_CLS, r=8, lora_alpha=16, target_modules=["query", "value"], lora_dropout=0.1, bias="none", ) model = get_peft_model(base, cfg) model.print_trainable_parameters()

输出应该是trainable params: 296,450 || all params: 109,778,946 || trainable%: 0.27。

这个数字要能自己算出来才对:12 层 × 2 个矩阵 × (768×8 + 768×8) = 294912,加上分类头的 768×2+2 = 1538,一共 296450。如果你打印出来的数字是 1.1 亿,说明基座没冻住;如果是 0,说明target_modules一个都没匹配上。这两种情况都很常见,别嫌麻烦,每次改配置都看一眼这个数。

bias="none"的含义是偏置项也不训练。有人会问要不要把 LayerNorm 的参数放开,我的经验是:分类任务里放开也就能提升 0.1 到 0.2 个点,但可训练参数会多好几万,性价比不高,除非你的数据分布和预训练语料差异特别大。

4.2 训练参数怎么定:学习率要比全参数微调大

这是 LoRA 调参最反直觉的一点。全参数微调用 2e-5 是常识,但 LoRA 用 2e-5 会明显欠拟合,我一开始就栽在这里:loss 降得非常慢,三轮下来准确率只有 89%。

原因在于,LoRA 训练的是一组从零开始的新参数,它们没有预训练积累,需要更大的步长才能快速找到方向;而全参数微调的权重已经在一个很好的位置附近,步子大了反而会破坏原有知识。经验值是全参数的 5 到 10 倍,即1e-4 到 5e-4,我常用的起点是 2e-4。

其他参数:

参数取值说明
learning_rate2e-4LoRA 侧参数,比全参数微调大一个量级
per_device_train_batch_size32显存够就往上加,对齐效果好
num_train_epochs3IMDB 上 2 到 3 轮足够,第 4 轮开始过拟合
warmup_ratio0.06防止前几百步震荡
weight_decay0.01对低秩矩阵有轻微正则作用
lr_scheduler_typelinear简单可靠,没必要上 cosine
fp16True显存减半,准确率几乎无损

还有一个小技巧:因为只训练 30 万参数,单步计算量小,训练会显得“喂不饱”GPU,可以把 dataloader 的num_workers调到 4,dataloader_pin_memory=True,能省下不少数据加载的等待时间。

4.3 训练与评估

Trainer 的配置和常规微调没区别,只是指标计算要单独写:

import numpy as np from sklearn.metrics import accuracy_score, f1_score def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) return { "accuracy": accuracy_score(labels, preds), "f1": f1_score(labels, preds), }

然后组装:

from transformers import TrainingArguments, Trainer args = TrainingArguments( output_dir="./lora-imdb", per_device_train_batch_size=32, per_device_eval_batch_size=64, learning_rate=2e-4, num_train_epochs=3, warmup_ratio=0.06, weight_decay=0.01, eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="accuracy", fp16=True, logging_steps=50, report_to="none", ) trainer = Trainer( model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds, compute_metrics=compute_metrics, ) trainer.train()

训练过程中盯着两个信号:训练 loss 是否在前 200 步内明显下降(不降就是学习率或模块匹配有问题),验证准确率是否在第 2 轮后趋于平缓(继续涨说明还能多跑一轮,开始掉就是过拟合)。

我这次跑下来,第 1 轮验证准确率 91.9%,第 2 轮 92.7%,第 3 轮 92.8%,曲线基本在第 3 轮走平。最终测试集 92.6%,比全参数基线低 1 个点。

4.4 合并权重与交付

训练完有两种交付形态,用途完全不同。

第一种是只存适配器,文件几 MB,适合多任务共存:

model.save_pretrained("./lora_adapter")

加载时先建同构基座,再挂适配器:

from peft import PeftModel base = AutoModelForSequenceClassification.from_pretrained( "bert-base-uncased", num_labels=2 ) model = PeftModel.from_pretrained(base, "./lora_adapter")

第二种是合并回基座,得到一份普通模型,推理时零额外开销:

merged = model.merge_and_unload() merged.save_pretrained("./bert-imdb-merged")

合并的数学就是 W' = W₀ + (alpha/r)·B·A,一次性算完写回权重。实测合并后推理速度比“基座 + 适配器”的动态挂载快 8% 左右,因为省掉了旁路的两