把在ImageNet上预训练好的VGG模型拿来做迁移学习,这句话听起来很常见,但真正动手之后会发现,从“加载预训练权重”到“模型在自己的小数据集上稳定收敛”之间,隔着大量容易被低估的细节。前段时间我做一个模拟的四分类外观缺陷识别任务,类别包括划痕、凹陷、污渍、正常,数据量不大,但图像风格和ImageNet差距又比较明显。整个流程推进下来,踩了好几次失速、过拟合、验证集波动的坑,所以这篇就用这次实践作为主线,把数据准备、迁移策略、代码骨架、训练监控这四个阶段拆开讲清楚,给想做VGG迁移学习的人一份可以直接对照执行的操作记录。
1. 为什么到现在还在用VGG做迁移学习
1.1 VGG是不是“过时货”?先算三笔账
现在一提视觉模型,大家第一反应是ResNet、EfficientNet、Swin Transformer这类新结构。VGG16参数多、计算量大、结构也不花哨,看起来确实不像最佳选择。但迁移学习场景下,事情不能只看单次推理的速度和理论精度,我习惯先算三笔账:预训练权重成熟度、工程实现成本、小数据适配度。
第一笔账是预训练模型的可获得性和稳定性。VGG系家族在绝大多数视觉框架里都有现成权重,版本迭代少、接口稳定,不会出现“换了个框架之后权重格式对不上”这种意外。ImageNet上训练好的VGG特征提取器经过大量社区项目反复验证,行为模式基本可预期。对于迁移学习来说,可预期的起点比“稍微快一点但处处要调试”的新模型更有价值。
第二笔账是模型结构本身的可分解性。VGG就是一堆卷积层堆叠然后接全连接层,特征提取和分类边界非常清楚。做迁移学习时,我们经常要区分“保留通用特征的部分”和“需要针对新任务重新学习的部分”,VGG的features模块和classifier模块分得明明白白,冻结、解冻、替换头都容易控制。相比之下,有些结构把残差连接、注意力模块、分类头耦合在一起,修改起来反而容易引入隐性问题。
第三笔账是数据规模适配。现代轻量模型在完整训练时优势明显,但小数据集下如果没有预训练权重,很容易陷入震荡;如果强行微调很深的网络,又要做大量正则化调参。VGG这种相对“笨重”但特征层次清晰的网络,在小数据迁移场景里反而稳定,因为它的低层卷积学到的边缘、纹理、颜色块特征非常通用,换一个领域仍然适用。
1.2 迁移学习能成立的核心原因:特征的可迁移性
迁移学习能成立,本质原因是卷积神经网络的不同层在学不同抽象层级的东西。浅层卷积核学习的是边缘、颜色梯度、角点这类低级视觉特征,这些特征在不同图像任务之间高度通用;中间层开始组合成纹理、局部形状;高层卷积才慢慢靠近任务专属语义,比如“这是某个特定物体的轮毂”。
所以当我们在ImageNet上预训练好一个VGG模型时,实际上是把一个“已经见过大量图像的通用视觉特征提取器”存了下来。新任务如果是工业缺陷、医学影像、卫星图这类与ImageNet差异较大的域,浅层和中层特征依然能用,需要调整的主要是高层语义和最后的分类器。这是VGG迁移学习能够用小数据训练出可用模型的最根本理由。
在实际项目里,我通常会先用预训练模型做特征提取,用分类头的训练结果反推数据质量,再决定是否解冻高层卷积。这个顺序本身就是在利用特征的可迁移性,一步一步判断哪些特征被新任务复用,哪些特征需要微调才能适配。
1.3 什么样的任务适合VGG迁移,什么样不适合
不是所有场景都适合VGG迁移学习。我个人的判断标准是:训练集在几百到几万张量级、图像尺寸不用太大、任务类别和ImageNet没有完全重叠但保留了基本视觉结构,这类场景最适合。
反之,如果你的任务本身极度依赖非常独特的低级纹理,而且采样域跟自然图像差异很大,比如某些特殊材质的超分辨率重建或者大规模检索,迁移价值会打折扣,不如直接做专门的预训练或者改用别的结构。还有一个情况要避坑:实时推理部署场景。VGG计算量大,即使迁移学习让训练速度快了,推理时依然要跑完整网络,移动端或者高并发服务里压不住。我见过不少人花大力气把VGG微调好,最后发现部署延迟超预算,又回头换成轻量模型,非常折腾。
2. 数据准备:训练开始前就决定了大半结果
2.1 别急着上模型,先给数据和标签做个“体检”
一开始我拿到那个四分类缺陷任务时,第一反应是加载模型然后跑起来,结果发现验证集上loss一直降不下来,根源却在数据里。所以现在做迁移学习,我坚决反对跳过数据检查直接开训。你要知道自己喂给预训练网络的数据长什么样,才能判断迁移的难度。
数据体检至少包含三步:检查每类样本数量是否均衡、检查标签是否存在噪声、检查图像分辨率是否一致。第二步尤其关键。缺陷检测里一个常见问题是“划痕”和“污渍”在视觉上有重叠,标注人员凭直觉给标签,模型就会学到错误映射。我的处理办法是随机抽一批图像生成一个标签分布统计图,人眼过一遍,把明显错标移除或修正。这个步骤看起来土,但收益极大。
还要看图像的平均尺寸和长宽比。VGG的经典输入是224×224,如果你的原始图像是1024×1024的工业相机图片,直接缩放会丢失大量局部细节。这种情况我会先做区域裁剪或滑窗切块,把关键缺陷区域切出来再缩放,而不是把整张大图硬压成小图。
2.2 预处理和图像增强:尊重预训练分布
预训练模型不是凭空工作的,它在预训练阶段学习到的是特定数据分布。加载VGG时,官方推荐预处理通常是把像素归一化到ImageNet的均值和标准差,均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225]。这个归一化不能改,否则模型输入的数值范围跟预训练时的分布不一致,低层特征提取器等于被喂了另一种分布的数据,迁移效果直接崩掉。
图像增强方面,我的经验是分成两类:一类不影响特征分布,比如随机水平翻转、随机旋转、随机裁剪;另一类要谨慎,比如颜色扰动、对比度调整,过度使用会把图像推向一个预训练模型没见过的新分布。对于缺陷检测任务,我建议优先使用几何增强,对颜色和亮度的扰动幅度控制在很小范围内。
还要注意增强的时机和强度。训练初期样本量少时,增强强度可以高一些,帮助模型学习到足够的不变性;到后期接近收敛时,增强过强会导致loss波动变大。我在实践里用的典型配置是,训练时随机缩放裁剪到224×224,再加水平翻转;验证和测试时用中心裁剪,不做随机增强,这样能保证评估结果稳定可复现。
2.3 划分训练/验证/测试集,不只是分个比例
很多人按常见的8:1:1划分数据就开跑了,但在迁移学习里这还不够。预训练模型已经从ImageNet学到了丰富特征,我们要验证的是“迁移后的模型在目标域到底学到了什么”,所以验证集必须有代表性。
我的做法是分层抽样,保证每个类别在训练、验证、测试中的占比一致,避免某些类只在训练集里出现。另外,如果数据是同一个批次采样的,不要随便按文件名排序后切片,否则可能出现“时间偏移”造成的信息泄露。正确做法是先把所有文件路径和标签随机打乱,再用分层抽样工具切分。
还要算一下验证集的样本量是否够用。如果总数据量只有几百张,验证集小于50张时,准确率波动会非常大,这时候不如使用交叉验证,或者把训练集再切一小部分出来持续监控。数据划分完成后,所有增强和预处理都必须在划分之后进行,防止同一张图的增强版本出现在训练和验证两个集合里。
3. 冻结还是微调:三条路线的取舍理由
3.1 三条常见迁移路线的直观对比
迁移学习遇到最多的一个问题就是:到底冻结多少层,解冻多少层,全部放开微调还是只训练新分类头。常见路线可以归纳成三种。
路线一是完全冻结特征提取器,只训练新分类头。把VGG的所有卷积层保持预训练状态不变,相当于用固定的“特征编码器”把每张图转换成特征向量,然后训练一个线性分类器或者小MLP来判断类别。这个方案训练参数极少、速度最快、不容易过拟合,适合数据量特别小且新任务和ImageNet比较接近的情况。
路线二是全局微调,也就是整个模型参与训练,只有初始权重从预训练权重开始,后续所有层都根据新任务进行更新。这个方案给了模型最大的适配能力,但数据量不足时容易过拟合,而且训练耗时明显增加,优化器也不好调,预训练部分理应有较低的学习率。
路线三是分阶段解冻,也是我现在最常用的思路。先用路线一训练分类头,让新层快速稳定;然后逐步解冻部分高层卷积层,降低学习率继续微调;再根据验证集表现决定是否继续解冻更多层。每一步都有明确目标和验证节点,不会一上来就把整个模型推入不可控状态。
| 方案 | 训练参数量 | 过拟合风险 | 算力成本 | 适用数据规模 |
|---|---|---|---|---|
| 冻结特征层 | 极少 | 低 | 低 | 小到中等 |
| 全局微调 | 极多 | 高 | 高 | 中到大 |
| 分阶段解冻 | 适中 | 中 | 中 | 中等,且可控 |
3.2 我为什么先冻结后分阶段解冻
回到这个四分类缺陷任务,我的初始选择是“冻结全部features层 + 训练新分类头”。原因很简单:总数据量不大,而缺陷图像的边缘、纹理特征和自然图像有一定重合,冻结低中层的成本很低。第一轮跑下来,验证集准确率达到了相当可用的水平,证明预训练特征确实迁移有效。
紧接着我观察到一个现象:分类头很快收敛,但一些“污渍”类样本和“正常”类样本之间仍然误判得比较离谱。这说明缺陷的细节语义没有被高层特征充分表达,只靠分类头学不出来。于是我第二次训练选择解冻VGG靠后的几个卷积块,给高层特征一个适配目标域的机会。解冻后的第一轮,验证准确率又往上爬了一段,同时过拟合来得也比以前快,需要在训练中期增加早停和更强的增强。
这个决策逻辑就是:先用固定特征判断“任务能不能靠通用特征解决”,再用高层微调判断“任务需要多少额外适配”。如果一开始就全局微调,就很难分清楚准确率提升到底来自特征适配还是单纯的过拟合。
3.3 计算资源与训练时间的边界要提前算清楚
选择冻结还是微调,说到底还要算清自己手头的计算资源。VGG这种网络结构不算新,实际参数量却没有缩水,VGG16光features层参数就超过一个量级。如果没有一块像样的GPU,全局微调一跑就是十几个小时甚至几天,试错成本很高。
我一般会在训练之前做一个快速估算。假设训练集有5000张图,batch size是32,一个epoch大约160次迭代。冻结特征时前向反向只更新分类头,速度和完整微调差别不大,因为前向计算还是得走完整个features层,差别主要在反向传播的参数更新量。体感上,冻结方案的训练时间大概能比全局微调快15%到30%,更多的收益在于参数少、过拟合风险低、不需要花大量时间去调预训练部分的学习率。
我建议新手先按“冻结特征+分类头”跑出一版结果,然后解冻最后两个卷积块,如果验证集提升不明显,就不必继续往深层微调。这样兼顾效果和成本,也是很多工业项目里迁移学习的标准打法。
4. 核心代码实现:加载、冻结、替换分类头到完整训练循环
4.1 加载预训练VGG并替换分类头
实操层面我用的是PyTorch加官方模型库,代码简洁、社区资料多,适合做迁移学习。加载VGG16_bn的预训练权重时,建议用带BatchNorm的版本,收敛稳定性更好。下面这段代码做了两件事:加载预训练模型,把分类头最后一层从ImageNet的1000类输出改成任务需要的类别数。
import torch import torch.nn as nn import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 加载在ImageNet上预训练好的VGG16BN model = torchvision.models.vgg16_bn(weights=torchvision.models.VGG16_BN_Weights.IMAGENET1K_V1) num_classes = 4 # 最后一层分类器替换为适配任务类别的线性层 model.classifier[6] = nn.Linear(in_features=4096, out_features=num_classes) model = model.to(device)有几点必须注意。不要贸然直接修改整个分类器结构,因为预训练权重里分类器的前三层特征还是可用的,只替换最后一层是最保守做法。如果你的任务类别特别多,比如超过几十类,也可以把classifier整体换成隐藏层更小的结构,但需要从头训练分类器部分,这会增加训练时间。
4.2 冻结参数与解冻策略的正确写法
PyTorch做参数冻结核心就是设置requires_grad=False,但这里有个容易被忽略的点:冻结参数后,优化器在构造参数列表时也必须排除这些参数,否则问题不大但浪费内存。另一个要点是BatchNorm层在模型处于train模式时会继续更新running statistics,即使参数被冻结了,如果你不想让预训练层的BN统计量被新数据带偏,需要手动把它切换到eval模式。
下面是我用的冻结代码。先遍历features层的所有参数,全部设置为不可更新;解冻时只解冻最后几个卷积块,然后构造一个只包含需更新参数的优化器。
# 冻结所有特征提取层 for param in model.features.parameters(): param.requires_grad = False # 解冻最后几个卷积块(具体边界以打印的网络结构为准) for param in model.features[30:].parameters(): param.requires_grad = True # 如果想连BN的统计量也固定住,可以这样处理 for m in model.features.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() # 优化器只接收需要更新的参数 optimizer = torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, momentum=0.9, weight_decay=1e-4, )不同torchvision版本下features内部的层索引可能会有变化,不要硬记数字。我每次都会先执行for i, m in enumerate(model.features): print(i, type(m).__name__)把结构打印出来,再决定解冻的起止位置。这是个笨办法,但最可靠。
4.3 训练循环、优化器分组与学习率调度
训练循环本身不难,难的是把不同层的学习率分开控制。预训练层应该用较小学习率去微调,新分类头可以用较大学习率去快速拟合。PyTorch的optimizer支持给不同参数组设置不同learning rate,我经常按三个区间分组:头部、中高层、分类器。这里给出一个完整训练循环示例。
from torch.optim.lr_scheduler import StepLR # 分组学习率:预训练层1e-4,解冻高层5e-5,新分类头1e-3 optimizer = torch.optim.SGD( [ {"params": model.features[:30].parameters(), "lr": 1e-5}, {"params": model.features[30:].parameters(), "lr": 5e-5}, {"params": model.classifier.parameters(), "lr": 1e-3}, ], momentum=0.9, weight_decay=1e-4, ) scheduler = StepLR(optimizer, step_size=8, gamma=0.5) for epoch in range(20): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = nn.CrossEntropyLoss()(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 每个epoch结束后在验证集上评估,并保存指标这里建议使用SGD而不是Adam,尤其在做微调时。Adam的默认学习率对预训练参数过于激进,容易在早期把已经学好的特征破坏掉。即使使用SGD,也建议配合step decay,每若干epoch把学习率下降一半,让训练从粗糙逼近转向精细收敛。关于早停,我通常在验证集指标连续三轮不改善时保存当前最佳模型并停止训练,避免无效占用显存和训练时间。
5. 训练曲线怎么读:常见异常和调参方案
5.1 三种有代表性的训练曲线
迁移学习的训练曲线和从头训练差别很大。合理情况下,第一个epoch训练loss就会明显下降,因为分类头在随机初始化后很快学会组合预训练特征。你可能会看到验证集准确率从一开始就比较高,这是正常的迁移红利,不要误以为出了bug。
如果不正常,通常会出现三种代表性曲线。第一种是loss下降但验证集准确率始终在某个数值附近徘徊,这种多半是标注噪声或类别不平衡。第二种是训练loss持续下降,验证loss先降后升,这是典型的过拟合征兆。第三种是所有指标都在缓慢上升但速度特别慢,大概率是学习率过小,或者只冻结特征层而没有训练分类头。
我建议画一张把train loss、val loss、val acc放在同一张图里的曲线,训练过程中每20步打印一次当前epoch的中间值,长期盯着数值比只看最终结果更能发现问题。我自己的习惯是设置torch.set_grad_enabled(True)后每50个batch打印一次loss,并间隔保存checkpoint,防止训练中断后要从头再来。
5.2 典型异常的定位流程与对策表格
实践过程中遇到最多的是两种:验证集准确率卡在某个点,以及过拟合来得太快。前者我会先检查数据增强是否过度,比如颜色扰动太强导致训练和验证分布不一致;然后检查标签有没有系统性错误,比如某类图片肉眼根本分辨不出来;最后才是考虑解冻更多特征层。后者我会优先降低微调层的学习率,再增加dropout比例和数据增强强度。
| 现象 | 可能原因 | 优先调整项 |
|---|---|---|
| 训练loss降不动 | 学习率过低或分类头太浅 | 先增大新头学习率再看 |
| 验证准确率卡住 | 类别混淆严重或特征不足 | 解冻更多高层再训练 |
| 验证loss回弹 | 过拟合 | 降低学习率、加大增强 |
| 训练acc高但验证acc低 | 数据增强不足或标注噪声 | 重查标签,引入更强增强 |
| 两个acc都很低 | 预处理归一化错误 | 检查均值和标准差 |
排查有一个固定顺序:先确认代码没bug,再查数据划分泄露,然后调学习率,最后才动模型结构。跳过前面的顺序直接改模型结构,经常会把一个本来没坏的问题越调越复杂。
5.3 BN层是迁移学习里最容易被忽略的隐形因素
如果VGG版本带BatchNorm,迁移学习时有一个隐藏问题:BN层在训练模式会持续更新当前batch的均值方差统计量,即使你已经把BN层的weight和bias参数冻结,running statistics依然会变。对于冻结的低层特征,统计量漂移会改变预训练权重的缩放语义,结果就是前几轮看着还行,越往后越不稳定。
我处理VGG BN层的原则是:完全冻结的卷积区域,同时把BN层设为eval;参与微调的卷积区域,BN层维持train状态正常更新。这样固定区域的特征表示保持稳定,而解冻区域允许其适应新任务。如果你用的是没有BN版本的VGG16,就不用考虑这个问题,但没有BN的版本收敛稳定性略差,需要更小心地控制学习率。
6. 一些实操收尾经验
6.1 关于标签、随机种子和模型保存
代码层面的细节不多,但直接影响复现。第一,固定随机种子,否则每次训练结果都可能不同,你根本没法判断改动到底是有效还是噪声。第二,保存模型时不要只存state_dict,最好连优化器状态、当前epoch、验证集指标一起存成checkpoint,这样中断后可以精确恢复到之前的状态。第三,测试阶段要保证和验证阶段使用完全相同的transform,否则评测结果失真。
标签处理上,多分类任务直接用CrossEntropyLoss就行,不需要自己做one-hot。但如果你遇到类别不均衡,可以给损失函数传入weight参数,让少数类的梯度权重更大,这时要注意不要设置过大的权重,否则少数类容易过拟合。我通常先用默认权重跑一版,观察混淆矩阵后再决定是否加类别权重。
6.2 如果效果上不去,请按这个优先级去调整
最后分享一个我会一直沿用的调整顺序。第一,先怀疑数据和标注,再怀疑模型;第二,先调整学习率和优化器配置,再调整冻结范围;第三,先做单一变量对比,不要同时改多个参数;第四,如果已经用了超过一周时间还没有从模型层面看到实质性突破,建议换一个预训练结构,比如同样权重的ResNet或EfficientNet,不要跟VGG死磕。
我这次最后的方案是两层解冻加SGD微调,用上了50个epoch的完整训练周期,最终在验证集上稳定到了一个可接受的指标。整个过程说明了一个很朴素的道理:VGG迁移学习在现在这个时代依然是一件值得做的事,只要你有清晰的数据审查、合理的冻结策略、严格的训练监控,老模型同样能高效地解决新任务。要说遗憾,唯一后悔的是最初没有把数据体检做得更彻底,如果一开始就发现那些标签噪声,至少能省下好几轮无效训练的时间。