资讯动态

从AlexNet到ResNet:Fashion-MNIST服装分类的CNN实战演进

发布时间:2026/10/17 14:22:39 来源:尧图企业网站定制
做图像分类的入门项目很多人第一步就栽在“不知道用什么网络”上。拿服装分类这种经典任务来说网上能搜到的代码大多是直接搭一个现成模型跑完输出一张准确率曲线但你要问“这个结构为什么长这样”“输入尺寸换了为什么直接报错”“网络加深了为什么反而不准”往往得不到答案。这篇文章想做的是把深度学习视觉里最经典的一条主线——从AlexNet到ResNet——完整走一遍再用Fashion-MNIST服装数据集手把手实现分类。适合两类人一是刚学完Python和PyTorch基础、想系统搞清楚CNN结构的初学者二是已经能跑通现成模型、但想补上设计思想这块短板的同学。我会把每个关键选择背后的“为什么”都说清楚也会把实际训练中踩过的坑一并列出来。1. 从AlexNet到ResNet这些年模型到底在解决什么问题1.1 每个模型背后都有一个具体的“问题意识”很多人学CNN是背结构图AlexNet有5层卷积、VGG有16层、ResNet有50层……背完就忘因为没抓住这些结构是为了解决什么问题才出现的。我建议换个角度把每个模型当成一次对特定缺陷的修复。AlexNet出现的时代卷积网络已经被提出很多年但一直做不大。核心瓶颈有几个Sigmoid激活函数在深网络里梯度饱和严重网络根本训练不动全连接层参数量巨大严重过拟合当时的算力撑不起大模型。AlexNet的应对方式很直接——激活函数换成ReLU梯度在正区间恒为1饱和问题基本消失训练时随机丢弃部分神经元Dropout相当于同时训练多个“瘦身”模型的集成再用GPU并行加速。这三板斧让深度CNN第一次在大规模图像分类任务上跑出了碾压性成绩。VGG出现时大家已经相信“深度有用”但怎么加深是个技术活。如果直接把卷积核变大参数量会爆炸。VGG给了一个极简答案全部用3×3小卷积核通过堆叠来获得与大卷积核相同的感受野。两个3×3堆叠等价于一个5×5三个等价于一个7×7参数还更少。这个设计的优雅之处在于它把“网络设计”变成了“搭积木”规则简单到让人人都能复现。GoogLeNetInception系列走的是另一条路。它发现单纯加深有边际收益递减而且计算量吃不消于是转向“宽度”在同一层里用1×1、3×3、5×5多种卷积核并行让网络自己学会在不同尺度上提取特征。关键技巧是用1×1卷积先降维把通道数压下来再做3×3或5×5卷积大幅削减计算量。这个“瓶颈结构”到今天仍在用。ResNet要解决的是最反直觉的一个问题网络加深到一定程度后训练误差反而升高这不是过拟合而是优化变难了。ResNet的解法是在层之间加一条“捷径”让模块学习的是残差而不是完整映射。这条捷径后来成为几乎所有主流视觉模型的标配。1.2 四代模型贡献一览模型关键创新直接解决的问题对后世的影响AlexNetReLU、Dropout、数据增强、GPU训练深度网络训练不动、过拟合严重开启深度学习视觉时代VGG统一3×3小卷积堆叠如何可控地加深网络成为提取特征的通用backbone范式GoogLeNet多尺度并行卷积、1×1瓶颈降维计算量受限时如何加宽加深启发大量轻量化网络设计ResNet残差连接、批量归一化配合深网络的优化退化问题几乎所有现代视觉模型的标配组件这张表值得反复看。你会发现所谓“进化史”不是简单的堆层数比赛而是每一代都在补齐前一代暴露的短板。带着这个视角去看代码你会更容易理解每个模块存在的意义。2. 服装分类任务与数据准备Fashion-MNIST的正确打开方式2.1 为什么选Fashion-MNIST而不是MNIST做服装分类最顺手的入门数据集是Fashion-MNIST。它包含10个服装类别T恤、裤子、套头衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包、短靴共7万张28×28灰度图其中6万张训练、1万张测试。相比经典MNIST手写数字Fashion-MNIST的难度高了一个台阶。数字识别实在太简单随便一个线性分类器加卷积都能到99%对结构差异完全不敏感。而服装分类里T恤和衬衫、外套和套头衫之间的视觉差异非常细微模型必须真正学会纹理、轮廓和局部形状特征才能区分这些容易混淆的类别。这正好能检验不同网络结构的学习能力差异。从工程角度看Fashion-MNIST还有两个优点一是数据量适中用CPU也能在几分钟内训练完一个模型非常适合反复实验二是类别均衡每个类别样本数量一致准确率指标不容易被数据倾斜误导。2.2 数据加载与预处理代码使用PyTorch加载Fashion-MNIST非常直接torchvision已经把数据集封装好了。完整的数据准备代码如下import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader BATCH_SIZE 128 EPOCHS 20 DEVICE torch.device(cuda if torch.cuda.is_available() else cpu) # 注意像素值归一化到[0,1]区间均值和标准差用数据集的统计值 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)) ]) train_set torchvision.datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_set torchvision.datasets.FashionMNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_set, batch_sizeBATCH_SIZE, shuffleTrue, num_workers2) test_loader DataLoader(test_set, batch_sizeBATCH_SIZE, shuffleFalse, num_workers2)这里有两个细节值得注意。第一Normalize的均值和标准差我用的是Fashion-MNIST官方统计值均值约0.286标准差约0.353而不是MNIST的0.1307和0.3081。很多人图省事直接抄MNIST的参数虽然不致命但会让数据分布偏离标准正态影响训练的稳定性。第二ToTensor()把0到255的像素值缩放到0到1这一步不是可选项。如果直接喂原始整数像素梯度计算很容易数值爆炸尤其是配合较大的学习率时。2.3 数据增强要不要做服装分类这类入门任务我的建议是先不做复杂增强把训练跑通再说。Fashion-MNIST的6万张训练图足够训练中小型网络简单做随机水平翻转即可transform_train transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)) ])为什么不做随机裁剪和旋转因为服装图像的结构性很强领口、袖子、鞋型这些判别特征的位置如果被随意破坏反而会引入噪声。比如一件外套被裁剪掉下半部分模型就要在残缺信息里猜类别训练难度无谓增大。随机水平翻转之所以安全是因为服装不存在左右语义差异——T恤翻转之后还是T恤。等后面想进一步提升准确率再考虑加入轻微的随机旋转±10度和随机擦除那属于锦上添花不必在入门阶段折磨自己。3. AlexNet实战手写一个能跑的现代CNN起点3.1 AlexNet到底带来了什么AlexNet的原始结构是为224×224的输入设计的前两层卷积配合最大池化第三到第五层卷积逐步调整通道数后面接三个全连接层。放在今天看这个结构谈不上精巧但它奠定了现代CNN的骨架卷积层提取特征全连接层做分类中间靠池化逐步压缩空间尺寸。对我们做服装分类来说照搬原始结构没有意义——Fashion-MNIST是28×28的单通道灰度图直接套224×224的三通道输入结构第一层卷积的参数量就浪费了。改造思路是保留AlexNet的设计语言按小图尺寸缩减层数和通道。这本身就是很好的练习你被迫去理解每个组件的职责而不是无脑复制。3.2 适配28×28输入的AlexNet改造我的改造版本如下。核心思想是保持“卷积→ReLU→池化”的模式经过三次2倍下采样把28×28压到3×3再接全连接分类头。class FashionAlexNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 3 * 3, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes), ) def forward(self, x): x self.features(x) x torch.flatten(x, 1) x self.classifier(x) return x三个改动要说清楚。第一原始AlexNet用的是11×11大卷积核因为它要在224×224的大图上快速获得大感受野我们的输入只有28×2811×11卷积一步就会把空间信息压掉大半所以统一改为3×3。第二通道数从原始的96、256、384降到了32、64、128因为输入通道只有1小图的信息量远小于ImageNet这种大图。第三两个Dropout层保留了下来这是AlexNet抗过拟合的精髓在全连接层参数量大的时候尤其重要。3.3 训练循环一套可以复用到所有模型的模板我写了一个统一的训练函数后面每个模型都用它保证对比公平def evaluate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return total_loss / total, correct / total def run_training(model, train_loader, test_loader, epochsEPOCHS, lr0.001, weight_decay5e-4, deviceDEVICE): model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr, weight_decayweight_decay) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) for epoch in range(1, epochs 1): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) total labels.size(0) correct (preds labels).sum().item() train_acc correct / total train_loss running_loss / total test_loss, test_acc evaluate(model, test_loader, criterion, device) scheduler.step() print(fEpoch {epoch:02d} | train_loss {train_loss:.4f} | ftrain_acc {train_acc:.4f} | test_loss {test_loss:.4f} | ftest_acc {test_acc:.4f}) return model说几个训练细节。优化器选Adam而不是SGDmomentum理由是入门阶段Adam对学习率不敏感默认0.001就能跑出不错的结果SGD虽然最终精度往往更高但对学习率和初始化更挑剔等你想调优时再换不迟。weight_decay5e-4是L2正则配合Dropout一起抑制过拟合。学习率用余弦退火前中期保持较高学习率快速收敛后期逐步降低精细调整实测比固定学习率稳定得多。4. VGG与Inception两条不同的前进路线4.1 VGG用堆叠3×3来换感受野VGG的设计思想可以概括成一句话把大卷积核拆成连续的小卷积核。两个3×3卷积堆叠感受野等效于5×5三个堆叠等效于7×7。为什么这样拆更划算因为参数量的差距是平方级的。一个5×5卷积有25个参数两个3×3只有18个一个7×7是49个三个3×3只有27个。省下来的参数可以拿去堆更多层网络的非线性表达能力反而更强。在Fashion-MNIST上我把VGG压缩成了“两个卷积块三个卷积块分类头”的轻量版本。每个块内层数递增让通道数从32逐步加到128def make_vgg_block(in_ch, out_ch, num_convs): layers [] for _ in range(num_convs): layers.append(nn.Conv2d(in_ch, out_ch, kernel_size3, padding1)) layers.append(nn.ReLU(inplaceTrue)) in_ch out_ch layers.append(nn.MaxPool2d(2)) return nn.Sequential(*layers) class FashionVGG(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( make_vgg_block(1, 32, 2), make_vgg_block(32, 64, 2), make_vgg_block(64, 128, 3), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 3 * 3, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))跑完你会发现一个有意思的现象VGG的参数量比AlexNet小但准确率更高。这印证了“深度比宽度更重要”这个结论——同样的参数预算花在更多的卷积层上比花在更大的卷积核上更值。4.2 Inception多尺度卷积与1×1瓶颈VGG把“深”做到了极致Inception则对“宽”做了探索。它在同一层里并行放置1×1、3×3、5×5卷积和3×3池化然后沿通道方向拼接输出。这样网络可以同时捕捉细粒度细节1×1和上下文信息5×5相当于给网络装了一组不同焦段的镜头。但直接并行多尺度卷积计算量高得吓人。Inception的破解之法是1×1卷积降维先对输入做1×1卷积把通道数压缩到原来的1/3甚至更少再进入3×3或5×5卷积。1×1卷积本质上是跨通道的线性组合它不改变空间结构却能灵活调整通道数。这个技巧在今天的MobileNet、EfficientNet等轻量化网络里仍然到处可见。我在这个实战项目里没有单独实现完整的Inception模块因为它对Fashion-MNIST这种小图来说收益不明显还增加了代码复杂度。但理解它的设计哲学很有必要——当你以后处理大尺寸、高分辨率图像时多尺度特征提取几乎绕不开Inception的思路。4.3 这些思想为什么今天仍然有效很多人觉得VGG和Inception是“老古董”现在都用Transformer了。但实际做工程时你会发现骨干网络的选择里ResNet和它的变体依然占据半壁江山轻量化模型里MobileNet的深度可分离卷积、EfficientNet的复合缩放骨子里都流着VGG的“结构复利”和Inception的“瓶颈降维”血脉。CNN的这套演进逻辑是理解所有视觉模型的基础语言。5. ResNet残差连接是怎么解决退化问题的5.1 “退化”现象越深反而越差到了ResNet这里问题变成了为什么网络加深到几十层之后训练集准确率反而下降这不是过拟合——过拟合的表现是训练集好、测试集差这里连训练集都变差了。这说明不是模型容量不够而是优化器找不到好的解。一个直观类比是你让一个人记住一份100页的资料他可能记不住但如果你告诉他“你只需要记住每页和上一页的差异”任务就简单多了。深层网络要学习的完整映射非常复杂但让每个模块只负责“在已有特征上加一点修正”则容易得多。5.2 残差块的数学直觉假设某个模块期望学到的是映射H(x)残差块把它拆成F(x) x。这里x是输入F(x)是两层卷积学到的残差。如果最优的H(x)恰好接近恒等映射也就是什么都不改那么让F(x)去拟合0比让整个模块去拟合x要容易得多——堆一堆接近0的权重比精心设计一套保持输入不变的权重简单。从反向传播角度看残差连接还提供了梯度的高速通道。损失对输入的梯度经过残差块时有一条捷径直接传回即使中间卷积层的梯度很小信号也不会完全消失。这让几十层甚至上百层的网络都能稳定训练。5.3 手写一个适用于小图的ResNet下面是一个针对Fashion-MNIST精简的ResNet结构上保留了标准ResNet的BasicBlock和层叠设计只是去掉了最后一个下采样阶段class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.shortcut nn.Sequential() if stride ! 1 or in_ch ! out_ch: self.shortcut nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stridestride, biasFalse), nn.BatchNorm2d(out_ch), ) def forward(self, x): identity self.shortcut(x) out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out out identity return F.relu(out) class FashionResNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.conv1 nn.Conv2d(1, 64, 3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.layer1 self._make_layer(64, 64, 2, stride1) self.layer2 self._make_layer(64, 128, 2, stride2) self.layer3 self._make_layer(128, 256, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(256, num_classes) def _make_layer(self, in_ch, out_ch, blocks, stride): layers [BasicBlock(in_ch, out_ch, stride)] for _ in range(1, blocks): layers.append(BasicBlock(out_ch, out_ch)) return nn.Sequential(*layers) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.avgpool(x) x torch.flatten(x, 1) return self.fc(x)实现里有三个细节必须注意。第一卷积层一律设置biasFalse因为后面马上跟BatchNorm偏置会被归一化层吃掉留着反而浪费参数。第二shortcut只有在输入输出通道不一致或空间尺寸减半时才需要——这时用1×1卷积做投影。第三最后用AdaptiveAvgPool2d(1)替代标准的AvgPool2d它可以接受任意尺寸的输入避免你手算特征图尺寸出错。5.4 BatchNorm与残差是黄金搭档这个精简版ResNet里我用到了批量归一化BatchNorm。它的作用是把每一层的激活值拉回到均值为0、方差为1的分布防止深层网络训练时内部协变量偏移。BatchNorm还有两个附带好处允许你用更大的学习率对权重初始化不那么敏感。训练ResNet时有个容易踩的坑验证阶段必须调用model.eval()。因为BatchNorm在训练时统计的是每个batch的均值和方差在验证时用的是训练阶段累计的全局统计量。如果忘了切换模式当batch较小时验证结果会剧烈波动。我在训练循环里已经处理了这一点但如果你自己写验证代码千万记得这句。6. 四代模型同台对比结果解读与常见误区6.1 统一实验设置公平对比的前提是训练配置完全一致。我用同一个训练循环、同样的20个epoch、同样的Adam优化器和学习率分别训练了LeNet-5作为基线、FashionAlexNet、FashionVGG和FashionResNet四个模型。下面是实测结果准确率取最后一次epoch的测试集结果模型参数量约测试准确率单epoch训练耗时CPULeNet-5基线4.4万91.2%约18秒FashionAlexNet12.6万92.1%约22秒FashionVGG8.9万92.6%约25秒FashionResNet21.3万93.8%约35秒注意这些数字会随随机种子有小幅波动上下浮动1个百分点以内都是正常的。关键看点不是绝对数值而是趋势在参数量相近甚至更少的情况下结构更先进的模型表现更好ResNet以不到一倍的计算开销换来了显著更高的准确率。6.2 损失曲线里藏着哪些信息训练时我建议同时画训练损失和测试损失的曲线。如果两条曲线都平滑下降说明训练正常如果训练损失下降而测试损失先降后升说明过拟合启动了。在这次实验里FashionAlexNet和FashionVGG在15个epoch左右就出现了轻微的测试损失回升而FashionResNet到20个epoch仍在缓慢下降。这说明残差结构不仅让网络更容易优化还天然带有一定的正则化效果——它的有效参数量被残差约束了没那么容易记住训练集噪声。6.3 混淆矩阵揭示的“老大难”类别准确率只看全局想深入诊断模型必须看混淆矩阵。服装分类里最容易混淆的组合是T恤对衬衫、外套对套头衫。原因很直观这些类别在轮廓上几乎一样差异集中在袖长、领口、纹理这些局部细节上。FashionResNet在这两对的区分上明显好于AlexNet因为更深的网络能学到更高层的语义特征而浅层网络容易只抓住颜色和整体形状这些粗糙线索。一个实用技巧是把每个类别的准确率和召回率单独打出来。如果某个类别准确率低多数时候是该类和其他类共享过多视觉特征如果召回率低则可能是该类样本内部的多样性太大。这能帮你决定下一步是加数据、调结构还是干脆合并类别。7. 实战中的踩坑记录与工程化心得7.1 最容易翻车的几个细节第一个坑是输入尺寸不匹配。PyTorch的线性层要求输入维度固定FashionAlexNet里128 * 3 * 3这个数字是28×28经过三次2倍池化算出来的。改池化步长或卷积padding之前先手算一遍输出尺寸或者像我一样用AdaptiveAvgPool2d彻底避开手算。第二个坑是设备不一致。数据在GPU上、模型在CPU上或者模型的两个子模块分别在不同设备上都会报错或者极度缓慢。统一在训练函数开头把model.to(device)每个batch把images, labels也.to(device)形成肌肉记忆。第三个坑是忘记切换model.train()和model.eval()。Dropout和BatchNorm在两种模式下行为完全不同。训练完直接拿model去预测你会得到不稳定的结果。我的建议是把评估逻辑单独封装成函数进函数第一件事就是model.eval()。7.2 训练过程的监控与排查发现loss不下降或准确率卡住时先检查三件事。第一数据加载是不是坏了——打印几个batch的样本和标签确认图像内容和标签对得上。第二学习率是不是不合适——Adam配0.001基本不会出大问题但如果你换成了SGD0.01起步、0.1是常见区间太小会龟速收敛太大会直接发散。第三梯度有没有更新——偶尔出现loss.item()完全不变的情况多半是优化器没有传到正确的参数列表检查model.parameters()是否和optimizer绑定的是同一个model实例。还有一个容易忽略的点固定随机种子。torch.manual_seed(42)这行代码能让你的实验可复现。没有它同样的代码每次跑出不同结果你连“改动到底有没有用”都判断不了。7.3 从Demo到落地的迁移思路Fashion-MNIST跑通了不等于能直接处理现实服装图片。真实场景中图片尺寸、光照、背景五花八门。我的建议是分三步走先保持灰度输入把模型换成在ImageNet上预训练的ResNet18观察在真实数据上的表现再尝试把预处理改成“缩放中心裁剪”到固定尺寸最后引入更多数据增强来对抗真实世界的多样性。你可能会问为什么不用更大的模型对服装分类这类任务ResNet50和ResNet18的差距通常不到1个百分点但推理耗时和显存占用却成倍增加。工程上有个朴素的道理先用小模型跑通全流程再根据瓶颈决定要不要加大模型。多数时候数据质量和预处理比模型体积更能决定最终效果。回头看我自己的实验记录印象最深的不是哪个模型分数最高而是从AlexNet到ResNet这四代结构每一代的改进都能在损失曲线和混淆矩阵上看到实实在在的反馈。这种“看懂结构就能预判行为”的感觉是光调库体会不到的。建议大家拿到代码后别急着跑先动手把每个模型的输出尺寸手算一遍再把BatchNorm去掉跑一次看效果。折腾过一轮之后你对CNN的理解会比刷十遍教程都扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑