Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

参数高效微调

上一章,我们动了模型里的全部参数。对只有几万个参数的小模型,这很轻松;如果模型大得多,而你只想让它学会一种输出格式,是否也必须准备一整套梯度和优化器状态?

参数高效微调试着换一种做法:保留大部分基础权重,只更新较少的参数。少动一些数,可能让训练和保存更省,也可能限制适应能力。到底省了哪里、效果怎样,我们仍用上一章的提取任务来比较。

1. 四种做法,先摆在一起

做法主要更新什么需要额外留意什么
全量微调原模型的全部或大部分权重梯度、优化器状态和任务版本较大
Adapter插入网络的小模块多出计算路径,部署需要相应结构
LoRA若干权重矩阵旁的低秩增量注入位置、秩和缩放都影响行为
量化基础权重加 LoRA低精度存储的基础权重保持冻结,训练增量计算精度、量化误差、算子与硬件支持

“参数高效”主要描述需要训练的参数少,不能直接翻译成“任何时候都更快、更准、显存只剩同样比例”。基础模型仍然要参与前向计算,许多激活也仍然需要保留。

2. Adapter:在原网络里加一小段可训练路径

假设某一层输出一个 dd 维向量 h\mathbf h。一种瓶颈 Adapter 先把它压到 rr 维,再经过非线性变换、升回 dd 维,并加回原输入:

h′=h+Wupσ(Wdownh) \mathbf h' =\mathbf h+W_{\mathrm{up}} \sigma(W_{\mathrm{down}}\mathbf h)

这里 WdownW_{\mathrm{down}} 的形状是 r×dr\times d,WupW_{\mathrm{up}} 是 d×rd\times r;为突出结构,公式省略偏置。当 rr 比 dd 小得多时,这一小段只有约 2dr2dr 个权重。

Houlsby 等人的 Adapter 研究 展示了冻结基础网络、为不同任务保留少量新增模块的思路。不同方法把模块插在不同位置,具体实现不只有这一种。

非线性模块通常不能简单地合并成原来某个线性矩阵,所以服务时也要执行它。是否值得,要结合任务效果、请求量与部署方式判断。

3. LoRA:把一个大增量拆成两个小矩阵

从全量更新说起

一个线性层原来计算:

y=W0x \mathbf y=W_0\mathbf x

W0W_0 的形状是 m×nm\times n,输入 x\mathbf x 有 nn 个分量,输出有 mm 个分量。全量微调可以把每个权重都改掉,得到 W0+ΔWW_0+\Delta W。

LoRA 让基础权重 W0W_0 保持不变,把增量写成两个小矩阵的乘积:

ΔW=αrBAA∈Rr×nB∈Rm×r \begin{aligned} \Delta W&=\frac{\alpha}{r}BA\\ A&\in\mathbb R^{r\times n}\\ B&\in\mathbb R^{m\times r} \end{aligned}

rr 是秩的上限,α/r\alpha/r 是缩放系数。所谓“低秩”,可以先理解成:输入先经过一个只有 rr 个通道的中间空间,再组合成输出增量。因此,BABA 最多提供 rr 个独立方向的变化。

需要训练的参数从一个完整矩阵的 mnmn 个,变成:

NLoRA=r(n+m) N_{\mathrm{LoRA}}=r(n+m)

例如,m=n=1024m=n=1024、r=8r=8,完整矩阵有 1,048,576 个权重,两个小矩阵合计 16,384 个。基础矩阵依然存在,只是不再为它准备训练梯度和 Adam 历史状态。

图 1:冻结的是基础参数,输出依然由基础路径与增量路径共同决定。图中省略已有偏置,实际实现会保留它。

为什么一开始只把一个矩阵设为零

我们把 AA 随机初始化,把 BB 设为零。于是最初 BA=0BA=0,新增模块不会立刻改变基础模型的输出。

如果 AA 和 BB 都设为零,情况反而不妙。增量对 AA 的梯度依赖 BB,对 BB 的梯度依赖 AA,两边都可能从零开始卡住。只让 BB 为零时,第一步可以先更新 BB,随后两边一起学习。

这并不是把全部梯度关闭。冻结权重通常用 requires_grad_(False);如果把整个模型前向包在 no_grad() 中,LoRA 参数需要的梯度路径也会一起被切断。

行向量批次里的写法

PyTorch 把一批输入放在最后一维为 nn 的张量中,线性层存放的权重形状是 (m,n)(m,n)。本例计算:

base(x) + (x @ A.T @ B.T) * (alpha / rank)

这与前面的列向量公式是同一件事,只是批次组织方式不同。

LoRA 原始论文 研究了这种低秩适应。实际使用时,秩不是唯一选择:更新注意力的哪些投影,是否包括前馈层与输出层,也会改变参数量和效果。

4. 保存与合并:小文件也有自己的依赖

训练后,可以只保存 AA、BB、缩放和注入位置;加载时仍需配套的基础权重、词表与网络配置。

“都是同一个系列的模型”还不够。基础版本换了、词表顺序改了、适配器注入位置变了,增量就可能接到错误的地方。因此,本例在适配器文件里保存基础权重指纹和配置,加载时检查是否一致。

推理时,如果基础层是普通线性层,可以先合并:

Wmerged=W0+αrBA W_{\mathrm{merged}}=W_0+\frac{\alpha}{r}BA

然后用一个线性层计算,不必再走两条分支。实数运算下两者等价;浮点加法顺序不同,结果可能存在很小的舍入差异。

如果基础权重是量化存储,合并与重新量化还会引入误差,并涉及相应格式,不能照搬这里的双精度合并保证。多个适配器同时组合也需要重新评估,不能因为每个单独工作正常,就假定相加以后仍然正常。

5. 动手:同一个任务,换成 LoRA

本实验沿用上一章的基础模型、192 条指令记录、验证与测试划分。比较两组:全量微调,以及秩为 4、α=4\alpha=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 格式、量化缩放参数以及分页优化器等设计,处理不同来源的内存开销。

这里有三个容易误会的地方:

  1. 4 位存储不等于全部计算都是 4 位。 参与运算时通常需要按相应方案恢复到计算精度,激活和适配器也有自己的数据类型。
  2. 基础权重冻结,不等于前向时没有量化误差。 模型使用的是权重的近似表示,需要评估这种变化是否可接受。
  3. 省了权重,不等于没有其他瓶颈。 长序列激活、临时工作区和数据搬运仍可能占据主要成本。

本章没有在 CPU 双精度实验里冒充实现了 QLoRA。理解机制之后,实际量化微调还需选择支持的格式、算子和设备,并测量峰值内存、吞吐和任务表现。第八篇会继续讨论量化与系统成本。

7. 怎样选择,也怎样维护

如果模型足够小、任务差异很大,全量微调可能最直接。如果需要给同一基础模型维护许多任务,适配器的小增量文件会更方便;代价是部署必须管理基础版本、任务版本、加载组合与测试结果。

LoRA 适配器能否合并、多个任务是否要频繁切换、是否需要量化基础权重,都属于选择的一部分。小文件只是交付物的一个属性,稳定完成任务才是使用目标。

先在与你的部署条件接近的模型、数据与上下文长度上做一组比较。报告可训练参数、实际内存口径、训练计算或时间、推理延迟与新旧任务结果,比只报告“节省了百分之多少参数”更有帮助。

8. 停下来试一试

  1. 一个 512×256512\times256 的矩阵,使用秩 8 的 LoRA,需要训练多少新增权重?
  2. 为什么 LoRA 的权重总字节数可能比未加适配器时更大?
  3. 如果 AA、BB 都从零开始,为什么可能无法启动学习?
  4. 冻结基础层与给整个前向加 no_grad() 有什么区别?
  5. LoRA 的基础权重没变,为什么旧任务仍可能退化?
  6. 为什么适配器文件需要记录基础模型与注入位置?
  7. 把秩从 4 改为 8,先预测参数量、状态内存和效果。哪些能直接计算,哪些必须运行?
  8. 比较全量、LoRA 和量化 LoRA 时,只看可训练参数数目会漏掉哪些成本?
参考答案与提示
  1. 8(512+256)=61448(512+256)=6144 个;原矩阵有 131,072 个权重,仍需保留。
  2. 基础权重仍在,旁边又加了 AA、BB;减少的主要是冻结参数对应的梯度和优化器状态。
  3. 两个因子相乘,某一因子的梯度依赖另一因子;都为零时两侧可能都得不到启动信号。
  4. 冻结指定参数仍允许其他参数所需的梯度传递;关闭整个前向的梯度记录会切断 LoRA 的学习路径。
  5. 实际输出由基础路径加增量决定,权重冻结不等于函数不变。
  6. 增量只对配套的权重、形状和词表约定有意义。
  7. 本例目标层不变时,新增参数翻倍为 8,792。状态字节可按数据类型和优化器估算;激活峰值、速度和效果需测量。
  8. 基础权重、激活、量化元数据、临时工作区、算子效率、额外路径与版本维护,也需要考虑。

9. 继续阅读

前两章都有一份明确的示范答案。接下来换一种反馈:不直接告诉模型每一步该写什么,而是在它行动以后给出奖励。这需要先认识强化学习。