图像分类是多数人接触深度学习时跨进的第一道门,也是我把基本功打得最扎实的方向。这篇文章整理自一次完整的模型对比实践,我把 PyTorch 下的 AlexNet、VGGNet、Inception、ResNet、DenseNet、MobileNet 六类经典网络放在同一套数据和训练流程里全部过了一遍,从自己手写网络结构到调整训练策略,最后沉淀出一套比较稳定的做法。文章没有停在“会用接口”的层面,而是把每个结构的来龙去脉、选型逻辑、实现细节和实际踩坑都讲清楚。不论你是刚学会张量操作的新手,还是已经能跑通几个模型、想在真实数据集上做出结果的工程师,都能从里面找到可以直接落地的部分。
1. 项目思路:为什么用 PyTorch 把六类经典网络全部过一遍
很多初学者会有一个误区:图像分类就是调库,torchvision里每个模型都封装好了,直接resnet50()一把梭就行。这话对一半。真正做项目时,模型选型从来不是“看谁最新用谁”,也不是“看谁精度高就无脑上最重的”,而是要理解每个网络的结构到底在解决什么问题,然后根据你的数据规模、算力成本、部署环境做取舍。我把这六类网络放在一起对比,就是因为它们分别代表了 CNN 发展史上的几个关键转折:从暴力堆叠到小卷积核堆叠,从单路径到多分支,从绕路连接到直达连接,最后再到高效计算。
先说为什么选 PyTorch。生态是一方面,更重要的是 PyTorch 的代码风格非常接近“手动推公式”的体验,nn.Module的灵活度足够高,方便我逐个模块去替换、打补丁、打印中间特征。相比固定模板式的框架,PyTorch 更适合做这种横向对比实验。尤其是在自定义一些像 Inception 分支、残差块、深度可分离卷积这类结构时,你能清楚看到每一步张量的形状变化,这对理解模型非常有帮助。
把六类网络放在同一套数据和流程里过一遍,还有一个额外好处:能摸清哪些结构差异真正影响了结果。我之前在模拟项目 X 里看到很多人用不同模型时,数据增强、优化器、训练周期全都不一样,最后比较出来的结论其实毫无意义。这次我统一固定了输入尺寸、数据增强策略、优化器、学习率调度、batch 大小和训练轮数,只让网络结构作为变量,这样得到的对比数据才具备参考价值。
1.1 六类网络的选型核心差异
在动手之前,我先给自己做了一张选型表,把每个网络的立身之本列出来:
| 网络 | 核心思想 | 参数量级 | 适合场景 |
|---|---|---|---|
| AlexNet | 大卷积核 + 相对深的结构,奠定了 CNN 基本范式 | 约 60M | 教学、入门 baseline |
| VGGNet | 用 3x3 小卷积核堆叠出足够深的网络 | 约 138M | 迁移学习、结构规整的特征骨干 |
| Inception | 多分支并行提取多尺度特征,1x1 卷积降维控制计算量 | 约 24M(v3) | 精度要求高、资源适中的场景 |
| ResNet | 残差连接解决深度网络的退化问题 | ResNet18 约 11.7M,ResNet50 约 25.6M | 通用首选、大规模分类 |
| DenseNet | 特征重用,层与层之间密集连接 | DenseNet121 约 8M | 参数预算紧、需要做特征融合的项目 |
| MobileNet | 深度可分离卷积,把参数和计算量压到很低 | MobileNetV2 约 3.4M | 移动端、边缘设备、端侧推理 |
这里要特别注意一点:参数量低不等于实际推理快。MobileNet 的核心是深度可分离卷积,但深度卷积在部分底层加速库上不一定能跑出理论峰值,有时候反而不如一个优化得很好的 3x3 卷积快。所以选型时一定要看你的目标平台,不能只盯着参数字典。
1.2 不是越新的网络就越好
现在每年新模型一大把,但经典模型依然有存在的必要。AlexNet 到今天已经远谈不上最优,可它的设计几乎包含了现代 CNN 的所有基本元素:卷积、池化、激活、Dropout、全连接分类。VGG 虽然重,可是它的结构极度规整,做迁移学习时在多个数据集上表现很稳。Inception 启发了大量关于多尺度特征的研究,你会在很多检测和分割模型里看到它的影子。ResNet 更不用说,残差连接几乎成了现代深度学习的基础设施,连 Transformer 里都在用类似思想。DenseNet 和 MobileNet 则分别把“特征复用”和“轻量化设计”做到了极致。
所以我的建议是:经典不是过时,而是沉淀。把它们吃透,你才能在新网络出现时快速看懂它到底新在哪。这也是我为什么坚持要自己实现一遍,而不是只调封装接口。
2. 数据准备与训练环境:先跑通一个公平的对比底座
模型对比这件事,最大的坑不是模型本身,而是数据处理不一致。这次我用的是自己整理的一套食材分类数据集,28 个类别,约 9 万张图片。每张图原始分辨率在 300 到 800 像素之间,训练集、验证集、测试集按 8:1:1 划分。类别数量没有做严格的完全均衡,但整体分布比较均匀,足够观察模型在真实分布下的表现。
数据质量检查是开工前最重要的一步。我会先抽一批图出来人工看一遍,重点确认三件事:图片有没有翻转方向错误的、有没有加载出来是损坏的、标签和文件名对不对。这个步骤看起来简单,但能避免后面训练到一半才发现数据乱了,白跑好几个小时。
2.1 数据增强与预处理细节
图像分类的预处理基本围绕尺寸归一化、数值归一化和数据增强来展开。我在训练阶段使用了随机裁剪、随机水平翻转和颜色抖动,验证和测试阶段只做 resize 和中心裁剪,保证指标是可比的。具体改动用transforms.Compose组合起来:
from torchvision import transforms transform_train = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.65, 1.0)), transforms.RandomHorizontalFlip(), 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]), ]) transform_eval = 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 范围不要直接复制别人代码,最好根据你的数据情况调。如果目标物体在图片里通常比较大,scale 可以设到 0.7 以上;如果物体经常很小,那就要把下界放宽,让模型有机会看到小目标。Normalize 用到的均值和方差是常见预训练统计值,如果你训练的数据分布和自然图像差异特别大,可以自己在训练集上算一份新的均值和方差,效果会更好。
2.2 DataLoader 与数据读取效率
数据读取这块,新手最容易忽略的是num_workers和pin_memory。我把常用配置贴在下面:
from torch.utils.data import DataLoader train_loader = DataLoader( train_set, batch_size=64, shuffle=True, num_workers=8, pin_memory=True, drop_last=True, ) valid_loader = DataLoader( valid_set, batch_size=64, shuffle=False, num_workers=8, pin_memory=True, )num_workers不是越大越好。我有一次在只有 16G 内存的机器上把 worker 开到 16,结果数据加载进程吃光了内存,训练直接卡死。常规做法是先看 CPU 核心数,再看内存余量,一般设成 CPU 核心数的一半到三分之二比较稳。pin_memory=True能让 GPU 拷贝数据更快,前提是你的机器确实在用 GPU 训练,纯 CPU 环境开了也没意义。drop_last=True只在训练时用,可以避免最后一个 batch 太小导致 BatchNorm 统计不稳定。
2.3 先跑通一个最小闭环再放大
刚开始做这个对比时,我没有直接上 9 万张全量数据,而是先取了其中 2% 的图片,把整个训练、验证、测试流程跑通。这一步非常关键,能快速暴露代码里的低级错误,比如张量维度不匹配、模型输入尺寸写错、标签越界等等。小数据跑通后再加全量,你会省下大量试错时间。
3. AlexNet、VGGNet、Inception 的原理与实现拆解
这三类网络代表了一条很清晰的演进路线:从“用大卷积核直接看大范围”,到“用小卷积核层层叠加感受野”,再到“用多个分支并行提取不同尺度特征”。我实现的时候没有完全照搬原始论文里的每一个参数,而是做了一点面向工程的调整,比如用 AdaptiveAvgPool 兜底全连接层的输入尺寸,这样代码在不同输入分辨率下不会一上来就炸。
3.1 AlexNet:暴力堆叠也能赢,但更值得记住的是三个惯例
AlexNet 的历史地位不用多说,它让业界重新认识到深度 CNN 的能力。它的结构在今天看来很朴素:前面几层用大卷积核快速缩小特征图,中间用 ReLU 激活加速收敛,最后接两层 4096 维的全连接层,中间加 Dropout。我实现时参考了常见的简化版本:
import torch.nn as nn class AlexNet(nn.Module): def __init__(self, num_classes=1000): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(64, 192, kernel_size=5, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(192, 384, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)如果你自己实现时输入尺寸不是标准的 224,最后的256 * 6 * 6很可能对不上。最稳妥的办法是在self.features后面加一个nn.AdaptiveAvgPool2d((6, 6)),然后固定全连接层输入为256 * 6 * 6,这样网络对输入尺寸的宽容度会高很多。我在项目里就是这么改的。
AlexNet 的参数量有 60M 左右,大头全在最后三个全连接层。这说明一个问题:早期 CNN 的设计思路还比较接近传统的多层感知机,特征提取部分反而相对单薄。实践上,AlexNet 现在主要用来跑通流程、练手,它本身已经很难在大数据集上取得特别高的精度。
3.2 VGGNet:用 3x3 卷积堆出来的深度
VGGNet 的思想一句话就能说明白:与其用一个 5x5 或 7x7 的大卷积核,不如堆叠多个 3x3 卷积。两个 3x3 卷积的感受野等于一个 5x5 卷积,三个 3x3 卷积等于一个 7x7 卷积,但参数量和非线性都更占优。这个“以小代大”的思路,后来几乎成了 CNN 设计的基本共识。
VGG 的结构极度规整,就是若干个“连续 N 个 3x3 卷积 + 一个最大池化”的模块。我封装了一个构建函数:
def vgg_block(in_channels, out_channels, n_convs): layers = [] for _ in range(n_convs): layers += [ nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1), nn.ReLU(inplace=True), ] in_channels = out_channels layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) return nn.Sequential(*layers)使用的时候,可以把vgg_block(3, 64, 2)、vgg_block(64, 128, 2)、vgg_block(128, 256, 3)这样一层层串起来,后面再接全连接分类器。
VGGNet 的缺点是重,VGG16 的参数量超过 138M,训练和推理都比较吃资源。但它的优点是结构规整,做迁移学习时非常好用。我在另一个项目里用它做过预训练骨干,下游任务只要把最后一层换掉就能很快收敛。如果你想复现一个比较轻的 VGG,也可以把每个 block 里的卷积数减少,做成 VGG11 或自定义变体。
实际用 VGG 时还要注意一个点:它特别依赖数据增强和 Dropout。因为参数量大,在小数据集上很容易过拟合。我通常会在全连接层至少加一层 Dropout,并且把 weight decay 设大一些。
3.3 Inception:多分支多尺度,让网络自己选“该看什么”
Inception 的思路和 VGG 完全不同。它不去追求“用更小卷积核堆得更深”,而是把 1x1、3x3、5x5 的卷积和 3x3 的最大池化放在同一个模块里并行计算,最后拼接输出。这样一来,同一层的特征里既有小范围的细节,也有大范围的上下文,网络可以自己学着组合不同尺度的信息。同时,Inception 大量使用 1x1 卷积先降维,再去做 3x3 或 5x5 卷积,大大控制了计算量。
一个简化版 Inception 块可以这样实现:
class InceptionBlock(nn.Module): def __init__(self, in_c, c1, c2a, c2b, c3a, c3b, c4): super().__init__() self.branch1 = nn.Conv2d(in_c, c1, kernel_size=1) self.branch2 = nn.Sequential( nn.Conv2d(in_c, c2a, kernel_size=1), nn.ReLU(inplace=True), nn.Conv2d(c2a, c2b, kernel_size=3, padding=1), ) self.branch3 = nn.Sequential( nn.Conv2d(in_c, c3a, kernel_size=1), nn.ReLU(inplace=True), nn.Conv2d(c3a, c3b, kernel_size=5, padding=2), ) self.branch4 = nn.Sequential( nn.MaxPool2d(kernel_size=3, stride=1, padding=1), nn.Conv2d(in_c, c4, kernel_size=1), ) def forward(self, x): out1 = self.branch1(x) out2 = self.branch2(x) out3 = self.branch3(x) out4 = self.branch4(x) return torch.cat([out1, out2, out3, out4], dim=1)注意几个细节。第一,分支里先做 1x1 卷积之后通常要接一个 ReLU,再接后面的卷积,这是 Inception 论文中的标准配置。第二,分支 4 的池化必须设置 padding=1,保证输出尺寸和其他分支一致,不然到了拼接维度就报错。第三,每个分支输出的通道数不同也可以拼接,只要空间尺寸相同就行。
Inception 的问题是实现自由度太高,一不小心就会写得很乱。如果你只是想做对比实验,建议直接用框架里封装好的 InceptionV3 或者 InceptionV4,不要自己从零搭完整网络,因为真正要调的辅助分类器、标签平滑等设置很容易出错。自己实现一个 InceptionBlock 用于理解结构就够了。
4. ResNet、DenseNet、MobileNet 的原理与实现拆解
如果说前面三个网络解决的是“怎么提取更丰富的特征”,那后面这三个网络解决的是两个更现实的问题:怎么把网络做得更深,以及怎么把网络做得更轻。ResNet 的答案是残差连接,DenseNet 的答案是特征复用,MobileNet 的答案是深度可分离卷积。这三个思路今天依然被大量使用。
4.1 ResNet:残差连接让梯度少走弯路
ResNet 的出现是因为研究人员发现,网络堆到一定深度后,效果不升反降,这种现象被称为退化问题。不是过拟合,而是训练时深层网络的误差反而更大。ResNet 给出的方案很优雅:与其让每一层直接学习目标映射 H(x),不如让网络学习残差 F(x) = H(x) - x,然后把输入 x 原样加到输出上。这样一来,即使某一层学不到什么有价值的东西,它至少可以学成恒等映射,不会让结果变差。
核心的 BasicBlock 代码并不复杂:
class BasicBlock(nn.Module): def __init__(self, in_planes, planes, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) if stride != 1 or in_planes != planes: self.shortcut = nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes), ) else: self.shortcut = nn.Identity() def forward(self, x): out = nn.functional.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return nn.functional.relu(out)实现时最容易错的是通道数不匹配。当 stride=2 下采样时,特征图尺寸减半,shortcut 必须用 1x1 卷积做投影,把通道数也对齐,否则out += self.shortcut(x)会直接报错。我经常看到新手只改卷积的 stride,忘了改 shortcut,导致维度对不上。
ResNet 还有一个升级版 Bottleneck 结构,用“1x1 降维 -> 3x3 卷积 -> 1x1 升维”的方式,把计算量压低,从而支持更深的层数。ResNet50 就是用它组成的。如果你的数据集不算太小,ResNet50 是一个很稳妥的默认选择,既能体现深度模型的优势,又不至于像 VGG 那样慢到让人崩溃。
4.2 DenseNet:极致的特征复用
DenseNet 的形态比 ResNet 更激进:在同一个 DenseBlock 里,每一层的输入是前面所有层输出的拼接。假设输入通道是 C0,增长率是 k,那么第 L 层的输入通道就是 C0 + k*(L-1)。这个设计让每一层都能直接拿到前面所有层的特征,实现了极致的特征复用,参数效率非常高。
DenseNet 的单元层实现也比较直接:
class _DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bn_size=4): super().__init__() self.norm1 = nn.BatchNorm2d(in_channels) self.conv1 = nn.Conv2d(in_channels, bn_size * growth_rate, kernel_size=1, bias=False) self.norm2 = nn.BatchNorm2d(bn_size * growth_rate) self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, padding=1, bias=False) def forward(self, x): out = self.conv2(nn.functional.relu( self.norm2(self.conv1(nn.functional.relu(self.norm1(x)))))) return torch.cat([x, out], dim=1)注意是torch.cat,不是加法。这也是 DenseNet 与 ResNet 最大的区别:ResNet 是恒等映射相加,DenseNet 是通道维度拼接。拼接带来一个问题,随着层数增加,通道数会越来越多,所以 DenseBlock 之间必须插入 Transition 层,用 1x1 卷积压缩通道,再用平均池化降低分辨率,否则显存和计算量都会爆炸。
实际训练 DenseNet 时,建议把 growth_rate 设小一点,常见值是 12 或 24。我之前第一次用的时候设了 48,结果参数量没多多少,但显存占用涨得很快。在图像分类任务上,DenseNet 的精度往往和 ResNet 相当,但参数量更少,这让我在特征提取类任务里更喜欢用它。
4.3 MobileNet:深度可分离卷积让模型跑进移动端
MobileNet 的核心不是“更准”,而是“更划算”。它把标准卷积拆成两步:先对每个通道单独做空间卷积,也就是 depthwise 卷积,再用 1x1 卷积把各通道的信息融合,也就是 pointwise 卷积。标准卷积的计算量是乘法,深度可分离卷积把它变成了加法,所以在精度损失可控的前提下,参数和计算量都大幅下降。
我给 MobileNetV1 做一个最简单但够用的模块:
class DepthwiseSeparableConv(nn.Module): def __init__(self, in_planes, out_planes, stride=1): super().__init__() self.depthwise = nn.Conv2d(in_planes, in_planes, kernel_size=3, stride=stride, padding=1, groups=in_planes, bias=False) self.bn1 = nn.BatchNorm2d(in_planes) self.pointwise = nn.Conv2d(in_planes, out_planes, kernel_size=1, bias=False) self.bn2 = nn.BatchNorm2d(out_planes) def forward(self, x): out = nn.functional.relu(self.bn1(self.depthwise(x))) out = nn.functional.relu(self.bn2(self.pointwise(out))) return out关键在groups=in_planes,这告诉 PyTorch 这个卷积不是跨通道的,而是每个通道独立处理一张特征图。如果漏掉这个参数,它就退化成一个普通卷积,整个 MobileNet 的轻量化优势全部归零。
MobileNetV2 进一步改进了设计,提出倒残差结构和线性瓶颈。所谓倒残差,就是先 1x1 扩张通道,再做深度卷积,最后 1x1 降维回去;线性瓶颈则是去掉最后一个 1x1 卷积后面的 ReLU,避免在低维空间里丢失信息。这个结构后续成为很多移动端网络的基础模块。工程上,如果你要部署到手机或者边缘设备,MobileNetV2 几乎是绕不开的选择,但要注意它和 ResNet 这类大模型的精度差距在很细粒度的分类任务上会明显放大。
5. 训练流程与调参经验:让每个模型发挥出应有水平
网络结构只是骨架,真正决定最终精度的是训练策略。我在这套对比实验里统一了所有模型的训练设置,但并不意味着每个模型的最佳设置都一样。下面这套流程是我反复验证过的,算是一个比较通用的起点。
5.1 优化器与学习率调度
图像分类任务里,SGD 加动量是我默认的优化器选择。它虽然看起来没有 Adam 那么“聪明”,但在 CNN 分类场景下收敛更稳定,泛化性也更好。初始化动量设 0.9,权重衰减设 1e-4,初始学习率根据 batch size 调整。我的经验是 batch size 64 时学习率从 0.01 到 0.05 之间试探,先用一小段数据跑几十个 iteration,观察 loss 是升还是降,再确定。
学习率调度我强烈推荐 warmup 加余弦退火的组合。一开始用很小的学习率跑几个 epoch,让模型权重从随机状态平稳过渡,之后再用余弦退火慢慢降低学习率,在训练后期不断细化。用 PyTorch 实现起来很简洁:
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR warmup = LinearLR(optimizer, start_factor=0.1, total_iters=5) cosine = CosineAnnealingLR(optimizer, T_max=epochs - 5, eta_min=1e-5) scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[5])如果不用 SequentialLR,也可以手动在两个调度器之间切换,但 API 在这种场景下确实省事。需要提醒的是,学习率调度器更新的时机有两种:每个 epoch 更新一次和每个 step 更新一次。上面这种调法适用于每个 batch 更新一次,也就是把scheduler.step()放在每个 batch 结束之后。如果你的代码习惯是每个 epoch 更新,那 CosineAnnealingLR 的 T_max 直接设为整体 epoch 数即可,两者不要混。
5.2 正则化、标签平滑与混合精度
过拟合是图像分类在中小规模数据集上的头号问题。为了防止每个模型在自己的强项上跑分虚高,我统一启用了重量级数据增强,同时加了标签平滑。标签平滑的作用是让模型不要对训练集的标签过于自信,减少过拟合,尤其是数据里有标注噪声时非常有效。PyTorch 的CrossEntropyLoss在较新版本里已经支持label_smoothing参数,直接设成 0.1 就行。
混合精度训练现在几乎是标配,尤其是跑 Inception、VGG 这种稍重的模型时,收益非常明显。我在训练循环里加了自动混合精度,代码片段如下:
scaler = torch.amp.GradScaler() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() with torch.autocast(device_type="cuda", dtype=torch.float16): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意混合精度并不是单纯的“快”,它还能降低显存占用。但在某些老版本 PyTorch 里,如果模型里存在自定义的数值不稳定的算子,要小心数值溢出。这时可以跳过 autocast 的层,或者先把模型切成 float32 看看是否有精度差异。
5.3 训练循环与模型保存
完整的训练循环看起来很简单,但有几个细节容易被忽略。第一个是model.train()和model.eval()的切换,尤其在 BatchNorm 和 Dropout 存在时,忘记切换会导致验证集结果忽高忽低。第二个是验证阶段要包在torch.no_grad()里,否则模型参数会被自动求导记录,消耗额外显存并拖慢速度。第三个是模型保存不要只看最后一个 epoch,要按验证集最优结果保存。
训练循环主体是这样的:
best_acc = 0.0 for epoch in range(epochs): model.train() train_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) with torch.autocast(device_type="cuda", dtype=torch.float16): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step() train_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() # 验证 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in valid_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = outputs.max(1) val_total += labels.size(0) val_correct += predicted.eq(labels).sum().item() val_acc = 100.0 * val_correct / val_total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth")我自己在项目里记录指标时,除了 Top-1 还会额外记录 Top-5。很多看起来效果一般的模型,Top-5 其实表现很好,这说明它的排序能力不差,只是第一名的置信度没有压准。如果你做的是检索或者推荐,Top-5 往往比 Top-1 更有参考价值。
把六个模型都跑完后,我在模拟项目 X 上的对比结果大致如下,仅供趋势参考:AlexNet 约 76%,VGG16 约 82%,InceptionV3 约 84%,ResNet50 约 85%,DenseNet121 约 84%,MobileNetV2 约 83%。可以明显看到,在同样的训练周期内,ResNet 和 DenseNet 这类带连接结构的模型收敛得更快,最终精度也更好。VGG 虽然参数最多,但它的优势在迁移学习里才更容易体现,直接在中小数据集上从头训练性价比不高。
6. 踩坑记录:精度、显存与复现问题速查
模型对比做多了,遇到的问题也越来越相似。我把这次实践里的典型问题整理成了一张速查表,后面单独提几个值得展开的细节。
| 问题 | 常见原因 | 排查方向 |
|---|---|---|
| 训练 loss 不降 | 学习率过大或过小、数据没有 shuffle、标签和图像错位 | 先小数据试跑,检查 loss 初始值,打印前几个 batch 的标签分布 |
| 验证集精度很低但训练集很高 | 过拟合、数据增强不足、Dropout/weight decay 没配好 | 增加增强强度,调大 weight decay,检查模型是否太大 |
| 显存不足 | batch size 太大、输入分辨率太高、特征图堆积 | 减小 batch,启用梯度累积,使用混合精度,检查是否有持久化的中间张量 |
| 训练很慢 | DataLoader 卡、worker 太少、模型里大量小算子 | 检查 CPU/GPU 利用率,适当增加 num_workers,优先用大卷积算子 |
| 加载预训练权重报尺寸不匹配 | 分类层通道数不同、输入通道数不同 | 先加载除了最后一层外的权重,再替换分类层 |
| 训练结果复现不一致 | 缺少随机种子、数据加载顺序不稳定、增强随机性 | 固定全套随机种子,统一 worker 数量,必要时关闭数据 shuffle |
6.1 梯度爆炸和初始 loss 异常
ResNet 和 DenseNet 这类有大量 BatchNorm 的网络,通常不太会遇到梯度爆炸,但 AlexNet 和 VGG 如果学习率起始值设太大,第一个 epoch 的 loss 就可能变成 NaN。我和同事排查过几次,最后发现一半以上都是学习率的问题。解决方法是先固定随机种子,把学习率调到 1e-3 级别试跑几十个 batch,如果 loss 能稳定下降再加回去。
另外,如果你的图片没有做归一化,或者 Normalize 的通道顺序不对,模型的初始 loss 也会异常偏高。比如你用的是 RGB 数据,但预处理写成了 BGR 顺序,模型等于在看一张颜色错乱的图,训练自然很吃力。这种问题日志里不一定会直接报错,但 Top-1 会一直卡在很低的位置。
6.2 过拟合在中小数据集上的典型表现
中小数据集上最典型的过拟合信号是训练 Top-1 快速冲到 99%,但验证集一直上不去。我在跑 AlexNet 时感受最明显,因为它全连接层太多。解决顺序建议是:先加强数据增强,再调大 weight decay,然后尝试 Dropout,最后才是减小模型。顺序不要颠倒,因为数据增强是最不伤已有结构的方案。
有一个很多人不知道的小技巧:Dropout 位置不同效果差别很大。放在全连接层前面和放在卷积特征图之间,作用并不一样。对于 VGG 这种网络,我通常在全连接层加 0.5 的 Dropout,但不会在卷积层加,否则会损失太多空间信息。
6.3 显存不足的工程解法
显存不够时,第一反应不要是换更小的模型,而是先看有没有写得不讲究的代码。常见浪费包括:在训练循环外保留整个 dataset 的归一化中间结果、验证阶段忘记包torch.no_grad()、用太多小 batch 但没开梯度累积。把这些问题清理干净,再去考虑模型层面。
如果还是不够,就用梯度累积模拟更大的 batch。下面是常用的写法:
accumulation_steps = 4 for step, (images, labels) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad() scaler.update()注意把 loss 除以 accumulation_steps,保证最终梯度量级和直接使用大 batch 一致。还有一个细节:BatchNorm 的统计量在梯度累积时会受影响,所以如果你真的对精度要求很高,尽量用真实 batch size 而不是过分依赖这个方法。
6.4 结果复现对不上的问题
在对比多个模型时,最烦的就是昨天跑出的结果今天复现不出来。后来我养成了一个习惯:在代码开头把所有随机种子固定,包括 Python、NumPy 和 PyTorch 的种子,同时把 DataLoader 的 worker 数量固定。即使这样,在 GPU 上由于底层算子的非确定性,多次运行结果仍有微小差异,这个属于正常范围,大家不用过于焦虑。
def set_seed(seed=42): random.seed(seed) os.environ["PYTHONHASHSEED"] = str(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)如果希望完全可复现,可以把torch.backends.cudnn.deterministic和torch.backends.cudnn.benchmark也设置好,但这时训练速度会慢一点。做对比实验时我建议固定 benchmark 为 False 或者 True,总之不要每次运行都不一样。
7. 模型选型与实操建议
把六个模型全部过完之后,我对选型的判断更清晰了。如果是第一次接触图像分类,我建议用 ResNet 入门,尤其是 ResNet18 和 ResNet50。它结构清晰、收敛稳定、代码实现难度低,能让你把注意力集中在数据流程和训练策略上。如果目标是部署到移动端,那就在 MobileNetV2 和 MobileNetV3 之间选,先把深度可分离卷积的原理吃透再说精度。如果手里数据集不大但又想拿一个高精度特征提取器,DenseNet 的性价比很高,参数利用率比同级别的 ResNet 更出色。
Inception 和 VGG 不是没用,只是用它们的场景更垂直。VGG 适合当迁移学习的骨干,因为它各个层级的语义特征非常规整,很多风格迁移和特征可视化任务到今天还在用它。Inception 适合做高精度分类,特别是你的输入图片本身尺度差异很大的时候,它的多分支结构能天然适应,而不需要你手动调整输入分辨率。
实际项目里,我一般不会只选一个模型,而是先让 ResNet 跑通 baseline,再用 DenseNet 或 MobileNet 做对比,最后看目标设备的限制来定。毕竟模型选型不是一场考试,没有一个标准答案。
在做这类项目时,我最后还会用torchinfo打印一次模型摘要,确认每一层的输出形状和参数量是否符合预期。这个小动作能帮你尽早发现代码里的维度问题,比出了 NaN 再回头查要高效得多。你如果正准备开始图像分类,不用追求一步到位,先跑通一个小规模闭环,再逐步把所有经典模型都过一遍,收获会比只调库大得多。