Series · Transfer Learning · Chapter 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$,预训练语料为 $\mathcal{D}_{\mathrm{pre}}$,下游标注数据为 $\mathcal{D}_{\mathrm{task}}$。普通监督训练直接最大化

$$ \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 提供的,正是这样的先验。

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

定义特征提取器 $f_{\theta}: \mathcal{X} \to \mathbb{R}^{d}$。预训练实际上在寻找一个 $\theta$,使得对许多下游标签空间 $Y_{1}, Y_{2}, \dots$,互信息

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

都比较高。ImageNet 学到的边缘、纹理、物体部件远超分类用途——它对检测、分割、甚至医学影像都有帮助。BERT 学到的句法语义表示在各种 NLP 任务上都顶用。一个预训练模型,本质上是把数据压缩成保留了可迁移信息的特征。

收敛更快、极小点更好

预训练参数本身就落在损失曲面的低谷区域,微调只需要做局部调整——所以收敛更快,而且倾向于找到更平坦、泛化更好的极小点,比随机初始化好得多。


自监督预训练任务

自监督的精妙之处在于:设计一个能从数据里自动生成标签的任务,让模型通过预测输入的某部分来学习另一部分。

对比学习(视觉)

核心思路:把相似样本的表示拉近、把不相似样本的表示推远。

SimCLR

对一个 batch 里的每张图 $x$,应用两次随机数据增强(裁剪、颜色抖动、模糊)得到正样本对 $(x_{i}, x_{i'})$。设编码器为 $f$,投影头为 $g$,记 $z = g(f(x))$。一个正样本对的 NT-Xent 损失是

$$ \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:动量更新的负样本队列

MoCo 把负样本数量从 batch size 解耦出来。它维护两个编码器:被梯度更新的 query 编码器 $f_{q}$,和用指数移动平均更新的 key 编码器 $f_{k}$,

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

历史 key 被存进一个长度 65 536 的队列。字典够大,编码器又因为 $f_{k}$ 走得慢而保持一致。这样在单卡上就能拿到 SimCLR 级别的对比学习效果。

掩码语言模型(NLP)

BERT 的 MLM 目标

随机把句子里 15% 的 token 替换成特殊符号 [MASK],让模型从上下文里把原 token 还原出来:

$$ \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)替代它:负样本是把同样两句话调换顺序,这就逼出了真正的句间关系建模。


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

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

上图就是贝叶斯论证的实证版本。在模型、数据、优化器都一样的前提下,预训练初始化的模型:

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

接下来是工程问题:上亿参数的预训练模型,怎么微调才不会把它们一脚踩烂?

全参微调

最直接:解冻全部参数,端到端训练。为了防止参数偏离预训练 checkpoint 太远,可以加一个 L2 锚:

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

这是 Elastic Weight Consolidation 的简化版。实践中,“小学习率 + early stopping"自带的隐式正则通常就够了,不一定要显式加这一项。

判别式学习率

不同层装着不同性质的知识:

  • 底层(embedding、靠下的 Transformer block)学到的是几乎与任务无关的通用特征——子词统计、基础语法、低级视觉原语。要轻轻碰。
  • 顶层(分类头、最后一两层)是任务专用的。可以放心大力训。

ULMFiT 把这个想法形式化为判别式微调:对 $L$ 层的模型,第 $\ell$ 层的学习率为

$$ \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}$。

学习率调度与判别式 LR

层冻结

层冻结策略

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

四种常见模式

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

实操决策表:

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

线性探测 vs 全参微调

线性探测 vs 全参微调

线性探测在冻结的特征上训一个线性分类器。它是对表示质量最纯粹的检验——并且在极小数据场景下,它常常打败全参微调,因为根本没有足够的标签去安全地挪动 1.1 亿个参数。数据涨上来之后,全参微调反超并拉开差距。找到这个交叉点在哪,是值得在上线前花几次实验确认的事。

灾难性遗忘

微调过程中的灾难性遗忘

激进的微调是一条单行道:在目标任务上每涨 1 个点,源任务上很可能掉 1–2 个点。McCloskey 和 Cohen 1989 年就把这个现象命名为灾难性遗忘,它至今是序贯迁移的核心痛点。

三类常见的对策:

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

Adapter:参数高效微调

在每个 Transformer block 里塞一个 bottleneck 模块:

$$ \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:低秩权重更新

LoRA 更进一步。它不插非线性 bottleneck,而是直接对冻结权重矩阵的更新量做低秩分解:

$$ 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 编码器的堆叠。每个 block 是多头自注意力 + 前馈网络,配残差和 LayerNorm。

适配不同任务

任务BERT 怎么处理
文本分类[CLS] 表示喂给一个线性头
序列标注(NER)在最后一层做逐 token 分类
问答(SQuAD)两个头分别预测答案 span 的起始和结束位置
句对任务(NLI)[SEP] 拼接,再在 [CLS] 上分类

GPT:自回归的另一支

GPT 用从左到右的下一个 token 预测做预训练:

$$ \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
197
198
199
200
201
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):
    """对文本做分词与 padding,喂给 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:
    """带判别式 LR、梯度累积、AMP 的训练器。"""

    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="Training")):
            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="Evaluating"):
            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 倍、显存 ~省一半Volta 及以上 GPU 都开
Warmup + 衰减稳住前几百步、收尾时退火微调永远开
梯度裁剪给全局梯度范数封顶,防止偶发爆炸永远开,几乎零成本

常见问题

为什么微调时 warmup 这么有效?

新接上的分类头在前几步梯度噪声特别大。如果这时候让它以全学习率往预训练骨干里灌梯度,会在分类头还没稳定下来之前就把预训练的好权重部分覆盖掉。Warmup 让 LR 在分类头安顿期间保持很小,之后再涨上来。

预训练需要多少数据?

门槛大约是:NLP 几百 MB 文本,CV 几百万张图。但多样性比绝对量更重要。1000 万张同一物种的图远不如 100 万张涵盖各种类别的图。Kaplan 等人 2020 的扩展律说,性能大致随语料规模呈幂律增长,前提是数据保持多样、模型也同步放大。

怎么判断微调过拟合?

三个信号:(1)训练 loss 降但验证 loss 升;(2)训练准确率高、验证准确率停滞;(3)模型变得过度自信,预测概率挤在 0 和 1 附近。对策:加 dropout、early stopping、数据增强、冻更多层,或者换 LoRA。

LoRA 还是全参微调?

要在一个底座上服务多个任务(每个任务挂一个小 delta)、显存吃紧、或者要快速迭代,用 LoRA。如果只有一个目标任务、标注数据充足、追求最后一两个百分点的精度,上全参微调。

为什么判别式微调要给底层这么小的 LR?

底层学到的是几乎与任务无关的特征——子词统计、低级语法、视觉原语。动它们换不来多少目标任务的提升,反而损失可迁移性。任务相关的决策都在顶层,绝大多数学习应该发生在那里。


总结

预训练把全世界的无标注数据压缩成一个强先验;微调是在这先验上,用你能搞到的标注数据做贝叶斯更新。两者一起,把深度学习从"每个任务要几百万标注"变成了"一个底座服务上千个专门化任务”。

要点:

  • 自监督任务(对比学习、MLM)让模型从无标注数据里自动获得监督信号——这是解锁互联网级语料的钥匙;
  • 判别式学习率逐层解冻让不同层以合适的速度适配;
  • 线性探测是个便宜又好用的 baseline;全参微调在数据充裕时拿到天花板;
  • 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 (Adapter). 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

系列导航

部分主题
1基础与核心概念
2预训练与微调(本文)
3域适应
4小样本学习
5知识蒸馏
6多任务学习
7零样本学习
8多模态迁移
9参数高效微调
10持续学习
11跨语言迁移
12工业应用与最佳实践

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub