从一张显卡、几百张图说起:一位做工业质检的开发者找我喝茶,说他用自己厂里采集的两千多张缺陷样本从头训了个分类网络,准确率在验证集上冲到 78% 就卡死了,再加数据增强、换优化器、调学习率全都不动。我看了一眼他的训练曲线——训练损失一路往下掉,验证损失第三轮就开始抬头,典型的小样本过拟合。我让他把骨干换成在通用大规模图像数据集上预训练过的网络,只训最后几层,同一批数据、同一台机器,半小时后验证集到了 93%。他问我这是什么原理,我说这叫迁移学习(Transfer Learning)。这个思路不新鲜,但每年都有大量团队在数据不够的时候硬扛从头训练。这篇就把迁移学习这件事掰开讲清楚:它到底在迁移什么、什么情况下能迁移、参数该怎么设、代码怎么写、坑在哪里,从前沿算法工程师到刚入门的学生都能拿去用。
1. 迁移学习到底在解决什么问题
1.1 先说清楚“不迁移”的代价
从头训练一个深度网络,本质是让它从随机初始化开始,自己摸索出一套从像素到语义的映射关系。这个过程极其消耗数据。以图像分类为例,一个几十层量级的卷积网络要在通用图像数据集上训到收敛,通常需要百万量级的标注样本、多卡并行跑上数天。而现实中的项目是什么情况?医疗影像几千张、工业缺陷样本几百到几千张、某个垂直行业的文本分类任务标注数据可能只有两三千条。在这个数据量级上,模型参数远多于样本,网络很快就把训练集“背”下来了,泛化能力极差。
这里有个容易被忽略的点:模型容量和数据量不匹配时,增加正则化只能缓解,不能根治。你可以用 Dropout、权重衰减、早停把过拟合压下去一点,但模型没见过的特征模式就是没见过,它学不到。这就好比让一个从没出过村的人去描述大海,你给他再多描述技巧的训练,他脑子里也没有那个概念。迁移学习的价值就在这里:先把一个在大量数据上训练好的模型当作“有见识的老师傅”,再把你的小数据集交给它做针对性调整。
1.2 迁移的到底是什么:三层视角
很多人把迁移学习理解成“加载个预训练权重”,这个理解太浅。从原理上拆,迁移的东西可以分三层。
第一层是底层特征提取器。卷积网络的前几层学到的是边缘、角点、颜色斑块这类通用视觉模式,这些模式跟具体任务几乎无关——识别猫的边和识别电路板缺陷的边,用的是同一套滤波器。这部分知识迁移性最强,通常直接冻结。
第二层是中层组合结构。中间层学到的是纹理、局部形状、部件组合。这部分跟数据域有关:自然图像里的纹理和工业图像里的纹理分布不同,但组合逻辑仍然可复用。实践中往往做部分微调。
第三层是高层语义与任务头。最后几层对应的是“这个类别长什么样”的判别逻辑,跟具体任务的标签体系强绑定,迁移价值最低,通常直接替换掉重训。
理解这三层,你才能回答“该冻结几层、该解冻几层”这个问题。我见过不少人一上来就全量微调,结果小数据集把底层通用特征也带偏了,效果反而不如只训分类头。这就是后面要讲的负迁移的一种表现形式。
1.3 哪些场景适合迁移,哪些别硬套
迁移学习不是万能药,判断标准要看源域和目标域的距离。实践中可以按这个顺序自检:
- 任务类型一致、数据分布接近:比如都是自然场景图像分类,只是类别不同。迁移效果最好,几乎稳赚不赔。
- 任务类型一致、数据分布有差异:比如自然图像预训练模型迁移到医学影像、遥感图像、工业缺陷图。需要更谨慎的解冻策略和更强的数据增强,效果通常仍然正向。
- 任务类型不同:比如图像预训练模型拿去做表格数据预测。这时只有极底层的特征可能有微弱作用,基本等同于重新设计模型。
- 目标域数据量已经很大:比如百万级标注样本。从头训练和迁移训练的差距会缩小,迁移的收益主要体现在收敛速度和更稳定的初期训练上。
注意:判断能不能迁移,别只看“都是图像”或“都是文本”。要看输入信号的统计特性是否接近。同样是图像,X 光片和手机拍的照片在对比度、噪声分布、色彩通道上差别巨大,直接套用往往需要额外做归一化和域适配。
2. 方案选型:三条迁移路线怎么挑
2.1 特征提取:最稳的起步姿势
做法是把预训练网络的骨干全部冻结,只把最后的分类层换掉,用你的数据训练这个新头。冻结的意思是这些层的参数不参与梯度更新,前向计算照常进行。
这条路线的优点非常明确:训练成本极低,几百张图、单卡几分钟就能跑完一轮;几乎不会破坏预训练知识;超参不敏感。缺点是上限受限——如果目标任务需要的特征和源任务差异较大,冻结的骨干可能根本提取不出有用信息,这时候你把分类头训出花来也没用。
什么时候选它?我的经验是:目标数据少于每类一百张时,先只训头,把它当作 baseline 和“域差异探测器”。如果只训头就能到不错的精度,说明域差异小,可以放心往下微调;如果只训头精度和随机猜差不多,说明域差异大或者标签映射有问题,这时候全量微调大概率也救不回来,得先检查数据和预处理。
2.2 微调:效果上限最高的路线
微调是在特征提取的基础上,解冻骨干的一部分或全部,用很小的学习率继续训练。它能让模型的高层特征适配你的数据分布,是绝大多数中等数据量场景下的首选。
微调的关键不在“要不要做”,而在解冻多少、用什么学习率、训几轮。这三件事互相耦合,后面第 3 节会展开。这里先给一个可以直接抄的默认策略:
- 先冻结骨干,只训分类头 3–5 个 epoch,让新头收敛到一个合理初值。
- 然后解冻骨干的最后 1–2 个 stage(比如残差网络的最后一个 block 组),学习率降到分类头的 1/10。
- 如果验证集指标还在涨且没有过拟合迹象,再逐层往上解冻,每次解冻都相应降低学习率。
- 始终保留一个“全冻结”的对照组,用来判断微调是否带来了负收益。
2.3 三种路线横向对比
| 维度 | 从头训练 | 仅训分类头 | 分层微调 |
|---|---|---|---|
| 所需样本量 | 万级以上 | 百级起 | 千级起 |
| 单轮训练耗时 | 高 | 极低 | 中等 |
| 过拟合风险 | 极高 | 低 | 中 |
| 效果上限 | 取决于数据量 | 受限于冻结特征 | 最高 |
| 超参敏感度 | 高 | 低 | 中高 |
| 典型适用场景 | 大数据、特殊模态 | 极小样本、快速验证 | 主流落地场景 |
这张表不是让你死记,而是帮你建立决策直觉:数据越少,越往左选;数据越多,越往右选。中间地带就做实验,用验证集说话。
2.4 迁移之前先给数据做个体检
这一步很多人跳过,但它能省掉后面几天的瞎调参。具体做三件事。
第一,算一下目标数据集和预训练数据在输入层面的统计差异。至少看通道均值、方差、尺寸分布。如果差得离谱,先做标准化对齐。
第二,用全冻结模型在目标数据上跑一遍推理,看看预测的类别分布。如果模型把九成样本都判成同一类,说明特征空间和你的任务严重不匹配,微调时要更激进地解冻。
第三,抽几十张样本做个可视化,看预训练模型关注的是不是你关心的区域。有条件的可以用类激活图看热力分布,如果模型盯着背景看,说明你的数据里存在捷径特征,得先做数据清洗,否则迁移只会放大这个问题。
3. 核心细节:决定成败的几个参数
3.1 冻结策略:由浅入深地解冻
冻结哪些层,本质是在“保留通用知识”和“适配目标分布”之间做权衡。我给一个可操作的判断依据:看目标任务需要多细粒度的特征。
做粗粒度分类(比如区分“有缺陷/无缺陷”),中层特征就够用,解冻最后一到两个 stage 即可。做细粒度分类(比如区分十几个高度相似的子类),需要更精细的高层特征,可以解冻更多层,但必须同步降低学习率并加强正则。
还有一个细节容易被忽略:批量归一化层(BatchNorm)的统计量。这些层在前向时会用当前 batch 的均值和方差做归一化,如果你的 batch 很小(比如 8 或 16),统计量估计非常不稳定,会直接破坏预训练权重的分布。我的做法是:冻结阶段把 BN 层设成 eval 模式,让它固定使用预训练时累积的统计量;微调后期如果数据量足够、batch 够大,再放开。
def freeze_bn(module): for m in module.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() m.weight.requires_grad = False m.bias.requires_grad = False这段代码在训练循环里每个 epoch 开始前调用一次,因为model.train()会把所有 BN 层重新设回训练模式,顺序错了就前功尽弃。
3.2 学习率:为什么必须小到“不像话”
微调的学习率通常比从头训练小一到两个数量级。原因很直接:预训练权重已经处于一个较优的局部区域,学习率过大时,第一批梯度就能把权重推出这个区域,等于把预训练成果扔了。
给个具体量级参考。从头训练卷积网络,SGD 学习率常用 0.01–0.1;微调时,分类头可以用 1e-3,骨干用 1e-4 到 1e-5。如果你用 Adam 系列优化器,整体再往下压,分类头 1e-4、骨干 1e-5 是比较安全的起点。
更精细的做法是分层学习率,越靠底层的学习率越小。假设总共有 L 个参数组,第 i 组的学习率可以按下式衰减:
$$ \eta_i = \eta_{base} \times \gamma^{(L-i)} $$
其中 γ 取 0.8–0.9。举个例子,骨干有 4 个 stage,分类头学习率 1e-3,γ=0.8,那么从顶层到底层的学习率依次是 1e-3、8e-4、6.4e-4、5.12e-4、4.1e-4。这个衰减让底层几乎不动,高层充分适配,实践中比“一刀切”稳不少。
另外别忘了预热。前 5% 的训练步用线性方式把学习率从 0 拉到目标值,能显著降低训练初期的震荡。按数据量 2000 张、batch size 32 算,一个 epoch 约 63 步,训 20 个 epoch 共 1260 步,5% 就是 63 步预热,实现起来就几行代码。
3.3 数据预处理必须和预训练完全对齐
这是最隐蔽的坑。预训练模型在训练时对输入做了特定的归一化,通常是按通道减均值、除标准差。你推理和微调时如果用了不同的均值方差,或者忘了做归一化,模型看到的数据分布就变了,精度会莫名其妙地掉一大截,而且报错信息完全没有,你只会觉得“迁移学习好像没什么用”。
解决办法很简单:把预处理参数写成配置项,和权重来源绑定在一起。下面是一组常见的图像归一化参数,实际使用时按你加载的权重说明来改。
IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ])注意验证集和测试集只能用Resize + CenterCrop + Normalize,不能带随机增强。我见过有人在验证集上用了随机裁剪,导致同一个模型每次评估结果上下浮动三四个点,白白浪费两天去调参。
3.4 数据增强:小样本场景下的第二增长曲线
迁移学习解决的是“模型没见过世面”的问题,数据增强解决的是“样本多样性不足”的问题,两者叠加效果最好。但增强策略要跟任务匹配,不能无脑堆。
图像分类里比较稳的组合是:随机裁剪缩放、水平翻转、颜色抖动(亮度、对比度、饱和度小幅扰动)、随机擦除。前两个几乎无成本,后两个要控制幅度,颜色抖动过强会破坏判别性特征。
如果样本极少(每类几十张),可以考虑 Mixup 或 CutMix 这类混合样本的方法,它们能显著抑制过拟合。但要注意:这两类方法会改变标签分布,如果你的任务对细粒度边界很敏感(比如某些缺陷分级),混出来的样本语义可能不成立,得先小规模验证再上。
文本任务那边的“增强”思路不同,常见的是回译、同义词替换、随机遮挡,但噪声更大,我一般建议优先保证数据质量,增强只作为补充。
3.5 类别不平衡与少样本的联合处理
迁移学习经常和不平衡数据一起出现。比如缺陷检测,正常样本几千张,缺陷样本几十张。这时候单纯靠微调不够,还要在损失函数和采样策略上做文章。
比较稳妥的组合拳是:分类头用带类别权重的交叉熵,权重按类别频次的倒数开方来设(不要直接用倒数,会过校正);训练时用加权随机采样,保证每个 batch 里少数类占一定比例;评估指标别看准确率,看每类召回率和宏平均 F1。
提示:少数类样本少于 20 张时,不建议单独划出验证集,容易造成评估方差过大。可以用分层 K 折交叉验证,报告均值±标准差,判断改进是否真实。
4. 实操:完整跑通一次迁移学习
4.1 环境与数据组织
我以图像分类为例走一遍全流程,文本任务的思路完全一致,只是把骨干换成预训练语言模型、把预处理换成分词。环境上,主流深度学习框架加图像处理库就够,单卡即可。
数据按标准的train/val/test三层目录组织,每个子目录名就是类别名。这种结构能直接被框架的数据加载器识别,省去写映射表的麻烦。
dataset/ train/ class_a/ ... class_b/ ... val/ class_a/ ... class_b/ ... test/ class_a/ ... class_b/ ...划分比例上,如果总样本量在千级,建议 7:1.5:1.5;如果只有几百张,把验证集比例压到 10%,测试集单独留一份固定不动的“最终考卷”,调参期间绝对不碰。
4.2 构建模型与冻结策略
下面这段代码完成三件事:加载预训练骨干、替换分类头、按阶段冻结参数。分类头的输出维度要改成你自己的类别数,这是必须的,否则维度对不上直接报错。
import torch import torch.nn as nn from torchvision import models def build_model(num_classes, backbone_name="resnet50"): if backbone_name == "resnet50": model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) # 第一阶段:冻结整个骨干 for name, p in model.named_parameters(): if not name.startswith("fc."): p.requires_grad = False return model替换分类头时加一层 Dropout 是我个人的习惯,小数据集上能再压一两个点的过拟合。注意新加的全连接层是随机初始化的,它的梯度会比预训练部分大得多,所以要么给它单独设更大的学习率,要么在冻结阶段先单独训几轮让它稳定下来,别一上来就全网络一起更新。
4.3 分阶段训练脚本
训练分两段:先只训头,再解冻最后一个 stage 联合微调。两段用不同的学习率和 epoch 数,这样比一次性设好参数更容易控制。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_stage(model, loaders, epochs, lr, device, unfreeze_last_block=False): if unfreeze_last_block: for p in model.layer4.parameters(): p.requires_grad = True params = [ {"params": model.layer4.parameters(), "lr": lr * 0.1}, {"params": model.fc.parameters(), "lr": lr}, ] else: params = [{"params": model.fc.parameters(), "lr": lr}] optimizer = optim.AdamW(params, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs) criterion = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() freeze_bn(model) # 保持 BN 统计量不变 # ... 标准训练循环:前向、算损失、反向、更新 scheduler.step()几个参数的选择依据说清楚:优化器用 AdamW 而不是 SGD,是因为小数据集上 SGD 对学习率太敏感,AdamW 的适应性更好;权重衰减设 1e-4,起正则作用但不至于把预训练权重压得太狠;余弦退火让学习率平滑下降,避免末期震荡。层的学习率比例设成 1:10,对应前面讲的“越靠底层越小”的原则。
4.4 训练过程该盯哪些指标
很多人只看验证集准确率,这在小样本和不平衡场景下会骗人。我一般同时盯四个数:
- 训练损失和验证损失的差值。差值持续扩大说明过拟合,这时候该停或者加强正则。
- 验证集宏平均 F1。类别不平衡时它比准确率可靠得多。
- 每类召回率。看少数类有没有被整体忽略。
- 学习率曲线。确认预热和退火按预期执行。
判断“微调是否有效”的标准我定得很死:必须和全冻结 baseline 在同一份验证集上比较,提升超过两个点才算数。一两个点的波动在小数据集上完全可能是随机噪声,别急着庆祝。
4.5 一次真实实验的记录
下面是我在一个约 2400 张图、6 个类别的工业缺陷数据集上做的对比。所有实验用同一份数据划分、同一套增强、跑三次取均值。
| 实验配置 | 验证集准确率 | 宏平均 F1 | 训练耗时(单卡) |
|---|---|---|---|
| 从头训练,100 epoch | 76.2% | 0.71 | 约 95 分钟 |
| 仅训分类头,15 epoch | 88.5% | 0.85 | 约 8 分钟 |
| 解冻最后 stage,20 epoch | 93.1% | 0.91 | 约 22 分钟 |
| 全量微调,20 epoch | 90.4% | 0.87 | 约 30 分钟 |
| 解冻最后 stage + Mixup | 93.6% | 0.92 | 约 25 分钟 |
这张表里最有意思的是最后两行:全量微调反而不如只解冻最后一个 stage。原因就是我前面说的,数据量不够时,底层通用特征被目标数据带偏了,产生了轻度负迁移。这个结论在我的其他项目里也反复出现,所以我现在默认从不全量微调起步。
5. 常见问题与排查速查表
5.1 损失不降或者剧烈震荡
先查三件事,按顺序来。第一,预处理是否和预训练权重匹配,均值方差对不对。第二,分类头的初始学习率是不是太大,试着降到 1e-4 再看。第三,标签有没有错位,尤其是用目录名映射标签的时候,排序不一致会导致标签全错,但损失看起来“正常地”不降。
如果这三点都没问题,看 batch size。小于 8 的时候 BN 统计量会崩,建议改用组归一化,或者干脆冻结 BN。另外检查一下数据加载器有没有把图片通道顺序搞反,RGB 和 BGR 弄混是经典事故,模型能训但精度上不去。
5.2 验证集很早就开始掉头
典型的小样本过拟合。处理顺序建议是:先加数据增强,再加 Dropout 和权重衰减,最后才考虑减少解冻层数。很多人第一反应是减层,其实增强往往更有效。
还有一个容易被忽略的点:验证集本身太小。几百张图的验证集,指标波动本来就大,你可能是在对噪声做决策。这种情况我会用交叉验证的均值来判断,而不是单次结果。
5.3 微调后效果反而不如冻结
这就是负迁移。诊断方法很直接:分别记录冻结阶段和解冻阶段每个 epoch 的验证指标,看解冻后是从第几个 epoch 开始变差的。如果是解冻后立刻变差,说明学习率太大;如果是训练几轮后缓慢变差,说明模型在拟合目标域的特有噪声。
对策上,优先缩小解冻范围、降低学习率、缩短微调轮数。如果还不行,那可能这个预训练模型和你的域确实不匹配,换一个在相近领域预训练的权重,效果往往立竿见影。
5.4 离线指标好,上线就掉点
这是域偏移问题,跟迁移学习本身无关,但经常被混为一谈。离线验证集和线上真实数据的采集条件不同——光照、设备、拍摄角度、后期处理都可能变。解决办法是在训练时就把这些变化纳入增强,或者在部署后收集线上数据做增量微调。
注意:增量微调时学习率要比初次微调再降一个量级,并且保留一部分历史数据一起训,否则模型会快速遗忘旧分布,出现“学了新的忘了旧的”。
5.5 问题速查表
| 现象 | 最可能原因 | 优先动作 |
|---|---|---|
| 损失完全不降 | 预处理不匹配、标签错位 | 核对归一化参数与标签映射 |
| 损失震荡 | 学习率过大、batch 过小 | 降学习率、冻结 BN |
| 验证集早期掉头 | 过拟合、验证集太小 | 加增强、交叉验证 |
| 微调不如冻结 | 负迁移 | 缩小解冻范围、降学习率 |
| 少数类召回极低 | 类别不平衡 | 加权损失、加权采样 |
| 上线精度掉点 | 域偏移 | 对齐采集条件、增量微调 |
| 每轮评估结果波动大 | 验证集用了随机增强 | 验证流程改为确定性变换 |
6. 几个我踩出来的硬经验
第一条,永远先跑全冻结的 baseline。这个习惯帮我省过太多时间。没有 baseline,你根本不知道微调带来的提升是真实的还是噪声。而且全冻结跑得极快,几分钟就能给你一个“域差异有多大”的判断。
第二条,学习率宁小勿大。微调时学习率设小了,最多是收敛慢一点,多跑几轮就回来了;设大了,预训练权重直接被冲垮,训练曲线看起来还挺正常,但精度就是上不去,排查起来非常痛苦。我现在的默认起点是骨干 1e-5、头 1e-4,只有在数据量上万时才往上调。
第三条,别迷信“更大更强的预训练模型”。骨干越大,在小数据集上越容易过拟合,而且推理成本上去了。我做过一组对比,中等规模的骨干在小样本任务上经常打平甚至超过超大骨干,因为后者需要更多数据才能发挥。选型时把参数量和你的数据量放在一起看,别只看排行榜。
第四条,把预处理参数和权重版本绑定管理。这是工程习惯问题。项目里经常出现换了个权重、忘了改归一化参数,然后花两天怀疑人生。我现在会把均值方差写进配置文件,跟权重文件名一一对应,加载时自动读取,从源头上消灭这类事故。
第五条,记录每一次实验的完整配置。数据划分、随机种子、增强参数、学习率、解冻策略,一个都不能少。小数据集上的结果方差很大,没有完整记录,你复现不出两周前那个“效果特别好”的配置,等于白做。
最后分享一个扩展方向:如果你手上的目标任务连标注数据都很少,可以考虑先用无标注数据做自监督预训练,再迁移到下游任务。这条路在文本和图像领域都已经比较成熟,本质还是迁移学习,只是把“源任务”从有监督换成了自监督。我最近在一个只有几百条标注的文本分类任务上试过,比直接用通用预训练权重又涨了三个多点,代价是要多花一些算力做预训练。值不值得,看你的标注成本和无标注数据量,一般无标注数据能到标注数据的百倍以上,这笔账就算得过来。