系列 · 迁移学习 · 第 2 篇

迁移学习(二):预训练与微调

预训练如何从无标注数据中学到强大的先验,微调如何把它适配到具体任务。涵盖对比学习、掩码语言模型、判别式学习率、层冻结、灾难性遗忘、LoRA,以及一个工业级 BERT 微调实现。

2018 年,BERT 横空出世,几乎一夜之间改变了 NLP 的游戏规则。一个在 Wikipedia 和 BookCorpus 上预训练的模型,只需几千条标注数据进行微调,就能超越研究者们花费数年精心设计的任务专用架构。同样的故事后来在视觉领域(ImageNet 预训练、SimCLR、MAE)、语音领域(wav2vec 2.0)以及代码领域(Codex)不断重演。如今,“一次预训练,到处微调”已经成为现代深度学习的标准做法。

但预训练为什么有效?何时该冻结部分层,何时该采用 LoRA,学习率又该设为多小?本文将从理论和工程实践两个角度,深入剖析这个目前最成功的迁移范式。


你将学到什么#

  • 从贝叶斯和信息论的角度理解预训练为什么有效
  • 自监督预训练任务:对比学习(SimCLR、MoCo)和掩码语言模型(BERT MLM)
  • 微调策略:全量微调、层冻结、逐步解冻、线性探测、差异化学习率
  • 灾难性遗忘以及如何保留源任务知识
  • 参数高效微调方法:Adapter 和 LoRA
  • 完整的 BERT 微调实现,包含差异化学习率、梯度累积、混合精度训练和 warmup 调度

前置知识:本系列第一篇(或具备相当的迁移学习直觉),熟悉 Transformer 架构的基本概念。


为什么要预训练?#

预训练-微调流水线

这张图把整个思路讲清楚了。第一阶段,用海量无标注数据和大量算力,跑一次自监督预训练,得到通用底座 $\theta_{\mathrm{pre}}$ 。第二阶段,拿少量标注数据把模型调整到具体任务上,成本低,想调几次都行。

数据的不对称性#

标注数据太贵了:

  • 医疗影像:医生给一张 CT 扫描做标注,收费 100 到 500 美元;
  • 法律文本:律师按小时计费审阅文档;
  • 小语种翻译:双语平行语料几乎找不到。

但互联网上存在 PB 级别的无标注文本、图像和视频。预训练正是为了利用这种数据不对称性:先从海量无标注数据中学习通用表征,再用少量标注数据适配具体任务。

贝叶斯视角:预训练就是先验#

$$\theta^{*} = \arg\max_{\theta} \log P(\mathcal{D}_{\mathrm{task}} \mid \theta).$$

预训练加微调分两步走:

  1. 预训练:估计先验 $P(\theta \mid \mathcal{D}_{\mathrm{pre}})$
  2. 微调:用任务似然更新后验 $P(\theta \mid \mathcal{D}_{\mathrm{task}}, \mathcal{D}_{\mathrm{pre}}) \;\propto\; P(\mathcal{D}_{\mathrm{task}} \mid \theta) \cdot P(\theta \mid \mathcal{D}_{\mathrm{pre}}).$ 如果 $\mathcal{D}_{\mathrm{task}}$ 很少,似然项噪声大,后验基本由先验主导。一个好的先验——能把概率集中在合理参数区域的先验——能显著提升后验质量。预训练得到的模型参数(checkpoint)就充当了这样一个先验。

信息论视角:可迁移的特征#

$$I(f_{\theta}(X); Y_{i})$$

尽可能高。ImageNet 学到的边缘、纹理、物体部件,不仅对分类有用,还对检测、分割甚至医学影像有帮助。BERT 的句法语义表示在各种 NLP 任务中都很强。预训练模型本质上是一种数据压缩,目标是保留对下游任务具有可迁移性的信息。

收敛更快、极小点更好#

预训练参数已经落在损失曲面的低谷区域,微调只需要做局部调整。所以收敛更快,而且更容易找到平坦、泛化能力强的极小点,比随机初始化效果好得多。

自监督预训练任务#

自监督学习的核心在于设计一类任务,其监督信号可直接从原始数据中自动生成。模型通过预测输入的一部分内容来学习另一部分内容。

对比学习(视觉)#

核心思想:让相似样本的表示靠近,同时推开不相似样本的表示。

SimCLR#

$$\mathcal{L}_{i} = -\log \frac{\exp(\operatorname{sim}(z_{i}, z_{i'}) / \tau)}{\sum_{k \neq i} \exp(\operatorname{sim}(z_{i}, z_{k}) / \tau)},$$

其中 $\operatorname{sim}$ 表示余弦相似度,$\tau$ 是温度参数。

  • 分子:希望正样本对的相似度尽可能高;
  • 分母:用 batch 中其他所有样本作为负样本进行归一化;
  • 温度 $\tau$ :较小的 $\tau$ 会让 softmax 更尖锐,损失会集中在最难区分的负样本上。

问题来了:你需要大量负样本。SimCLR 使用 4096 到 8192 的 batch size,还得靠 TPU pod 才能装得下。

MoCo:动量更新的负样本队列#

$$\theta_{k} \leftarrow m \cdot \theta_{k} + (1 - m) \cdot \theta_{q}, \qquad m \approx 0.999.$$

历史 key 被存入一个长度为 65536 的队列中。字典足够大,而编码器因为 $f_{k}$ 更新缓慢保持一致性。这样,单块 GPU 就能实现媲美 SimCLR 的对比学习效果。

掩码语言模型(NLP)#

BERT 的 MLM 目标#

$$\mathcal{L}_{\mathrm{MLM}} = -\sum_{i \in \mathcal{M}} \log P(x_{i} \mid x_{\setminus \mathcal{M}}).$$

这 15% 的比例进一步细分为:80% 替换为 [MASK]、10% 替换为随机 token、10% 保持不变。这个分配不是为了好看,而是为了缩小训练和推理之间的分布差距。微调时没有 [MASK],如果模型只见过 [MASK],泛化能力会打折扣。10% 随机 token 和 10% 保持不变的设计,迫使模型无论某个位置是否被 mask,都能给出可靠的表示。

为什么是 15%,而不是 5% 或 50%? 太少的话,每条样本提供的学习信号太弱;太多的话,上下文会被破坏到模型无法预测的程度。经验表明,15% 是最佳点。不过最近的研究(Wettig et al., 2023)发现,在合适的架构下,这个比例可以提升到 40%。

NSP 及其替代方案#

BERT 还训练了一个 NSP(Next Sentence Prediction)任务:给定句子 A 和 B,判断 B 是否真的接在 A 后面。后来 RoBERTa 的实验表明,NSP 几乎不起作用:模型仅依靠句子主题相似性就能达到较高准确率,无需建模真正的篇章级关系。ALBERT 提出 SOP(Sentence Order Prediction)作为 NSP 的替代方案:负样本通过对同一文档中相邻两句的顺序进行交换来构造。这种方法逼迫模型真正建模句间关系。

微调:为什么收敛得这么快#

从头训练 vs 微调的损失曲线

这张图是贝叶斯观点背后的实证数据。模型、数据集和优化器完全相同的情况下,预训练模型有三个明显优势:

  • 起点损失更低(先验已经很好);
  • 几个 epoch 就能达到从头训练的最佳验证损失;
  • 最终收敛到一个更低的下界。

接下来是实际问题:如何在微调时调整上亿参数,又不破坏它们?

全参微调#

$$\theta^{*} = \arg\min_{\theta} \; \mathcal{L}_{\mathrm{task}}(\theta) + \lambda \lVert \theta - \theta_{\mathrm{pre}} \rVert^{2}.$$

这是 Elastic Weight Consolidation 的简化版。实践中,小学习率加上早停自带的隐式正则通常就足够了。

分层学习率#

不同网络层所学知识具有层次性:

  • 底层(embedding、靠前的 Transformer 块)捕捉通用特征——分词、基础语法、低级视觉模式。要轻碰。
  • 顶层(分类头、最后几层)是任务专用的。可以大力训。
$$\eta_{\ell} = \frac{\eta_{L}}{\xi^{\,L - \ell}}, \qquad \xi \approx 2.6.$$

底层学习率比顶层小约 $\xi^{L}$ 倍。

先 warmup 再衰减#

一套对微调几乎万能的学习率调度策略:

  1. Warmup:前 $T_{w}$ 步把学习率从 0 线性增长到 $\eta_{\max}$
  2. 衰减:用余弦或线性曲线把学习率降到接近 0。

Warmup 很重要,因为新接的分类头在最初几步会输出高方差梯度。如果直接用大学习率,会破坏预训练权重。Warmup 让分类头先稳定下来,再让骨干网络开始更新。

经验法则:微调学习率通常是预训练学习率的 1–2 个数量级。BERT 预训练用 $10^{-4}$ ,微调一般用 $2 \times 10^{-5}$

学习率调度与分层学习率

层冻结#

层冻结策略

冻结某层就是把它的参数 requires_grad = False。前向计算照常进行,但优化器看不到它。从数学上看,冻结是 L2 锚的极限情况:被冻参数子集上 $\lambda \to \infty$

四种常见模式

  1. 全参微调:全部可训。数据多的时候最优。
  2. 冻底训顶:小数据集且任务接近预训练域时的默认选择。
  3. 逐层解冻(ULMFiT):先只解冻分类头,每个 epoch 自顶向下多解冻一层。对灾难性遗忘最稳。
  4. 线性探测:只训分类头,骨干完全冻住。最便宜的微调形式,常常是个很强的基线。

快速决策表:

任务与预训练相似度标注样本数推荐策略
少(< 1k/类)冻底层、微调顶层
全参微调
冻中间层、微调底+顶,或用 LoRA
全参微调 + 分层学习率

线性探测 vs 全参微调#

线性探测 vs 全参微调

线性探测仅在冻结的骨干特征上训练一个线性分类器,是对模型表征质量最直接的评估方式:在极小样本场景下,它常优于全参数微调,因为标注数据远不足以安全地更新全部 1.1 亿参数。数据量增加后,全参微调反超并拉开差距。上线前花几次实验找到交叉点,往往很值得。

灾难性遗忘#

微调过程中的灾难性遗忘

激进的微调具有单向性:下游任务性能每提升 1 个百分点,源任务性能可能下降 1–2 个百分点。McCloskey 和 Cohen 在 1989 年把这个现象命名为灾难性遗忘,至今仍是序贯迁移的核心痛点。

三类常见对策:

  • 正则化(EWC):用 Fisher 信息的对角线给“对源任务重要的参数”加高权重二次惩罚,尽量不让它们动;
  • 回放(Replay):保留一小批源任务样本,每个微调 batch 里掺一点;
  • 结构隔离:骨干彻底冻住,每个任务挂自己的 Adapter 或 LoRA——源任务权重没被碰过,自然忘不掉。

Adapter:参数高效微调#

$$\operatorname{Adapter}(h) = h + W_{\mathrm{up}}\, \sigma\big(W_{\mathrm{down}}\, h\big),$$

其中 $W_{\mathrm{down}} \in \mathbb{R}^{m \times d}$$W_{\mathrm{up}} \in \mathbb{R}^{d \times m}$ ,且 $m \ll d$ (典型值 $m = 64$$d = 768$ )。残差连接保证未训练的 Adapter 等于恒等映射,所以训练起点和预训练模型完全一致。你只训 Adapter,每个任务只需保存 $\sim 1\%$ 的参数。

LoRA:低秩权重更新#

$$W' = W_{0} + \Delta W = W_{0} + B A, \qquad A \in \mathbb{R}^{r \times d}, \; B \in \mathbb{R}^{d \times r}, \; r \ll d.$$

微调时 $W_{0}$ 冻住,只训 $A, B$ 。三个特性让 LoRA 成为今天事实上的 PEFT 标准:

  • 极小:每个被适配的矩阵只新增 $2dr$ 个参数,常用 $r = 4$$16$
  • 零推理开销:部署时把 $BA$ 合并到 $W_{0}$ ,对外只暴露一个矩阵;
  • 可组合:换任务就是换一个十几兆的 delta,而不是 reload 一个 GB 级 checkpoint。

它隐含的假设——而且经验证明常常成立——是:任务适配只发生在权重空间的一个低维子空间里。


到底需要多少标注数据?#

性能 vs 目标数据集大小

这张图总结了实际选择。同样的目标任务,同样的骨干网络:

  • 从头训练在标注数量达到几千之前几乎没变化。低于这个数量,模型基本上只是在记噪声。
  • 线性探测一开始就占优——预训练特征已经把类别分得挺清楚了,只需要学一个超平面。
  • LoRA 和全参微调的表现差不多,差距很小,但只用了很少一部分可调参数。
  • 全参微调的上限最高,但只有在标注数据足够多、能安全调整每个参数时才划算。

横向箭头是重点:预训练让数据效率曲线向左上方移动了一个数量级——同样的精度,标注量少了 10 倍。


BERT 实战#

架构回顾#

BERT 由多层双向 Transformer 编码器堆叠而成。输入 token 后,每个编码器块通过多头自注意力机制和前馈网络生成上下文表示,同时加入残差连接和 LayerNorm。

适配不同任务#

任务BERT 的处理方式
文本分类提取 [CLS] 表示,送入线性分类头
序列标注(NER)在最后一层为每个 token 添加线性分类头
问答(SQuAD)使用两个分类头分别预测答案片段的起始和结束位置
句对任务(NLI)[SEP] 拼接句子,在 [CLS] 上进行分类

GPT:自回归的表亲#

$$\mathcal{L}_{\mathrm{GPT}} = -\sum_{t} \log P(x_{t} \mid x_{< t}).$$

在理解类任务中,BERT 的双向上下文更具优势;而在生成类任务中,GPT 的自回归结构更符合需求。如今的 decoder-only 大模型(LLaMA、Qwen、GPT-4)都继承了 GPT 的血脉。本文提到的微调原则几乎可以直接套用,只是学习率需要更小,同时更加依赖 LoRA。


完整实现:BERT 微调#

一个工业级的训练器,支持判别式学习率、梯度累积、混合精度以及 warmup + 线性衰减调度。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from transformers import (
    BertTokenizer, BertModel, AdamW, get_linear_schedule_with_warmup,
)
from torch.cuda.amp import autocast, GradScaler
from tqdm import tqdm
from sklearn.metrics import accuracy_score, f1_score

class BERTClassifier(nn.Module):
    """BERT 主干网络 + [CLS] 上的线性分类头。"""

    def __init__(self, bert_model_name="bert-base-uncased",
                 num_classes=2, dropout=0.1):
        super().__init__()
        self.bert = BertModel.from_pretrained(bert_model_name)
        self.dropout = nn.Dropout(dropout)
        self.classifier = nn.Linear(self.bert.config.hidden_size, num_classes)

    def forward(self, input_ids, attention_mask):
        outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
        pooled = self.dropout(outputs.pooler_output)
        return self.classifier(pooled)

class TextDataset(Dataset):
    """对文本进行分词和填充,适配 BERT 输入格式。"""

    def __init__(self, texts, labels, tokenizer, max_length=128):
        self.texts = texts
        self.labels = labels
        self.tokenizer = tokenizer
        self.max_length = max_length

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        encoding = self.tokenizer.encode_plus(
            str(self.texts[idx]),
            add_special_tokens=True,
            max_length=self.max_length,
            padding="max_length",
            truncation=True,
            return_attention_mask=True,
            return_tensors="pt",
        )
        return {
            "input_ids": encoding["input_ids"].flatten(),
            "attention_mask": encoding["attention_mask"].flatten(),
            "label": torch.tensor(self.labels[idx], dtype=torch.long),
        }

class BERTFineTuner:
    """支持判别式学习率、梯度累积和混合精度的训练器。"""

    def __init__(self, model, train_loader, val_loader,
                 num_epochs=3, learning_rate=2e-5, warmup_ratio=0.1,
                 gradient_accumulation_steps=1, max_grad_norm=1.0,
                 device="cuda", use_amp=True,
                 discriminative_lr=False, lr_decay=2.6):
        self.model = model.to(device)
        self.train_loader = train_loader
        self.val_loader = val_loader
        self.num_epochs = num_epochs
        self.device = device
        self.use_amp = use_amp
        self.gradient_accumulation_steps = gradient_accumulation_steps
        self.max_grad_norm = max_grad_norm

        total_steps = len(train_loader) * num_epochs // gradient_accumulation_steps
        warmup_steps = int(total_steps * warmup_ratio)

        if discriminative_lr:
            self.optimizer = self._discriminative_optimizer(learning_rate, lr_decay)
        else:
            self.optimizer = AdamW(model.parameters(), lr=learning_rate, eps=1e-8)

        self.scheduler = get_linear_schedule_with_warmup(
            self.optimizer, warmup_steps, total_steps,
        )
        self.scaler = GradScaler() if use_amp else None
        self.criterion = nn.CrossEntropyLoss()

    def _discriminative_optimizer(self, lr, decay):
        """底层学习率指数级递减,顶层保持最大学习率。"""
        num_layers = len(self.model.bert.encoder.layer)
        groups = []
        # Embedding 层 —— 学习率最小
        groups.append({
            "params": self.model.bert.embeddings.parameters(),
            "lr": lr / (decay ** num_layers),
        })
        # 每个 Transformer 层
        for i in range(num_layers):
            groups.append({
                "params": self.model.bert.encoder.layer[i].parameters(),
                "lr": lr / (decay ** (num_layers - i - 1)),
            })
        # Pooler + 分类头 —— 学习率最大
        groups.append({
            "params": list(self.model.bert.pooler.parameters())
                      + list(self.model.classifier.parameters()),
            "lr": lr,
        })
        return AdamW(groups, eps=1e-8)

    def train_epoch(self):
        self.model.train()
        total_loss = 0.0
        for step, batch in enumerate(tqdm(self.train_loader, desc="训练中")):
            input_ids = batch["input_ids"].to(self.device)
            mask = batch["attention_mask"].to(self.device)
            labels = batch["label"].to(self.device)

            if self.use_amp:
                with autocast():
                    loss = self.criterion(self.model(input_ids, mask), labels)
                loss = loss / self.gradient_accumulation_steps
                self.scaler.scale(loss).backward()
            else:
                loss = self.criterion(self.model(input_ids, mask), labels)
                loss = loss / self.gradient_accumulation_steps
                loss.backward()

            if (step + 1) % self.gradient_accumulation_steps == 0:
                if self.use_amp:
                    self.scaler.unscale_(self.optimizer)
                    nn.utils.clip_grad_norm_(self.model.parameters(),
                                             self.max_grad_norm)
                    self.scaler.step(self.optimizer)
                    self.scaler.update()
                else:
                    nn.utils.clip_grad_norm_(self.model.parameters(),
                                             self.max_grad_norm)
                    self.optimizer.step()
                self.scheduler.step()
                self.optimizer.zero_grad()

            total_loss += loss.item() * self.gradient_accumulation_steps
        return total_loss / len(self.train_loader)

    @torch.no_grad()
    def evaluate(self):
        self.model.eval()
        all_preds, all_labels, total_loss = [], [], 0.0
        for batch in tqdm(self.val_loader, desc="评估中"):
            input_ids = batch["input_ids"].to(self.device)
            mask = batch["attention_mask"].to(self.device)
            labels = batch["label"].to(self.device)
            logits = self.model(input_ids, mask)
            total_loss += self.criterion(logits, labels).item()
            all_preds.extend(logits.argmax(dim=1).cpu().numpy())
            all_labels.extend(labels.cpu().numpy())
        return (
            total_loss / len(self.val_loader),
            accuracy_score(all_labels, all_preds),
            f1_score(all_labels, all_preds, average="weighted"),
        )

    def train(self):
        best_val_loss = float("inf")
        for epoch in range(self.num_epochs):
            train_loss = self.train_epoch()
            val_loss, val_acc, val_f1 = self.evaluate()
            print(f"Epoch {epoch + 1}: train_loss={train_loss:.4f}  "
                  f"val_loss={val_loss:.4f}  acc={val_acc:.4f}  f1={val_f1:.4f}")
            if val_loss < best_val_loss:
                best_val_loss = val_loss
                torch.save(self.model.state_dict(), "best_model.pt")
                print("  -> 已保存最佳模型")

def main():
    BERT_MODEL = "bert-base-uncased"
    tokenizer = BertTokenizer.from_pretrained(BERT_MODEL)

    # 假数据,请替换为真实数据集
    texts = ["This movie is great!"] * 500 + ["This movie is terrible!"] * 500
    labels = [1] * 500 + [0] * 500

    dataset = TextDataset(texts, labels, tokenizer, max_length=128)
    train_loader = DataLoader(dataset, batch_size=16, shuffle=True)
    val_loader = DataLoader(dataset, batch_size=16)

    model = BERTClassifier(BERT_MODEL, num_classes=2)
    trainer = BERTFineTuner(
        model, train_loader, val_loader,
        num_epochs=3, learning_rate=2e-5,
        gradient_accumulation_steps=2,
        discriminative_lr=True,
        use_amp=True,
    )
    trainer.train()

if __name__ == "__main__":
    main()

关键技巧速查#

技巧功能使用场景
判别式 LREmbedding 层学习率为 LR / $2.6^{12}$ ,分类头使用完整 LRBERT 类模型微调时始终开启;底层参数通常不需要大幅调整
梯度累积小显存模拟大 batch 效果单 batch 显存不足(OOM)时
混合精度(AMP)FP16 前向计算 + FP32 主权重,速度提升 ~2 倍,显存节省 ~50%Volta 及以上 GPU 推荐始终开启
Warmup + 衰减稳定前几百步训练,后期逐步降低学习率微调时始终开启
梯度裁剪限制全局梯度范数,防止梯度爆炸始终开启,性能开销可忽略

Elastic Weight Consolidation:Fisher 加权锚定#

微调损失曲面:warmup + 小学习率 vs 朴素跳跃。

上一节中的 L2 锚定将每个参数视为同等重要,这显然是一种浪费。预训练网络中的大多数权重对源任务几乎无关紧要;只有少数几个承载了绝大部分能力。对它们施加统一的惩罚,要么对关键参数太宽松,要么对其他参数太严苛。

Elastic Weight Consolidation(EWC,Kirkpatrick 等,2017)用一个各向异性的约束替代了均匀的 L2 球。该惩罚在源任务不敏感的方向上拉伸,在敏感方向上收缩。只有当你推动一个“刚硬”的方向时,遗忘才会发生。

从贝叶斯后验推导#

$$\log P(\theta \mid \mathcal{D}_{1}, \mathcal{D}_{2}) = \log P(\mathcal{D}_{2} \mid \theta) + \log P(\theta \mid \mathcal{D}_{1}) + \text{const}.$$ $$\log P(\theta \mid \mathcal{D}_{1}) \approx -\frac{1}{2} (\theta - \theta^{*})^{\top} F (\theta - \theta^{*}) + \text{const}.$$ $$\mathcal{L}_{\mathrm{EWC}}(\theta) = \mathcal{L}_{\mathrm{task2}}(\theta) + \frac{\lambda}{2} \sum_{i} F_{i} (\theta_{i} - \theta^{*}_{i})^{2},$$ $$F_{i} = \mathbb{E}_{x \sim \mathcal{D}_{1}} \left[ \left( \frac{\partial \log p(y \mid x; \theta^{*})}{\partial \theta_{i}} \right)^{2} \right].$$

$F_{i}$ 恰好是源任务最优解处梯度的期望平方。若某参数在任务 1 中梯度已接近零,则 $F_{i} \approx 0$ ——可自由移动;若其梯度较大(损失对其敏感),则 $F_{i}$ 很大——被牢牢固定。

从零实现#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader

def compute_fisher(model, dataloader, n_samples=500, device="cuda"):
    """在当前参数处计算对角 Fisher 信息。"""
    model.eval()
    fisher = {n: torch.zeros_like(p) for n, p in model.named_parameters()
              if p.requires_grad}
    seen = 0
    for x, y in dataloader:
        x, y = x.to(device), y.to(device)
        model.zero_grad()
        logits = model(x)
        # 从模型预测分布中采样 y_hat。
        log_probs = F.log_softmax(logits, dim=-1)
        y_hat = torch.multinomial(log_probs.exp(), 1).squeeze(-1)
        loss = F.nll_loss(log_probs, y_hat, reduction="sum")
        loss.backward()
        for n, p in model.named_parameters():
            if p.grad is not None:
                fisher[n] += p.grad.detach() ** 2
        seen += x.size(0)
        if seen >= n_samples:
            break
    for n in fisher:
        fisher[n] /= max(seen, 1)
    return fisher

def ewc_penalty(model, theta_star, fisher, lam=400.0):
    """使用对角 Fisher 加权的二次锚定惩罚。"""
    loss = 0.0
    for n, p in model.named_parameters():
        if n in fisher:
            loss = loss + (fisher[n] * (p - theta_star[n]) ** 2).sum()
    return 0.5 * lam * loss

def finetune_with_ewc(model, source_loader, target_loader,
                      epochs=10, lr=1e-3, lam=400.0, device="cuda"):
    """两阶段:保存 theta*,在任务 1 上计算 Fisher,然后在任务 2 上微调。"""
    theta_star = {n: p.detach().clone()
                  for n, p in model.named_parameters() if p.requires_grad}
    fisher = compute_fisher(model, source_loader, n_samples=500, device=device)

    opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9)
    for epoch in range(epochs):
        model.train()
        for x, y in target_loader:
            x, y = x.to(device), y.to(device)
            logits = model(x)
            loss = F.cross_entropy(logits, y) \
                 + ewc_penalty(model, theta_star, fisher, lam=lam)
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model

EWC 与均匀 L2 的实际对比#

在同一设置下分别运行 $F_{i} \equiv 1$ (即 L2-SP,“所有参数同等锚定"基线)和真实对角 Fisher。在 CIFAR-10 到 STL-10 的小 ResNet 顺序迁移任务中,典型结果如下:

方法微调后任务 1 准确率任务 2 准确率平均
无正则化41.278.559.9
L2-SP ($F_{i} = 1$ , $\lambda$ 调优)72.076.874.4
EWC ($\lambda = 400$ )88.176.282.2

EWC 比 L2-SP 多保留了 16 个百分点的源任务准确率,仅在目标任务上损失约 0.6 分。原因在于结构差异:L2-SP 必须使用较小的 $\lambda$ 以允许无关参数自由移动,但这恰恰不足以保护关键参数。而 EWC 对每个参数单独决策。

注意事项#

  • Fisher 在 $\theta^{*}$ 处计算一次后不再更新。对于长任务序列,需使用 online EWC(Schwarz 等,2018),维护 Fisher 的滑动平均。
  • 对角 $F$ 忽略了参数间的相关性。虽有 K-FAC 和全矩阵变体,但很少值得其计算开销。
  • $\lambda$ 是唯一超参;应在源任务的验证集上调优,而非任务 2 上。

EWC 是应对遗忘的一种原则性方法——向过去正则化。下一个问题是其对偶问题:当特征本身必须从未标注数据中学习时,如何让对比目标足够稳定以学到有用表示?


对比损失稳定性:温度与批次大小#

NT-Xent 在纸面上看起来很简单。但在实践中,两个超参数——温度 $\tau$ 和批次大小——决定了 SimCLR 是教会编码器有用表示,还是训练它输出噪声。

$$\mathcal{L}_{i} = -\log \frac{\exp(s_{i, i^{+}} / \tau)}{\sum_{j \neq i} \exp(s_{i, j} / \tau)}, \qquad s_{i, j} = \frac{z_{i}^{\top} z_{j}}{\lVert z_{i} \rVert \lVert z_{j} \rVert}.$$

为何温度很敏感#

$$\frac{\partial \mathcal{L}_{i}}{\partial z_{i}} = \frac{1}{\tau} \left( \sum_{j \neq i} p_{i, j} \cdot \frac{\partial s_{i, j}}{\partial z_{i}} - \frac{\partial s_{i, i^{+}}}{\partial z_{i}} \right),$$

其中 $p_{i, j} = \exp(s_{i, j} / \tau) / \sum_{k} \exp(s_{i, k} / \tau)$ 是负例 $j$ 的 softmax 权重。立即可得两种情形:

  • $\tau$ (如 $0.05$:softmax 尖锐,$p_{i, j}$ 集中在最难的负例上——即与 $z_{i}$ 最相似的少数样本。$1 / \tau$ 因子放大其梯度。训练初期,编码器输出近乎随机嵌入,“最难”毫无意义,导致噪声梯度爆炸。
  • $\tau$ (如 $1.0$:softmax 平坦,所有 $p_{i, j}$ 约为 $1 / (B - 1)$ ,损失对 $z_{i}$ 几乎恒定。同时 $1 / \tau$ 因子缩小。模型学不到东西。

SimCLR 的最佳 $\tau$ 范围是 $0.07$$0.5$ ,但前提是编码器已初步稳定。若冷启动直接用 $\tau = 0.07$ ,极易引发梯度爆炸。

为何批次大小重要:InfoNCE 的方差#

$$I(z; z^{+}) \geq \log K - \mathcal{L}_{\mathrm{NCE}}^{(K)},$$

其中 $K$ 为负例数量。随着 $K$ 增大,下界收紧;同时估计量的方差大致以 $1 / K$ 速率下降。小批次会带来高噪声信号,使编码器偏向本步恰好采样的五六个负例。SimCLR 使用 4096–8192 的大批次并非炫技,而是方差控制的必需。

温度预热与稳定性监控#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import math
import torch
import torch.nn.functional as F

def nt_xent(z1, z2, tau):
    """带可配置温度的 SimCLR 损失。"""
    z = torch.cat([z1, z2], dim=0)                  # 2B x d
    z = F.normalize(z, dim=-1)
    sim = z @ z.t() / tau                           # 2B x 2B
    B = z1.size(0)
    mask = torch.eye(2 * B, device=z.device, dtype=torch.bool)
    sim.masked_fill_(mask, -1e9)                    # 去除自相似
    targets = torch.cat([torch.arange(B, 2 * B),
                         torch.arange(0, B)]).to(z.device)
    return F.cross_entropy(sim, targets)

def tau_schedule(step, total_steps, tau_start=0.5, tau_end=0.07, warmup_frac=0.1):
    """温度余弦预热:初始较大,快速退火。"""
    warmup_steps = int(total_steps * warmup_frac)
    if step >= warmup_steps:
        return tau_end
    progress = step / max(warmup_steps, 1)
    return tau_end + 0.5 * (tau_start - tau_end) * (1 + math.cos(math.pi * progress))

def train_step(model, x1, x2, opt, step, total_steps):
    tau = tau_schedule(step, total_steps)
    z1, z2 = model(x1), model(x2)
    loss = nt_xent(z1, z2, tau)
    opt.zero_grad()
    loss.backward()
    grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1e9)
    opt.step()
    if step % 50 == 0:
        print(f"step {step}  tau={tau:.3f}  loss={loss.item():.3f}  "
              f"grad_norm={grad_norm.item():.2f}")
    return loss.item(), grad_norm.item()

观察前几百步的 grad_norm 可立即判断 $\tau$ 是否过低。健康训练的梯度范数通常在 $1$$10$ 之间;若第一轮就出现超过 $100$ 的尖峰,说明预热时间太短。

数值示例#

在 CIFAR-10 上运行 SimCLR 风格实验:ResNet-18 主干,批次 1024,预训练 200 轮,然后在标注集上训练冻结特征的线性探针:

$\tau$ 调度策略线性探针准确率
固定 $\tau = 0.5$79.0
固定 $\tau = 0.07$86.0(需两次重启尝试)
前 10% 步骤余弦退火 $\tau = 0.5 \to 0.07$87.5

模型和数据完全相同。差异仅在于损失何时以及多快聚焦于难负例。

至此,自监督训练的稳定性问题已覆盖。有了稳固的先验,下一个实际问题是:如何低成本地适配它?这又回到了 LoRA,以及其秩应设为多大的问题。


LoRA 秩选择与适配器组合#

LoRA 的核心主张是“低秩更新已足够”。但这留给实践者两个原论文刻意模糊的问题:实际应选多大秩?能否在不重新训练的情况下堆叠多个 LoRA?

秩选择:经验扫描#

以 BERT-base 为例,仅用 LoRA 微调 SST-2(二分类情感任务)——主干权重冻结,每个注意力投影层训练 $A$$B$ 。扫描 $r \in \{1, 2, 4, 8, 16\}$ ,其余设置固定(学习率 $3 \times 10^{-4}$ ,3 轮,批次 32):

$r$可训练参数量开发集准确率
10.07 M89.1
20.15 M91.2
40.29 M92.0
80.59 M92.3
161.18 M92.4

两点观察:首先,秩 1 确实太小——瓶颈丢失了真实信息;其次,曲线在 $r = 4$ 后急剧平缓:翻倍至 $r = 8$ 仅提升 0.3 分,再翻倍仅得 0.1 分。此任务的拐点在 $r = 4$ 。实用启发法可推广:从 $r = 4$ 开始,若快速实验表明有效,可增至 $r = 8$ ;仅当目标任务与预训练分布显著不同时才考虑更高秩。

这与 LoRA 假设一致:若任务适配存在于低维子空间,只需张成该子空间。一旦满足,增加秩便无益。

适配器组合:增量的线性运算#

$$W = W_{0} + \alpha_{1} (B_{1} A_{1}) + \alpha_{2} (B_{2} A_{2}).$$

这就是无需训练的多任务适配方法。已有情感 LoRA 和金融领域 LoRA?加权融合常能直接得到可用的金融情感分类器,无需专门训练。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    """冻结的 Linear 层,在前向时叠加一个或多个 LoRA 增量。"""
    def __init__(self, base: nn.Linear):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad = False
        self.adapters = nn.ModuleList()  # 每个为 (A, B) 对
        self.weights = []                # 每个适配器的 alpha

    def add_adapter(self, A: torch.Tensor, B: torch.Tensor, alpha: float):
        ad = nn.ParameterDict({
            "A": nn.Parameter(A, requires_grad=False),
            "B": nn.Parameter(B, requires_grad=False),
        })
        self.adapters.append(ad)
        self.weights.append(alpha)

    def forward(self, x):
        out = self.base(x)
        for ad, alpha in zip(self.adapters, self.weights):
            out = out + alpha * (x @ ad["A"].t() @ ad["B"].t())
        return out

def compose_loras(base_model, lora_list, weights):
    """将 lora_list 中指定的 Linear 层替换为 LoRALinear,
    并按给定权重叠加提供的 (A, B) 增量。"""
    name_to_loras = {}  # layer_name -> (A, B) 列表
    for lora in lora_list:
        for name, (A, B) in lora.items():
            name_to_loras.setdefault(name, []).append((A, B))

    for name, mod in list(base_model.named_modules()):
        if name in name_to_loras and isinstance(mod, nn.Linear):
            wrapped = LoRALinear(mod)
            for (A, B), alpha in zip(name_to_loras[name], weights):
                wrapped.add_adapter(A, B, alpha)
            parent = base_model
            *path, last = name.split(".")
            for p in path:
                parent = getattr(parent, p)
            setattr(parent, last, wrapped)
    return base_model

实例:在 SST-2 上训练 sentiment_lora,在金融文本 MLM 任务上训练 domain_lora。以权重 $(0.7, 0.3)$ 组合,在留出的金融情感测试集上评估:

设置F1
基础 BERT,无 LoRA71.4
sentiment_lora78.9
domain_lora73.0
组合 $(0.7, 0.3)$80.6
在联合数据上训练单个 LoRA81.8

组合方法以零训练成本达到联合训练模型 1.2 F1 以内的性能。在小验证集上快速网格搜索调优权重,可进一步缩小差距。

两点警告:组合要求两个 LoRA 基于相同基模型检查点训练——跨基模型混合无定义。此外,若适配器包含非线性(如原始 Houlsby 适配器),权重线性运算性质即失效,这也是 LoRA 主导 PEFT 领域的又一原因。

至此,适配策略闭环完成:我们有了贝叶斯先验、无标签学习它的稳定方法、微调时保护它的原则性手段,以及在其上高效堆叠多任务增量的参数高效方式。剩下的失败模式正是此前被忽略的假设:预训练与部署数据同分布。一旦该假设不成立,再巧妙的微调也无济于事——这正是领域自适应(domain adaptation)的用武之地。

常见问题#

为什么微调时 warmup 效果这么好?#

新接的分类头在刚开始几步会产生很多噪声梯度。如果直接用全学习率把这些梯度推到预训练骨干里,就会在分类头还没稳定之前覆盖掉一些有用的权重。Warmup 的作用是让学习率在分类头逐渐稳定的过程中保持较低水平,之后再慢慢提升。

预训练需要多少数据?#

最低要求是:NLP 领域几百 MB 的文本,或者视觉领域几百万张图片。但多样性比数据量更重要。1000 万张同一物种的图片,效果远不如 100 万张涵盖多种类别的图片。Kaplan 等人(2020)提出的扩展定律表明,性能大致随语料规模呈幂律增长,但这只有在数据保持多样性和模型同步扩大的情况下才成立。

如何判断微调是否过拟合?#

有三个信号:(1)训练损失下降,但验证损失上升;(2)训练准确率很高,但验证准确率停滞不前;(3)模型变得过于自信,预测概率集中在 0 或 1 附近。解决办法包括:增加 dropout、使用早停、进行数据增强、冻结更多层,或者改用 LoRA。

LoRA 和全参微调,什么时候该用哪个?#

如果你需要在一个基础模型上支持多个任务(每个任务加载一个小 delta)、显存有限,或者想快速迭代,那就用 LoRA。如果你只有一个目标任务、标注数据充足,并且追求精度的最后几个百分点,那就选择全参微调。

为什么判别式微调会给底层设置这么小的学习率?#

底层学到的是几乎与任务无关的特征,比如子词统计、低级语法、简单的视觉原语。调整这些层对目标任务的提升很小,反而会降低模型的可迁移性。任务相关的决策主要发生在顶层,因此大部分学习应该集中在顶层。

总结#

预训练把全世界的无标注数据压缩成一个强大的先验。微调则是用你能拿到的标注数据,在这个先验基础上做贝叶斯更新。两者结合,让深度学习从“每个任务需要几百万标注”变成了“一个基础模型服务上千种专门化任务”。

关键点:

  • 自监督目标(对比学习、MLM)能自动产生监督信号——这是解锁海量无标注数据的关键。
  • 判别式学习率逐层解冻让不同层以合适的速度进行调整。
  • 线性探测是一个简单又高效的基线方法;如果数据充足,全参微调效果最好。
  • LoRA 和 Adapter 让微调既节省参数,又能轻松组合。
  • Warmup、衰减、梯度裁剪是几乎所有微调方案中必备的三板斧,用来保证训练稳定。
  • 灾难性遗忘确实存在——用正则化、回放或隔离策略来保护源任务的能力。

下一篇:第三篇 —— 域适应 。当预训练数据和部署数据分布不同时,光靠本文提到的微调技巧已经不够,还需要专门的对齐方法。

参考文献#

  1. Devlin et al. (2019). BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL. arXiv:1810.04805
  2. Chen et al. (2020). A Simple Framework for Contrastive Learning of Visual Representations (SimCLR). ICML. arXiv:2002.05709
  3. He et al. (2020). Momentum Contrast for Unsupervised Visual Representation Learning (MoCo). CVPR. arXiv:1911.05722
  4. Howard and Ruder (2018). Universal Language Model Fine-tuning for Text Classification (ULMFiT). ACL. arXiv:1801.06146
  5. Hu et al. (2022). LoRA: Low-Rank Adaptation of Large Language Models. ICLR. arXiv:2106.09685
  6. Liu et al. (2019). RoBERTa: A Robustly Optimized BERT Pretraining Approach. arXiv:1907.11692
  7. Houlsby et al. (2019). Parameter-Efficient Transfer Learning for NLP (Adapters). ICML. arXiv:1902.00751
  8. Kirkpatrick et al. (2017). Overcoming Catastrophic Forgetting in Neural Networks (EWC). PNAS. arXiv:1612.00796
  9. Kaplan et al. (2020). Scaling Laws for Neural Language Models. arXiv:2001.08361
  10. Wettig et al. (2023). Should You Mask 15 % in Masked Language Modeling? EACL. arXiv:2202.08005
本系列

迁移学习 12 篇

  1. 01 迁移学习(一):基础与核心概念
  2. 02 迁移学习(二):预训练与微调 当前
  3. 03 迁移学习(三):域适应
  4. 04 迁移学习(四):小样本学习
  5. 05 迁移学习(五):知识蒸馏
  6. 06 迁移学习(六):多任务学习
  7. 07 迁移学习(七):零样本学习
  8. 08 迁移学习(八):多模态迁移
  9. 09 迁移学习(九):参数高效微调
  10. 10 迁移学习(十):持续学习
  11. 11 迁移学习(十一):跨语言迁移
  12. 12 迁移学习(十二):工业应用与最佳实践

读有所得?

GitHub 关注我 → 新文周更

GitHub