做图像分类的项目,很多人第一站就是迁移学习。我自己最早接触这个词,是在做一个某实验室的植物叶片分类需求时,数据集只有几千张图,手写网络怎么调都过不了80%的准确率。后来把预训练好的VGG16搬过来,只训练新加的分类层,半天时间就把验证集准确率拉到了93%左右。这篇内容就是围绕“使用迁移学习来训练VGG模型”这个主题,把我实际跑通的一套流程、参数和踩坑记录完整梳理一遍。无论你是刚开始学深度学习,还是手头数据量不大但想快速出结果,都可以直接照着操作。
1. 迁移学习的基本思路与选型理由
1.1 迁移学习到底在干什么
先理解一个核心概念:迁移学习就是把一个已经在大规模数据集上训练好的模型,拿过来当作新任务的起点。VGG系列在ImageNet上训练过,ImageNet有一千个类别、上百万张图片,模型在这么大规模的数据上见过各种边缘、纹理、形状、物体局部结构。这些底层特征其实是通用的,比如一只猫的耳朵和一条狗的耳朵,底层特征都是“弯曲的边缘+纹理组合”,只是高层语义不一样。
可以打个比方:一个做了十年川菜的厨师,换到粤菜馆,他不需要重新学“怎么切菜、怎么控火候”,只需要学“新菜谱的调味比例和食材搭配”。迁移学习也是这个逻辑,VGG在ImageNet上已经学会了“怎么看图”,我们只需要教它“怎么区分你手里的新类别”。
实际操作中,迁移学习一般有两种用法。第一种叫特征提取,把底层卷积层全部冻结,只训练最后新加的分类层,适合数据量少、和ImageNet场景比较接近的任务。第二种叫微调,先把分类层训练好,再解冻一部分底层参数继续训练,让模型逐步适应新数据集的分布差异,适合数据量中等、图像风格差异比较大的任务。后面我会把两种方式都讲一遍。
1.2 为什么选VGG而不是ResNet、MobileNet
VGG的核心结构很简单:十六到十九层,全部是3x3卷积和2x2最大池化的堆叠,最后接三层全连接。它没有ResNet那种残差连接,也没有Inception那种多分支,结构最“朴素”,适合理解卷积网络的基本套路。
先看VGG16和VGG19的区别。VGG16有13个卷积层加3个全连接层,VGG19有16个卷积层加3个全连接层。多出来的3层卷积加在最后几个block里,参数量从大约1.38亿增加到1.44亿。实际测试中,VGG19比VGG16的准确率提升非常有限,大概只有0.1到0.3个百分点,但训练时间和显存占用却明显增加。所以我个人默认选VGG16,除非你的任务和ImageNet分布非常接近、对精度要求极高,才考虑VGG19。
和ResNet、MobileNet对比的话,VGG最大的优势是结构整齐、容易解释、预训练权重获取方便。ResNet因为有残差连接,收敛更快、准确率也更高,但概念上对新手稍微抽象一点。MobileNet的优势是参数量小、速度快,适合部署在手机端。依赖的场景很直接:如果你要快速跑通一个分类项目、看懂每一步在做什么,VGG是最合适的教学和基线模型;如果在意推理速度和模型体积,再考虑MobileNet;如果你已经理解了迁移学习的原理,直接上ResNet也完全没问题。
还有一个技术上很关键的点:VGG全连接层的参数量占比非常高,三个全连接层占了总参数量的绝大部分,尤其是第一个全连接层,输入是7x7x512,展平后是25088个特征。这意味着如果你只训练全连接层,训练速度会偏慢,显存占用也比只训练卷积层的方案高。这个特性在后面做微调策略时会有影响,我会在进阶部分展开。
2. 环境准备与数据集处理
2.1 环境与依赖
我用的是PyTorch生态,整体配置如下:
- Python 3.9以上
- PyTorch 2.0以上
- torchvision 0.15以上
- CUDA 11.8以上(有NVIDIA显卡)
torchvision是重点,因为torchvision.models里直接内置了VGG的预训练权重,不用自己去找模型文件。我遇到过不少同学卡在这里,手动下载权重结果路径配错,用torchvision自带的方式就不会有这种问题。
显存方面,VGG16在224x224输入、batch size 32的情况下,FP32训练大概需要8GB左右的显存。如果你的显卡只有6GB,把batch size降到16,或者把输入分辨率缩小到160x160,也能跑起来,只是准确率会有轻微下降。没有独立显卡的话,用在线GPU环境也可以,但要注意VGG参数量大,免费额度跑完整训练周期有点紧张,建议先用小数据集验证流程,再上全量数据。
2.2 数据集组织与预处理
先讲数据集怎么摆。PyTorch的ImageFolder要求按类别分文件夹,目录结构长这样:
data/ ├── train/ │ ├── class_a/ │ │ ├── 001.jpg │ │ ├── 002.jpg │ │ └── ... │ ├── class_b/ │ │ └── ... │ └── ... ├── val/ │ ├── class_a/ │ │ └── ... │ └── ... └── test/ └── ...每个类别下放对应的图片,ImageFolder会根据子文件夹名称自动生成类别标签。注意train和val里每个类别的图片数量不要悬殊太大,否则类别不均衡会影响最终准确率。
预处理有两个关键点。第一,所有图片必须缩放到224x224,因为VGG的全连接层输入维度是固定的,输入尺寸变了,features部分输出的feature map尺寸就不对,全连接层直接报错。第二,归一化必须用ImageNet的均值和标准差,即mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]。预训练模型在归一化后的数据上学习,如果换一套均值和标准差,输入分布就变了,迁移效果会明显下降。我见过有人偷懒直接用Normalize(0.5, 0.5),准确率掉了好几个点。
数据增强我一般这样配置:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])训练集用随机裁剪、随机水平翻转、颜色抖动来增加多样性,验证集和测试集只做缩放和中心裁剪,保证评估时结果稳定。需要注意RandomResizedCrop的scale参数,0.8到1.0的意思是裁剪区域占原图的80%到100%,比例范围别设太小,否则物体主体很容易被裁掉。
3. 加载预训练模型与冻结策略
3.1 加载VGG预训练权重
torchvision从某个版本开始推荐用weights参数,而不是老式的pretrained=True,代码很直观:
import torch import torch.nn as nn from torchvision import models model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1)第一次运行会自动下载权重文件,大小大概500多MB,存放在PyTorch的缓存目录里。如果网络下载慢,可以手动下载后放到指定路径,这里不多展开。还有一个细节,VGG16_Weights.IMAGENET1K_V1是文档推荐的枚举写法,比传字符串更规范,IDE里还有自动补全,不容易写错。
3.2 替换分类头
VGG16的默认分类器是三层全连接,输出1000类。现在要换成我们自己的类别数,直接替换最后一层就行:
num_classes = 5 # 你的类别数量 model.classifier[6] = nn.Linear(4096, num_classes)这里classifier是一个nn.Sequential容器,下标6对应最后一个全连接层。VGG的classifier结构是:Linear(25088, 4096) -> ReLU -> Dropout(0.5) -> Linear(4096, 4096) -> ReLU -> Dropout(0.5) -> Linear(4096, 1000)。下标0和3对应前两个全连接层,下标6对应输出层。只换最后一层,是因为前两层已经从ImageNet的千类分类中学会了高层次的组合特征,保留它们反而能给新分类层提供更好的输入。
3.3 冻结特征层参数
替换完分类头后,需要把卷积层参数冻结掉:
# 冻结所有卷积层参数 for param in model.features.parameters(): param.requires_grad = False # 也可以冻结分类器前两层,只训练最后一层 for param in model.classifier[:6].parameters(): param.requires_grad = Falserequires_grad=False的作用是让反传时不再为这些层计算梯度。好处有两个:一是这些预训练参数不会被更新,保留ImageNet上学到的通用特征;二是节省了大量梯度和优化器状态的内存,因为不需要保存这些层的中间导数。如果你只训练最后一层,参数量从1.38亿降到几百万,训练速度会快非常多。
这里有个常见的疑问:冻结了卷积层,输入图片还能通过它们得到特征吗?可以的。requires_grad=False只是不更新参数,前向传播照常执行。你依然能得到512个通道的feature map,然后展平输入到全连接层。相当于你把VGG的卷积部分当成一个固定的特征提取器,就像用SIFT或者直方图提取特征一样,只是这个提取器是深度学习的,表达能力更强。
4. 训练配置与完整训练流程
4.1 损失函数与优化器选择
分类任务默认用CrossEntropyLoss,PyTorch里这个损失函数自带Softmax,所以模型最后一层不需要额外加Softmax,直接输出logits即可。
优化器我用过Adam和SGD两种,结论比较明确:训练新加的分类层时,Adam收敛快、对学习率不那么敏感,适合快速让分类头工作起来;等到做微调解冻卷积层时,换成带动量的SGD效果更稳,尤其在数据集比较小的情况下,SGD不容易陷入过拟合的局部最优。参数设置参考如下:
optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4 )注意用了filter(lambda p: p.requires_grad, ...)来过滤掉被冻结的参数,避免优化器为不需要更新的参数维护状态。学习率从1e-3开始,对一个新的分类层来说是安全的起点。如果数据集特别小,比如每类只有一二百张,可以降到3e-4。
4.2 完整训练循环
训练循环我习惯写成一个函数,包含训练和验证两个阶段:
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += torch.sum(preds == labels).item() total += labels.size(0) return running_loss / total, correct / total def evaluate(model, dataloader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += torch.sum(preds == labels).item() total += labels.size(0) return running_loss / total, correct / total两个函数的关键区别:训练阶段要调用optimizer.zero_grad()、loss.backward()、optimizer.step();验证阶段包在torch.no_grad()里,不计算梯度,只做前向推理。model.train()和model.eval()也需要切换,因为VGG里有Dropout层,训练时随机丢弃一部分神经元,验证时要关闭Dropout并保持所有神经元输出,否则验证结果会有随机波动。
训练主循环里我会保存验证集准确率最高的模型权重:
best_acc = 0.0 num_epochs = 20 for epoch in range(num_epochs): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) print(f"Epoch {epoch+1}/{num_epochs}: " f"train_loss={train_loss:.4f}, train_acc={train_acc:.4f}, " f"val_loss={val_loss:.4f}, val_acc={val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth")只保存验证集表现最好的权重作为最终模型,而不是最后一轮的权重。因为最后一轮可能已经过拟合,验证集准确率反而下降了。保存的state_dict可以在推理时直接重新加载。
4.3 训练过程中的观察点
训练阶段我主要盯三个指标:训练损失、训练准确率、验证准确率。
如果训练损失持续下降、验证准确率同步上升,说明一切正常。如果训练损失下降但验证准确率停滞不前,大概率过拟合了,可以增加数据增强强度、加大Dropout、或者提前停止训练。如果训练损失和验证准确率都在震荡,大概率是学习率太高,把学习率除以10再试。
我推荐用tqdm给每个epoch加进度条,尤其在数据量大的时候,不然盯着控制台容易失去耐心。另外,建议每个epoch打印一次验证损失,对比训练损失的差距,这个差距是判断过拟合程度最直接的信号。
5. 进阶:分层解冻与微调策略
5.1 为什么需要微调
冻结卷积层做特征提取,适合新数据集和ImageNet图像分布比较接近的情况。但如果你的任务是医学图像、红外图像、或者某种特定风格的工业产品图,分布差异就很大了,底层的通用特征可能不够用。这时候需要微调,也就是解冻一部分卷积层,让模型在新数据上继续学习一部分参数,适应自己的分布。
深度学习的基础认知是:浅层卷积核学到的是边缘、颜色、角点这些通用低级特征,深层卷积核学到的是与任务相关的语义结构。所以微调的基本策略是:浅层保持冻结,深层逐步解冻。解冻越深的层,模型对新任务的适应能力越强,但过拟合风险也越高。根据数据集大小,有一个粗略的参考:
| 数据集规模 | 建议策略 |
|---|---|
| 每类少于100张 | 只训练分类头,冻结全部卷积层 |
| 每类100到1000张 | 冻结前2/3卷积层,解冻后1/3卷积层微调 |
| 每类超过1000张 | 解冻较多层甚至全部微调 |
5.2 解冻实操
VGG16的features部分有5个block,分别包含2、2、3、3、3个卷积层。解冻最后一两个block是常见的做法:
# 先给所有特征层设置 requires_grad=False for param in model.features.parameters(): param.requires_grad = False # 解冻最后一个block(第5个block) for param in model.features[24:].parameters(): param.requires_grad = Truefeatures是一个nn.Sequential,下标24是第5个block里第一个卷积层的起始位置。如果你不确定下标,可以用for i, layer in enumerate(model.features): print(i, layer)来查看每一层的索引。解冻之后,需要为可训练参数单独设一个更低的学习率,因为预训练参数已经比较成熟,学习率太大会破坏原有特征。一般设为主学习率的1/10到1/100。
PyTorch里可以给优化器传参数组,不同分组用不同学习率:
params_1x = [] params_10x = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if "features" in name: params_1x.append(param) # 解冻的卷积层,用低学习率 else: params_10x.append(param) # 新分类层,用高学习率 optimizer = torch.optim.SGD([ {"params": params_1x, "lr": 1e-5}, {"params": params_10x, "lr": 1e-3} ], momentum=0.9, weight_decay=1e-4)新分类层是从随机初始化开始训练的,需要较大的学习率;预训练卷积层只需要轻微调整,用小学习率。这是微调阶段最关键的参数设计,很多人直接全部参数一个学习率,结果不是收敛慢就是特征被破坏。
5.3 学习率调度
微调阶段我会加一个学习率调度器,最常用的是ReduceLROnPlateau,当验证损失连续几个epoch不下降时,自动把学习率降为原来的1/10:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.1, patience=3 ) # 每个epoch验证结束后调用 scheduler.step(val_loss)patience=3的意思是验证损失连续3个epoch没有改善才降学习率。配合提前停止机制,即验证损失连续10个epoch不改善就停止训练,基本可以防止过拟合。这两个机制配合,微调阶段通常20到30个epoch就能收敛,不需要傻傻地跑满100个epoch。
6. 常见问题与排查实录
我把实际跑的过程中遇到的高频问题整理成了一张速查表,方便对照排查:
| 问题表现 | 常见原因 | 处理方案 |
|---|---|---|
| 显存不足(OOM) | batch size过大 / 输入尺寸太大 | batch size减半,或降低输入分辨率;开启混合精度训练 |
| 训练损失不下降 | 学习率过高或过低 | 检查损失是否剧烈震荡,把学习率调到1e-3到1e-4区间再试 |
| 训练损失下降但验证准确率低 | 过拟合 | 增强数据增强、调大Dropout、冻结更多卷积层、引入提前停止 |
| 输出为nan | 学习率过高 / 数据有异常值 | 降低学习率,检查标签是否有负数或越界,加梯度裁剪 |
| 验证集结果随机波动大 | Dropout未关闭 / batch size太小 | 确保验证阶段model.eval(),增大batch size |
| 预测时输入尺寸报错 | 输入图片没有缩放到224 | 推理前必须走相同预处理流程,包括Resize和Normalize |
下面挑三个最有代表性的展开讲。
第一个是显存不足。VGG的参数量和中间feature map都很大,尤其是前向传播时每一层的输出都存在显存里用于反向传播。我试过用6GB显卡跑VGG16,batch size 32直接OOM。最快的解决方案是把DataLoader的batch size从32降到16或8,同时开启混合精度。PyTorch 2.0里可以用torch.autocast(device_type='cuda', dtype=torch.float16)包住前向和损失计算,显存占用能降低30%到40%,速度还更快。如果还不行,就把输入尺寸从224降到160,注意同时调整预处理阶段的两处代码。
第二个是过拟合。这是用小数据集做迁移学习最常见的问题。我记得有次每类只有150张图,第一轮跑完验证准确率就比训练准确率低12个百分点,很明显过拟合了。处理办法是组合拳:把训练增强从随机裁剪、随机水平翻转扩展成加旋转、加颜色抖动;把分类器里的Dropout概率从0.5调到0.7;再把解冻的卷积层全部冻结回去,只保留分类头训练。这样处理之后,过拟合差距缩小到了3个百分点以内。
第三个是收敛非常慢。有次微调VGG19,跑了10个epoch损失纹丝不动,查了一圈发现是学习率设成了1e-6,对分类头来说太低了。另一个常见情况是忘了在DataLoader里设置shuffle=True,模型每个epoch看到的训练样本顺序完全一样,收敛慢而且容易陷入局部最优。排查收敛问题时,先检查这两点,再检查归一化参数是否正确。
7. 实操心得与扩展方向
最后聊几点我自己的使用体会。第一次做迁移学习,建议严格按照“先冻结全部卷积层、只训练分类头”这个流程走通。这个阶段验证集准确率通常就能到85%以上,足够应付很多简单任务。数据量够多、分布差异大,再进入微调阶段。千万不要一上来就全部解冻,那样不仅训练慢,而且大概率过拟合。训练时间和效果的关系,我的经验是:特征提取阶段跑20个epoch,微调阶段再跑20个epoch,总共的时间开销比从头训练小了一个数量级。
模型跑通之后,有几个扩展方向很值得玩味。可以用Grad-CAM可视化模型关注图片的哪些区域,验证一下模型到底是学了类别特征还是学了背景干扰。也可以把VGG的features部分替换成ResNet或EfficientNet的特征提取器,分类头完全不用改,就能对比不同骨干网络的迁移效果。如果模型要部署到移动端,还可以对训练好的模型做量化压缩,VGG的参数量大,量化后模型体积能缩小3到4倍,速度也能快不少。迁移学习这件事,把VGG这一条线吃透,后面换任何Backbone都是差不多的套路。