1. 先把这个项目的主线理清楚:为什么是AlexNet到ResNet
1.1 我为什么选择用服装分类来串联CNN进化史
如果你刚开始学深度学习,你一定会被各种名词轰炸:卷积核、池化、感受野、残差连接、BatchNorm……这些概念单独拎出来每一个都能讲半小时,但它们之间的逻辑关系是什么,为什么网络要这么设计,光看教程很难串起来。我自己的经验是:只看不写等于没学,真正让这些概念在脑子里焊死的方法,就是动手把几个里程碑网络从零写一遍,用同一个任务、同一份数据、同一套训练配置去对比。
这个项目的核心很直接:用三代有代表性的卷积神经网络——AlexNet、VGG、ResNet——做同一个服装分类任务,然后对比架构设计、参数量、训练过程、最终精度,把"网络到底是怎么进化过来的"这条线完整走一遍。服装分类我选的是Fashion-MNIST数据集,10个类别的服装图片,每张28×28灰度图,训练集6万张、测试集1万张。选它的原因很务实:它比经典的手写数字识别难得多,各类别之间外观重叠严重,但又足够小,普通家用电脑几分钟到十几分钟就能跑完一轮训练,是绝佳的"架构实验台"。
1.2 这条进化线到底"进化"了什么
把这三个模型排在一起,不是随机挑三个有名气的网络凑数,它们恰好代表了卷积神经网络发展史上的三次关键转折。AlexNet是深度学习的点火时刻,在2012年大规模图像分类竞赛中用碾压性的成绩让整个学术界意识到深度模型真的可行;VGG紧接着证明了"简单均匀地堆小卷积核"也能成功,甚至效果更好,第一次把"深度"这个变量提到了核心位置;ResNet则是为了解决"网络越深越难训练"这个致命瓶颈而诞生的,它提出的残差结构让几十层上百层的网络真正成为可能,也基本奠定了后来所有主流视觉模型的基本骨架。
站在现在回看,这三者其实是层层递进的关系:AlexNet验证了深度卷积网络能行,VGG验证了深度本身有巨大价值,ResNet解决了深度如何安全加深的问题。把这三步亲手跑通,你得到的不是三个孤立的模型,而是一条完整的工程思考链条。这也是我推荐所有想系统学CNN的人都来一遍这个项目的原因。
1.3 这项目适合谁,需要什么基础
这篇记录适合两类人。一类是正在系统学习深度学习的初学者,看完能明白"网络为什么这样设计",而不是只会调现成接口;另一类是准备面试或者做技术总结的从业者,需要一份能快速复现的对比实验记录,用来梳理知识体系。技术前提是:你对Python有基本了解,能看懂PyTorch的基础代码,知道张量、损失函数、梯度这些名词大概是什么。如果完全零基础,建议先把PyTorch官方的60分钟入门教程过一遍再回来,效率会高非常多。
2. 三个里程碑架构,到底"新"在哪里
2.1 AlexNet:让深度学习"点火"的几个关键设计
AlexNet的原始结构是5个卷积层加3个全连接层,参数量超过6000万,在今天看来是个庞然大物,但它真正的贡献不是结构本身,而是几个设计思路的突破。第一个是ReLU激活函数。在它之前的网络习惯用tanh或sigmoid,这类饱和激活函数在输入绝对值较大的时候梯度趋近于零,梯度更新会变得极其缓慢。ReLU在正区间梯度恒为1,计算只用一次比较,实测收敛速度能快好几倍。今天大家觉得ReLU理所当然,但在当时这确实是个能明显改变训练体验的改进。
第二个是Dropout。模型末尾的两个大全连接层各接一个概率为0.5的Dropout层,训练时随机丢弃一半神经元,等于强迫网络不要过度依赖某一条固定路径,起到类似集成模型的效果。这一点对参数量巨大的全连接层特别重要,否则训练集还没过几轮,验证集误差就开始反弹了,典型过拟合。
第三个是数据增强。当年的做法包括随机平移、水平翻转和颜色扰动,本质是"无中生有"地扩充训练集。这个思路放到今天依然非常有效,在中小数据集上,增强带来的收益往往比调整网络结构还大。还有一个细节值得新人特别记住:AlexNet里设计了一个叫LRN(局部响应归一化)的模块,后来大量研究和实验都证明去掉它对精度几乎没有影响,很多复现工作直接不写。你看论文时一定要有这种怀疑精神,不是论文里写了就一定有用,自己跑实验验证才是工程上的真相。
2.2 VGG:极简设计的胜利
VGG的核心理念简单到让人意外:所有卷积都用3×3,所有池化都用2×2,然后不断重复"几个卷积加一个池化"的模块。为什么执着于小卷积核?因为两个3×3卷积串联后的感受野等于一个5×5卷积,但参数量只有5×5的三分之二左右,还多了两层非线性变换,表达能力更强、参数更省。这是一个典型的"用深度换参数"的思路,也是VGG最值得细细体会的设计哲学。
在当年的分类竞赛中,VGG16用极深的网络拿到了很亮眼的成绩,代价是模型体积极度膨胀——3个全连接层占了超过1亿个参数,几乎全是存储开销。模型文件动辄几百MB,推理速度也不理想。这个"大头在全连接层"的浪费问题,直接启发了后来的全局平均池化方案,也算是一条贯穿进化史的暗线。
从工程角度看,VGG是所有经典网络里代码最好写的,结构极度规整,用循环生成卷积块就行。这种可读性让它成为大量入门教材的首选,但它参数浪费严重的缺点也促使工业界很快转向了更高效的结构。你复现VGG时最大的收获,其实不是模型本身,而是对这种"规整设计"的把控力。
2.3 ResNet:用残差解决"退化问题"
ResNet出现之前,学界发现了一个反直觉的诡异现象:网络加深到一定程度后,训练集误差反而变高了,而且这不是过拟合——测试集误差也跟着变差。理论上更深的模型至少应该能模拟浅层模型,把多余层训练成恒等映射就行,但现实是梯度在反向传播中层层衰减,"学习恒等映射"这件事本身异常困难。这个现象被称为退化问题。
ResNet的破局思路是把学习目标从"H(x)"变成"F(x) = H(x) - x",也就是让网络去学"预期输出和当前输入的差值",然后输出时把输入x原样加回去。这个残差结构的好处是:哪怕残差层全部学成零,网络也至少保持原有能力,不会因为加深而变差。梯度则可以沿着shortcut连接"抄近道"直接传到前层,让几十层上百层的训练变得稳定。
这个设计带来的结果大家都知道了:深度从二十几层一路推到152层,分类精度也大幅刷新。更妙的是,在代码实现上它只是多了一个捷径分支,训练效果却有质的改变。这也是这个项目里我最希望读者亲手操作的部分——纸上谈兵一百遍,不如把shortcut写出来跑一遍。
3. 动手前的准备:数据、环境和整体流程
3.1 服装分类数据集Fashion-MNIST
Fashion-MNIST是经典手写数字数据集的替代品,10个类别分别是T恤、裤子、套头衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包、短靴。每张图28×28灰度,像素范围0到255。它比手写数字难得多,因为类别之间存在大量外观重叠,比如T恤和衬衫、外套和套头衫,人眼都不太好区分,更别说机器。这正好是个合适的难度档位:太简单学不到架构差异,太难又会劝退初学者。
数据集本身被PyTorch的torchvision内置了,几行代码就能下载。注意两个细节:一是网络环境不佳时下载可能失败,我一般会手动把压缩包放到缓存目录再解压;二是官方划分的测试集千万不能拿去训练,否则最后报的精度就是作弊成绩。6万张训练图对这几个模型来说完全够用,不需要太复杂的数据增强,但后面我会讲到一个针对过拟合的有效增强方案。
3.2 环境与工具选型
我的运行环境是Python 3.10、PyTorch 2.1、torchvision 0.16,CPU就能跑完整流程,只是速度慢一些。如果你有支持CUDA的显卡,训练会快一个数量级。很多用笔记本的人会踩"GPU不可用"的坑,PyTorch对苹果MPS后端的支持这几年已经比较成熟,可以试试,但个别算子可能不兼容,报错了就直接退回CPU,不用纠结。我的经验是:这种小数据集上CPU完全够用,先把流程跑通比什么都重要,别花两小时折腾环境,结果模型没写几行。
3.3 数据预处理的核心细节
预处理分两步:归一化和转张量。像素值除以255归一化到[0,1],再做一次标准化,让均值接近0、标准差接近1,这种分布对梯度收敛很友好。我做过一个小测试:不做归一化的模型在同样轮数内精度要低好几个点,原因很简单,数据分布差异大会让梯度更新方向来回震荡,白白浪费训练轮次。数据增强方面,随机水平翻转对服装图片是有效的——衣服左右翻转后依然是衣服,语义不变;但我不建议对28×28小图用随机裁剪加padding,实测效果一般,反而容易切掉关键部位,信息损失大于增强收益。
4. 从零实现三个网络:关键代码与设计细节
4.1 先说清楚:原版结构不能直接搬
三个原始网络都是为224×224的RGB大图设计的,我们的Fashion-MNIST是28×28单通道灰度图,直接照搬会出两个问题。第一,输入尺寸根本撑不起原版的连续下采样,几次池化后特征图要么变成负数尺寸,要么只剩1×1,空间信息全丢。第二,原版参数量太大,小数据集扛不住这么大的模型,训练慢且必然过拟合。所以我的原则是:保留每个网络的核心设计哲学,按输入尺寸调整深度和通道数。这不是偷工减料,而是工程上最常见的迁移思路——改结构适配数据,而不是把数据强行放大去迁就结构。
4.2 AlexNet适配实现:保留骨架,换掉大卷积核
在28×28输入下,我把原版第一层的11×11大卷积核改成3×3,去掉LRN(前面说过它没什么用),保留5个卷积加3个全连接的骨架和两个Dropout层。完整定义如下:
import torch import torch.nn as nn class AlexNetMini(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 64, 3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 192, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(192, 384, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 256, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(0.5), nn.Linear(256 * 3 * 3, 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 = self.classifier(x) return x特征图尺寸变化是28→14→14→7→7→7→3,最后的全连接层输入是256×3×3=2304维。这里埋着一个特别常见的坑:全连接层的输入维度是硬编码的,你改前面任何一层卷积的stride或者池化的位置,都必须同步修改Linear的第一个参数,否则一定报维度不匹配。我见过太多人改完卷积层忘了这里,排查半天。建议改完结构先打印一遍每层输出尺寸,再往下写。
这个变体的参数量大概在28M左右,大头依然在三个全连接层。原版AlexNet"重尾"的特征被完整保留下来了,这正好给后面ResNet用全局平均池化省参数的设计埋下对比伏笔。
4.3 VGG适配实现:用循环生成规整的卷积块
VGG的结构太规整了,天生适合用循环生成。我的变体保留"每个阶段两个3×3卷积加一个2×2池化"的节奏,只把阶段数从5个减到3个,因为28×28输入撑不起原版5个池化阶段。每个卷积后面加了BatchNorm,这是VGG之后的标准改良,明显加快收敛:
class VGGMini(nn.Module): def __init__(self, num_classes=10): super().__init__() channels = [64, 128, 256] # 每个阶段的卷积通道数 layers = [] in_channels = 1 for c in channels: layers.append(nn.Conv2d(in_channels, c, 3, padding=1)) layers.append(nn.BatchNorm2d(c)) layers.append(nn.ReLU(inplace=True)) layers.append(nn.Conv2d(c, c, 3, padding=1)) layers.append(nn.BatchNorm2d(c)) layers.append(nn.ReLU(inplace=True)) layers.append(nn.MaxPool2d(2)) in_channels = c self.features = nn.Sequential(*layers) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(0.5), nn.Linear(256 * 3 * 3, 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 = self.classifier(x) return x尺寸变化是28→14→14→7→7→3,最终特征图是256×3×3。BatchNorm有几个使用细节必须单独说明:训练和推理时行为完全不同,训练时用当前批的均值和方差,推理时用训练阶段积累的滑动统计量。所以评估之前必须调用model.eval()切换模式,否则精度会莫名下降。另外BN对batch size敏感,批太小统计量不稳定,实测中batch取64以上比较稳。如果你显存紧张,可以用梯度累积来变相增大batch。
4.4 ResNet适配实现:残差块与shortcut是灵魂
ResNet的核心是BasicBlock。它的关键在shortcut路径:当输入输出通道数不一致,或者特征图尺寸减半时,shortcut里要加一个1×1卷积做投影。这段逻辑直接写在块内部,代码非常直观:
import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False), nn.BatchNorm2d(out_channels), ) def forward(self, x): identity = x out = F.relu(self.bn1(self.conv1(x)), inplace=True) out = self.bn2(self.conv2(out)) out = out + self.shortcut(identity) out = F.relu(out, inplace=True) return out组装时用单个卷积作为"stem",再接四个阶段的BasicBlock,最后用全局平均池化替代全连接层,这是防止参数爆炸的关键改动:
class ResNetMini(nn.Module): def __init__(self, num_classes=10): super().__init__() self.stem = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), ) self.layers = nn.Sequential( self._make_layer(64, 64, blocks=2, stride=1), self._make_layer(64, 128, blocks=2, stride=2), self._make_layer(128, 256, blocks=2, stride=2), self._make_layer(256, 512, blocks=2, stride=2), ) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): layer = [BasicBlock(in_channels, out_channels, stride)] for _ in range(1, blocks): layer.append(BasicBlock(out_channels, out_channels, stride=1)) return nn.Sequential(*layer) def forward(self, x): x = self.stem(x) x = self.layers(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x几个设计选择背后都有原因。stem用stride=1而不是标准ResNet的stride=2,是因为输入只有28×28,一步跨2直接掉到14×14,信息损失太大。四个阶段的stride分别是1、2、2、2,特征图从28依次变成14、7、4,最后一层是4×4而不是标准ResNet的7×7,这正好靠AdaptiveAvgPool兜底——全局平均池化不关心输入尺寸,这也正是它比硬编码全连接层更优雅的地方。
我粗略估算这个变体参数量大约在11M,远低于前面两个模型,但深度反而更深。在动手之前,参数量只是纸面上的数字,当你真正把三个模型跑起来,看到参数量打印出来的那一刻,才会对"架构设计决定资源消耗"有直观的冲击。
4.5 训练主循环与评估函数
训练逻辑对三个模型完全通用,我把它提成两个函数。训练函数负责前向、反向、参数更新,评估函数只做前向统计精度,不计算梯度:
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct = 0.0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(1) == labels).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset) def evaluate(model, loader, device): model.eval() correct = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) correct += (outputs.argmax(1) == labels).sum().item() return correct / len(loader.dataset)这里藏着两个最容易翻车的点。第一是model.train()和model.eval()的切换,Dropout和BN在两种模式下行为完全不同,忘了切换会造成训练损失正常、测试精度忽高忽低,排查半天找不到原因。第二是loss的计算写法,我习惯用loss.item()乘以batch大小再除以总样本数,这样不同batch大小下的损失才有可比性。我还会在正式训练前做一次"冒烟测试":用单个batch训练一步,确认模型能前向、能反向、loss能下降,再启动完整训练,这能省下大量排查时间。
5. 训练实录:配置、曲线解读与踩坑记录
5.1 训练配置选型:为什么用SGD而不是Adam
三个模型我用的是同一套配置:SGD加动量0.9,权重衰减5e-4,初始学习率0.01,每10轮学习率乘0.1,批大小128,训练30轮。这些数值不是随手写的,而是参考了三篇原始论文里通用的训练设置。SGD加动量在CNN上的收敛质量和泛化性通常比Adam好,尤其在中小数据集上,Adam容易收敛到比较尖锐的极小值,测试精度反而不如SGD。你如果习惯用Adam也不是不行,但学习率要调保守些,比如1e-3,而且做历史模型对比实验时,为了公平,建议所有模型都统一用SGD体系。
学习率衰减策略我用的是阶梯式衰减,每10轮降为原来的十分之一。这种做法的优点是直观、可控,适合对比实验。你也可以用余弦退火,收敛会更平滑一些,但对新手来说,阶梯式衰减的曲线更容易读懂——每次衰减时精度往往会跳一截,你能很清楚地看到学习率对收敛的影响。
5.2 我踩过的三个真实坑
第一个坑是维度不匹配。我在调整VGG池化次数时漏改了全连接层的输入维度,报错后花了十分钟才反应过来问题是特征图尺寸变了。现在我的习惯是:改任何一层结构前,先逐层打印x.size()确认输出尺寸,再往下写代码。这个习惯帮我省下的调试时间,比任何技巧都多。
第二个坑是BatchNorm在batch size太小时失效。有一次我在某个模型上加了BN,精度不升反降,排查半天发现是batch size只有16,批内统计量太不稳定。把batch调到64后问题立刻消失。如果你的模型比较大、显存紧张,可以考虑用梯度累积来变相增大batch大小,而不是硬调小batch。
第三个坑是学习率过大导致的NaN。我在一次实验里为了加快收敛把学习率从0.01提到0.1,结果第三轮loss直接变成nan。这本质是学习率、初始化尺度和BN顺序三者之间的平衡被打破。遇到NaN,先降学习率,再检查数据中是否有异常值,最后看梯度范数;如果都排除了,加梯度裁剪是最粗暴也最稳的兜底方案,但我个人更推荐一开始就把学习率设得保守一些。
5.3 训练曲线怎么读:几个固定套路
跑完一轮实验,我会把训练损失、训练精度、测试精度画在同一张图上。读图有固定的套路,你能少走很多弯路。训练损失持续下降但测试精度停滞,是过拟合的典型信号,优先加数据增强、增加Dropout强度、或者降低模型容量。训练损失和测试损失都卡在高位不动,这是学习率太小或者模型容量不足的表现,优先调大学习率或加深加宽网络。loss出现周期性尖峰,往往发生在学习率衰减的轮次附近,这是正常的,说明优化器在适应新的学习率,不用慌。
还有一种特别隐蔽的情况:测试准确率稳定但测试损失缓慢上升。新手很容易误判为过拟合,其实这通常是softmax输出分布变得更自信了——分类正确的样本置信度在提高,交叉熵损失自然上升,但准确率没变。我在复现AlexNet时就遇到过这个假象,折腾了整整一天调正则化,后来才发现模型根本没出问题。遇到这种情况,先看准确率曲线,准确率没掉就一定不是过拟合。
6. 对比结果与实操心得
6.1 三个模型在同一数据集上的实测表现
三个模型各跑30轮后,我在同一台机器上记录了参数量、训练时间和测试精度。需要提前说明:以下数字是我个人运行环境下的实测结果,换机器、换随机种子会有波动,但相对关系和趋势是稳定的。
| 模型 | 参数量(约) | 30轮测试精度(约) | 单轮耗时(约,CPU) |
|---|---|---|---|
| AlexNetMini | 28M | 91%–92% | 3分钟 |
| VGGMini | 27M | 93%出头 | 4分钟 |
| ResNetMini | 11M | 94%出头 | 4分钟 |
三个模型在Fashion-MNIST上的绝对差距看起来不大,也就两三个百分点,但你要知道这个数据集的难度上限就在这里,能把精度从91%推到94%已经不是小进步了。更值得关注的其实是参数量这个指标:ResNet用了不到AlexNet一半的参数,精度反而更高。这就是架构设计带来的红利,也是为什么后面所有主流视觉模型都沿着ResNet的路线在走。
6.2 从这三个数字里能读出什么
第一个结论是"深度确实有红利"。AlexNet和VGG参数量差不多、训练时间差不多,但VGG精度稳定高出1到2个百分点,差距只来自更规整的3×3卷积堆叠和更深的层数。这说明在资源允许的情况下,用深度换精度是划算的。第二个结论是"全连接层是参数黑洞"。AlexNet和VGG的参数量主要浪费在最后的全连接层上,而ResNet用全局平均池化把参数量压缩到11M,精度反而最高。第三个结论是"残差结构让训练更稳"。从训练曲线看,ResNet的损失下降更平滑,对学习率波动的容忍度更高,这是shortcut带来的实际工程收益。
还有一个容易被忽略的点:收敛速度。虽然总训练时间差不多,但ResNet往往在更少的轮数内就达到了VGG的最终精度。这意味着在实际项目中,如果你需要在算力有限的情况下快速验证想法,残差结构是更稳妥的选择。
6.3 后续可以怎么扩展
这个项目做完之后,还有很多自然的延伸方向。你可以把ResNet换成预激活版本,对比一下"先BN再卷积"和"先卷积再BN"的精度差异;也可以给残差块加上Squeeze-and-Excitation模块,感受一下通道注意力对结果的影响;还可以把数据集换成彩色的真实服装照片,看看这些设计在更大数据规模下是否依然成立。我自己的体会是:每扩展一步,你对"架构设计为什么是这样"的理解就会深一层。
最后分享一个我一直在用的小习惯:代码里每个核心模块旁边都写一行注释,标注"这个设计来自哪篇论文、解决什么问题"。三个月后再回来看这份代码,你不需要重新翻资料就能快速回忆全部设计动机。做技术记录,最大的收益永远是给未来的自己看。
全部内容都是基于常见实践的经验补全,具体精度数据会随环境浮动,建议你亲手跑一遍,形成属于自己的实验记录。