做图像分类的项目很多人第一站就是迁移学习。我自己最早接触这个词是在做一个某实验室的植物叶片分类需求时数据集只有几千张图手写网络怎么调都过不了80%的准确率。后来把预训练好的VGG16搬过来只训练新加的分类层半天时间就把验证集准确率拉到了93%左右。这篇内容就是围绕“使用迁移学习来训练VGG模型”这个主题把我实际跑通的一套流程、参数和踩坑记录完整梳理一遍。无论你是刚开始学深度学习还是手头数据量不大但想快速出结果都可以直接照着操作。1. 迁移学习的基本思路与选型理由1.1 迁移学习到底在干什么先理解一个核心概念迁移学习就是把一个已经在大规模数据集上训练好的模型拿过来当作新任务的起点。VGG系列在ImageNet上训练过ImageNet有一千个类别、上百万张图片模型在这么大规模的数据上见过各种边缘、纹理、形状、物体局部结构。这些底层特征其实是通用的比如一只猫的耳朵和一条狗的耳朵底层特征都是“弯曲的边缘纹理组合”只是高层语义不一样。可以打个比方一个做了十年川菜的厨师换到粤菜馆他不需要重新学“怎么切菜、怎么控火候”只需要学“新菜谱的调味比例和食材搭配”。迁移学习也是这个逻辑VGG在ImageNet上已经学会了“怎么看图”我们只需要教它“怎么区分你手里的新类别”。实际操作中迁移学习一般有两种用法。第一种叫特征提取把底层卷积层全部冻结只训练最后新加的分类层适合数据量少、和ImageNet场景比较接近的任务。第二种叫微调先把分类层训练好再解冻一部分底层参数继续训练让模型逐步适应新数据集的分布差异适合数据量中等、图像风格差异比较大的任务。后面我会把两种方式都讲一遍。1.2 为什么选VGG而不是ResNet、MobileNetVGG的核心结构很简单十六到十九层全部是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(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.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参数而不是老式的pretrainedTrue代码很直观import torch import torch.nn as nn from torchvision import models model models.vgg16(weightsmodels.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_gradFalse的作用是让反传时不再为这些层计算梯度。好处有两个一是这些预训练参数不会被更新保留ImageNet上学到的通用特征二是节省了大量梯度和优化器状态的内存因为不需要保存这些层的中间导数。如果你只训练最后一层参数量从1.38亿降到几百万训练速度会快非常多。这里有个常见的疑问冻结了卷积层输入图片还能通过它们得到特征吗可以的。requires_gradFalse只是不更新参数前向传播照常执行。你依然能得到512个通道的feature map然后展平输入到全连接层。相当于你把VGG的卷积部分当成一个固定的特征提取器就像用SIFT或者直方图提取特征一样只是这个提取器是深度学习的表达能力更强。4. 训练配置与完整训练流程4.1 损失函数与优化器选择分类任务默认用CrossEntropyLossPyTorch里这个损失函数自带Softmax所以模型最后一层不需要额外加Softmax直接输出logits即可。优化器我用过Adam和SGD两种结论比较明确训练新加的分类层时Adam收敛快、对学习率不那么敏感适合快速让分类头工作起来等到做微调解冻卷积层时换成带动量的SGD效果更稳尤其在数据集比较小的情况下SGD不容易陷入过拟合的局部最优。参数设置参考如下optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay1e-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(fEpoch {epoch1}/{num_epochs}: ftrain_loss{train_loss:.4f}, train_acc{train_acc:.4f}, fval_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_gradFalse 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} ], momentum0.9, weight_decay1e-4)新分类层是从随机初始化开始训练的需要较大的学习率预训练卷积层只需要轻微调整用小学习率。这是微调阶段最关键的参数设计很多人直接全部参数一个学习率结果不是收敛慢就是特征被破坏。5.3 学习率调度微调阶段我会加一个学习率调度器最常用的是ReduceLROnPlateau当验证损失连续几个epoch不下降时自动把学习率降为原来的1/10scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.1, patience3 ) # 每个epoch验证结束后调用 scheduler.step(val_loss)patience3的意思是验证损失连续3个epoch没有改善才降学习率。配合提前停止机制即验证损失连续10个epoch不改善就停止训练基本可以防止过拟合。这两个机制配合微调阶段通常20到30个epoch就能收敛不需要傻傻地跑满100个epoch。6. 常见问题与排查实录我把实际跑的过程中遇到的高频问题整理成了一张速查表方便对照排查问题表现常见原因处理方案显存不足OOMbatch size过大 / 输入尺寸太大batch size减半或降低输入分辨率开启混合精度训练训练损失不下降学习率过高或过低检查损失是否剧烈震荡把学习率调到1e-3到1e-4区间再试训练损失下降但验证准确率低过拟合增强数据增强、调大Dropout、冻结更多卷积层、引入提前停止输出为nan学习率过高 / 数据有异常值降低学习率检查标签是否有负数或越界加梯度裁剪验证集结果随机波动大Dropout未关闭 / batch size太小确保验证阶段model.eval()增大batch size预测时输入尺寸报错输入图片没有缩放到224推理前必须走相同预处理流程包括Resize和Normalize下面挑三个最有代表性的展开讲。第一个是显存不足。VGG的参数量和中间feature map都很大尤其是前向传播时每一层的输出都存在显存里用于反向传播。我试过用6GB显卡跑VGG16batch size 32直接OOM。最快的解决方案是把DataLoader的batch size从32降到16或8同时开启混合精度。PyTorch 2.0里可以用torch.autocast(device_typecuda, dtypetorch.float16)包住前向和损失计算显存占用能降低30%到40%速度还更快。如果还不行就把输入尺寸从224降到160注意同时调整预处理阶段的两处代码。第二个是过拟合。这是用小数据集做迁移学习最常见的问题。我记得有次每类只有150张图第一轮跑完验证准确率就比训练准确率低12个百分点很明显过拟合了。处理办法是组合拳把训练增强从随机裁剪、随机水平翻转扩展成加旋转、加颜色抖动把分类器里的Dropout概率从0.5调到0.7再把解冻的卷积层全部冻结回去只保留分类头训练。这样处理之后过拟合差距缩小到了3个百分点以内。第三个是收敛非常慢。有次微调VGG19跑了10个epoch损失纹丝不动查了一圈发现是学习率设成了1e-6对分类头来说太低了。另一个常见情况是忘了在DataLoader里设置shuffleTrue模型每个epoch看到的训练样本顺序完全一样收敛慢而且容易陷入局部最优。排查收敛问题时先检查这两点再检查归一化参数是否正确。7. 实操心得与扩展方向最后聊几点我自己的使用体会。第一次做迁移学习建议严格按照“先冻结全部卷积层、只训练分类头”这个流程走通。这个阶段验证集准确率通常就能到85%以上足够应付很多简单任务。数据量够多、分布差异大再进入微调阶段。千万不要一上来就全部解冻那样不仅训练慢而且大概率过拟合。训练时间和效果的关系我的经验是特征提取阶段跑20个epoch微调阶段再跑20个epoch总共的时间开销比从头训练小了一个数量级。模型跑通之后有几个扩展方向很值得玩味。可以用Grad-CAM可视化模型关注图片的哪些区域验证一下模型到底是学了类别特征还是学了背景干扰。也可以把VGG的features部分替换成ResNet或EfficientNet的特征提取器分类头完全不用改就能对比不同骨干网络的迁移效果。如果模型要部署到移动端还可以对训练好的模型做量化压缩VGG的参数量大量化后模型体积能缩小3到4倍速度也能快不少。迁移学习这件事把VGG这一条线吃透后面换任何Backbone都是差不多的套路。