简介:基于AlexNet的FashionMNIST图像分类项目,面向深度学习入门的开发者和学生,使用PyTorch框架实现,解决服装图像识别任务中从数据准备到网络训练的完整流程问题。资源共26个文件,涵盖FashionMNIST数据集、Python训练与测试脚本、最佳模型权重、项目配置文件和说明文档,其中数据文件以idx格式存储,模型权重为pth格式,压缩包约260.82MB。已有102人学习,适合在图像分类场景中快速复现AlexNet。项目的代码按数据预处理、模型构建、训练与测试进行组织,明确拆分了训练集/验证集和测试集处理流程;AlexNet网络包含特征提取层、自适应平均池化层与分类器层,并使用Adam优化器和交叉熵损失函数,读者还可结合训练好的权重直接评估模型效果。整体目录清晰,从数据集到模型权重一一对应,可帮助理解经典卷积网络在FashionMNIST上的落地实践。
1. FashionMNIST只有28x28,AlexNet原版结构直接抄会出问题
FashionMNIST的样本是28×28单通道灰度图,AlexNet原论文却把输入设计成227×227的RGB三通道。直接拿官方模型结构套到这个数据集上,第一层卷积(11×11、stride=4)就会把特征图压到几乎没有空间信息,后续全连接层只能拿到高度聚合的残渣,训练精度会长期停留在0.1附近,相当于每次都在随机猜类别。这个项目表面上是「图像分类」入门,真正的门槛在于拆掉AlexNet对输入尺寸和通道数的假设,再在没有预训练权重的前提下让它稳定收敛。下面按数据加载、模型改造、训练参数、收敛检查四条线展开,全程用PyTorch实现,CPU也能完整跑通,只是每轮要慢一些。适合能看懂卷积层、池化层,但第一次把经典模型迁移到其他数据集上的人。
2. FashionMNIST数据集加载与28x28像素结构:从torchvision到DataLoader
2.1 用FashionMNIST接口一次加载训练集和测试集
PyTorch生态里加载FashionMNIST最直接的方式是走torchvision.datasets,它内部把下载、解压、标签映射都处理好了,代码不需要手动读二进制文件。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)) ]) train_dataset = datasets.FashionMNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.FashionMNIST( root='./data', train=False, download=True, transform=transform )root='./data'表示数据落在当前目录的data子目录下,训练集和测试集会分别存好,二次运行不再重复下载。train=True/False用来区分60,000张训练图与10,000张测试图。FashionMNIST的官方切分是固定的,不需要自己按比例拆。download=True表示缺失时自动下载。网络慢时可以把压缩包手动放到data/FashionMNIST/raw目录下,再执行一次加载代码它就能跳过下载。transform接收PIL图像并转成张量,后面每个batch实际拿到的形状是[B, 1, 28, 28],最后一维是通道数1,代表灰度图。
这套写法在换CIFAR、SVHN这类torchvision内置数据集时同样成立,区别只在datasets.xxx名称和归一化参数上。
2.2 归一化均值0.2860、标准差0.3530从哪来,为什么不能用ImageNet那组
很多人在迁移ImageNet模型时会顺手把Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))抄过来,这在单通道FashionMNIST上会出问题:通道数对不上,而且均值标准差完全不是这个分布算出来的。FashionMNIST官方向导里推荐的预计算统计值就是均值0.2860、标准差0.3530。这两个数是把训练集全部像素除以255后,在全体像素上统计得到的全局均值和全局标准差。
注意PyTorch的Normalize期望每个通道一个均值一个标准差,所以写法是(0.2860,)和(0.3530,),保留长度为1的元组。若忘记做归一化,模型仍能训练,但收敛明显迟钝。AlexNet没有BatchNorm层,准确说是原始结构没有任何归一化层,数据分布方差大时,靠近输入层的梯度很容易抖动,学习率稍大就直接发散。
一个自查办法是归一化后打印一个batch的分布:均值应接近0,标准差接近1。如果看到像素值还在0到1之间,说明ToTensor之后没有执行Normalize,是管道顺序写反了,Compose里必须把Normalize放在ToTensor之后。
2.3 28x28输入不缩放,模型侧适配比数据侧放大更划算
FashionMNIST每张图主体只占画面中央一小块,强行插值到227×227约等于凭空捏造像素。放大后边缘区域引入了大量无效插值信息,11×11的卷积核在原始28×28上还能捕捉一些局部纹理,放大后反而被稀释。更实际的问题是计算量:一幅图从28×28变成224×224,单通道数据量膨胀64倍,CPU训练成本直接翻好几番。
常见做法是保持输入28×28不变,把尺寸适配放到模型内部。AlexNet只有一个输入分支,改造点集中在第一层卷积的stride和最后的池化策略,这比在DataLoader里改图像尺寸更干净,也更容易复现。后面第3章会专门讲这个适配层怎么加。
2.4 DataLoader参数:batch、shuffle、num_workers三组配置
train_loader = DataLoader( train_dataset, batch_size=128, shuffle=True, num_workers=2 ) test_loader = DataLoader( test_dataset, batch_size=256, shuffle=False, num_workers=2 )batch_size:训练集用128,测试集用256。测试阶段不需要反向传播,可以开大一点减少IO次数。shuffle:训练集必须打乱,否则每个epoch内模型看到的类别顺序固定,梯度更新会带有周期性偏置;测试集不需要shuffle,保持原始顺序即可,不影响指标统计。num_workers:Linux下用2左右比较稳妥,Windows下多进程启动偶发卡死,推荐先设成0排查问题,确认代码跑通再调大。显存和内存紧张时优先把num_workers降到0,而不是降batch size。
标签是整数索引0到9,不是字符串。想看具体类别名,访问train_dataset.classes就能拿到按索引排好的列表。这个映射要在训练开始前确认一遍,后面分类头输出维度、混淆矩阵图例、分类报告都依赖它。
3. 用PyTorch复刻AlexNet:自适应池化解决尺寸冲突,分类头改为10类
3.1 原版227x227的假设在哪一层断掉
AlexNet原文针对ImageNet设计,输入被固定为227×227,整个网络的计算图都围绕这个尺寸展开。把28×28直接喂进去,第一层卷积的尺寸变化是:
H_out = floor((28 + 2*2 - 11) / 4) + 1 = 6
结构里第一层卷积后紧跟核大小为3、步长为2的MaxPool,6×6变成2×2。第二层卷积带padding=2、核5×5,2×2输入算完还是2×2,再池化一次变成1×1。到这一步,第三个卷积块还没有执行,特征图已经完全失去空间分辨率。
问题在于原版分类器期望的输入是6×6×256的展平向量,共9216维,而28×28输入走完卷积堆叠只剩1×1×256。强行跑会直接报维度不匹配。工程上不会去改全连接层的维度,因为4096→4096→1000这套结构是AlexNet容量和Dropout设计的一部分,改小反而丢失原本的表达能力。常用解法是在卷积输出和分类器之间插入AdaptiveAvgPool2d((6, 6)),把特征图统一池化到6×6,再展平进入分类器。
3.2 复刻模型代码:保留卷积块,接上自适应池化
import torch import torch.nn as nn class AlexNetFMNIST(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 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.avgpool = nn.AdaptiveAvgPool2d((6, 6)) 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 = self.avgpool(x) x = torch.flatten(x, 1) return self.classifier(x)这里每个参数都有讲究:Conv2d(1, 64, kernel_size=11, stride=4, padding=2)的in_channels=1是因为FashionMNIST是灰度图,不是RGB三通道;stride=4是AlexNet快速下采样的核心设计,虽然对28×28有点激进,但配合自适应池化仍然可用。inplace=True复用输入张量内存,省一点显存,在CPU训练时也能降低峰值占用。
AdaptiveAvgPool2d((6, 6))的作用不是传统意义上的下采样,而是把任意尺寸的输入映射到指定的输出尺寸。即使前面的卷积输出只有1×1,它也能通过平均池化得到6×6,这比手动计算池化核大小和步长要省事得多。torch.flatten(x, 1)从通道维开始展平,第0维batch保留,输出形状是[B, 256*6*6]。
3.3 各模块输出尺寸核对表
| 模块 | 输出形状(batch=1) |
|---|---|
| 输入 | [1, 1, 28, 28] |
| Conv1 + ReLU | [1, 64, 6, 6] |
| Pool1 | [1, 64, 2, 2] |
| Conv2 + ReLU | [1, 192, 2, 2] |
| Pool2 | [1, 192, 1, 1] |
| Conv3到Conv5 + ReLU | [1, 256, 1, 1] |
| MaxPool + AdaptiveAvgPool | [1, 256, 6, 6] |
| 展平 | [1, 9216] |
| 全连接分类器 | [1, 10] |
num_classes传入10,对应FashionMNIST的10个服饰类别。最后一个全连接层的输出不再接任何激活函数,直接作为logits返回。很多初学者在这里被误导多加一层Softmax,会在下一章损失函数部分解释原因。
3.4 Dropout在训练与评估下的行为差异
模型里两处Dropout(0.5)只在训练时随机置零,推理时必须关闭。PyTorch是通过model.train()和model.eval()两个模式切换来控制这个行为的,不是靠重新实例化模型。用验证集或测试集计算准确率时,漏写model.eval()会让Dropout继续生效,同一批数据每次预测结果都不同,验证集指标会像噪声一样跳来跳去,且系统性偏低。
提示:每个epoch开头先确认模式。训练循环之前写
model.train(),验证循环之前写model.eval(),这是图像分类任务里最隐蔽、但也最容易修的一个性能杀手。
4. 训练参数实操:优化器、学习率与损失函数在FashionMNIST上的配置
4.1 用Adam替换SGD+momentum,收敛更省心
AlexNet论文里用的是SGD,初始学习率0.01、动量0.9、权重衰减5e-4。那个配置是针对ImageNet这种百万级数据的,FashionMNIST只有6万张图,模型又相对深,直接用SGD调起来比较费劲。常见做法是改用Adam,默认学习率0.001,对学习率的敏感度低很多。
优化器改动不影响模型结构,只影响权重更新规则。Adam内部维护一阶动量和二阶动量,学习率自适应当前参数的梯度尺度。FashionMNIST这个数据集规模下,Adam能在30个epoch内达到一个合理精度,而SGD需要额外调学习率退火策略,对新手不友好。如果你后续要刷更高精度,再换回SGD配合CosineAnnealing不迟。
4.2 训练循环主体:30个epoch,每轮同时输出训练和验证指标
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = AlexNetFMNIST(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) def run_epoch(loader, model, criterion, optimizer=None, device='cpu'): is_train = optimizer is not None model.train() if is_train else model.eval() total_loss = 0.0 total_correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) loss = criterion(logits, labels) if is_train: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) total_correct += (logits.argmax(dim=1) == labels).sum().item() total += images.size(0) return total_loss / total, total_correct / total for epoch in range(1, 31): train_loss, train_acc = run_epoch( train_loader, model, criterion, optimizer, device ) val_loss, val_acc = run_epoch( test_loader, model, criterion, None, device ) print(f'epoch {epoch:02d} ' f'train_loss {train_loss:.4f} train_acc {train_acc:.3f} ' f'val_loss {val_loss:.4f} val_acc {val_acc:.3f}') if epoch % 10 == 0: torch.save(model.state_dict(), f'alexnet_fmnist_{epoch}.pth')optimizer is not None用来区分训练和验证两种模式,验证时只前向传播,不更新权重。logits.argmax(dim=1)取每行最大logits对应的索引作为预测类别,再与标签做逐元素比较。loss.item()取标量,否则会带着梯度图叠加,内存会越积越多。每10个epoch存一次模型,只保存state_dict(),不保存整个对象,加载时再实例化模型结构即可。
环境上有GPU就用GPU,没有就CPU硬跑。CPU单epoch大约两分钟左右,30个epoch约一小时,可接受。如果连得慢,优先把num_workers调大,再看batch size是否能增到256。
4.3 核心超参数速查表
| 参数 | 建议值 | 调参方向 |
|---|---|---|
| 优化器 | Adam | 收敛慢换SGD+momentum=0.9 |
| 学习率 | 0.001 | 训练震荡降到0.0005 |
| batch_size | 128 | 爆显存时降到64 |
| epochs | 30 | 验证集连续5轮不涨就早停 |
| weight_decay | 1e-4 | 过拟合时增大到5e-4 |
| Dropout | 0.5 | 深度过拟合时增大到0.7 |
weight_decay在PyTorch的Adam里对应L2正则化,能抑制全连接层4096维参数过拟合。FashionMNIST空间分辨率低,抗过拟合的关键其实是Dropout和全连接层的容量控制,数据增强反而没那么重要。验证集准确率在25个epoch后不再上升,可以直接用最后保存的模型,不必等到30轮。
4.4 CrossEntropyLoss直接吃logits,最后一层别加Softmax
PyTorch的nn.CrossEntropyLoss内部已经组合了LogSoftmax和负对数似然损失。如果模型最后一层输出原始logits,直接传给它即可;如果自己在全连接层后手动加nn.Softmax(dim=1),会计算两次softmax,数值上破坏梯度信息,训练初期loss下降缓慢,后期精度也会受影响。
只在推理时如果需要概率输出,可以单独调用torch.softmax(logits, dim=1),训练过程中完全不涉及。类别数10与num_classes一致,标签是0到9的整数索引,CrossEntropyLoss内部会自动做one-hot形式的计算,不需要手动转独热编码。
5. 收敛检查:先在1个batch上通链路,再按三个故障位排错
5.1 用单个batch验证模型输出形状
模型写完先别急着跑30个epoch,取一个batch做链路测试,确认输入输出维度、设备、损失函数三件事全部正确。这一步能节省大量排查时间。
model.eval() sample_loader = DataLoader(test_dataset, batch_size=4, shuffle=True) images, labels = next(iter(sample_loader)) print('images:', images.shape, 'labels:', labels.shape) with torch.no_grad(): logits = model(images.to(device)) print('logits:', logits.shape) print('pred:', logits.argmax(dim=1))正常输出是images: [4, 1, 28, 28]、labels: [4]、logits: [4, 10]。next(iter(sample_loader))只取第一个batch,不遍历整个数据集。模型在验证模式下跑,避免Dropout带来随机性。如果logits最后一维不是10,去检查分类器的num_classes;如果第2维不是1,去检查Conv2d的in_channels。
设备不一致会在images.to(device)之后报错。CPU和GPU混跑时,模型和数据必须都在同一个device上,否则RuntimeError提示信息里会包含cpu和cuda:0字样,一眼能认出来。
5.2 三个高发故障位与排查脚本
第一个故障位是loss不降。训练5个epoch后train_loss仍停留在0.5以上,先打印梯度范数确认学习率是否过大:
def grad_norm(model): total = 0.0 for p in model.parameters(): if p.grad is not None: total += p.grad.norm().item() ** 2 return total ** 0.5 # 在 loss.backward() 之后 optimizer.step() 之前调用 # print(grad_norm(model))梯度范数超过100说明梯度爆炸,把学习率降到0.0005或0.0001。如果范数一直小于0.01,说明梯度消失或学习率过小,这时候需要检查是不是在训练循环里误用了model.eval(),导致Dropout关闭但BatchNorm也不更新,整体前向计算被固定在初始状态。
第二个故障位是训练准确率高、验证准确率只有0.1左右。这种症状基本是标签错位。打印labels的形状和取值范围,确认它是0到9的整数。如果标签是从1开始编号的10类,分类器输出10维,损失计算不会报错,但所有的预测都会偏向某几类,准确率上不去。
第三个故障位是训练极其缓慢。FashionMNIST的图很小,瓶颈不在计算而在数据IO和线程调度。先确认num_workers设置合理,再到任务管理器看CPU占用率。CPU占用率长期低于30%,说明数据加载在等待,可以把num_workers调大;如果内存不足导致交换,则要减小num_workers。GPU环境下给DataLoader加pin_memory=True可以进一步缩短数据搬运时间,CPU环境下这个参数无意义,保持默认即可。
最后一个验证技巧是把run_epoch训练循环里的日志打印频率调成每个batch一次,观察前几个batch的loss是否在下降。前10个batchloss从2.3降到2.0左右,说明链路和优化器都是正常的,剩下的就是等它慢慢收敛。把这条链路检查脚本固定下来,换数据集、换网络、换设备时先跑一遍,能省掉至少半个晚上的盲调时间。
本文还有配套的精品资源,点击获取