PyTorch实现Fashion-MNIST分类:CNN模型训练与调参全流程

发布时间:2026/9/28 16:26:12
PyTorch实现Fashion-MNIST分类:CNN模型训练与调参全流程 简介面向机器学习和神经网络入门者围绕 Fashion-MNIST 时装图像分类任务提供可直接运行的 Python 实现与配套说明文档。该数据集包含 T 恤、连衣裙、运动鞋等 10 个类别的 28x28 灰度衣物图片其中每类有 6000 张训练图和 1000 张测试图非常适合用来练习完整的图像分类建模流程。压缩包共 2 个文件包含 1 个 .py 主程序脚本和 1 个 .doc 说明文档整体仅 559KB学生可对照代码快速完成课程作业或动手实践。项目完整演示了数据加载与归一化、标签 one-hot 编码随后构建含卷积层、池化层和全连接层的神经网络使用交叉熵损失与 Adam 优化器训练并通过测试集准确率评估模型最后还涉及学习率、批量大小等超参数的调优思路。目前已有 297 人次学习浏览代码注释与文档说明能帮助读者深入理解神经网络工作原理并能迁移到其他图像识别场景中。1. 这份代码解决什么Fashion 数据分类从零到能跑的最短路径这份 homework6 压缩包里只有一个 main.py 和一份 doc 文档但里面装的是一条完整的 Fashion 数据分类流水线数据集加载、归一化、CNN 网络搭建、训练、评估、模型保存全部串起来了。先说结论这个任务看着比手写数字识别难不了多少实际上一堆人第一次跑都栽在同一个地方——不是神经网络搭不出来而是数据预处理和张量 shape 三处连环翻车。这套代码适合两类人一类是正在做课程作业、想找一份能跑的 baseline 参考的学生另一类是已经跑过 MNIST、想换 Fashion-MNIST 练手并搞清楚卷积网络调参门道的开发者。下面按我拆解的顺序从数据预处理一路写到避坑和调优。2. 数据预处理把 28x28 灰度图变成能喂给网络的张量2.1 数据加载torchvision 一行拿到训练集和测试集Fashion-MNIST 是 Zalando 公司做的 MNIST 替代品10 个类别全是衣裤鞋包每张图 28x28 灰度训练集 6 万张、测试集 1 万张。用 PyTorch 的话torchvision.datasets 已经把下载和解压都封装好了不需要自己写下载逻辑。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集root 指定存放目录trainTrue 取 60000 张 train_dataset datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor() ) # 测试集trainFalse 取 10000 张 test_dataset datasets.FashionMNIST( root./data, trainFalse, downloadTrue, transformtransforms.ToTensor() ) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse)先解释几个关键点。root是数据集落地目录程序第一次运行会在./data下建FashionMNIST/raw子目录把四个.gz压缩文件放进去所以你传给程序一个空目录就行没必要自己提前下载。trainTrue拿的是 6 万张训练图trainFalse拿的是 1 万张测试图这个布尔值对应的是原始数据集的文件名train-images-idx3-ubyte.gz和t10k-images-idx3-ubyte.gz不是传统意义上的训练集/验证集划分。batch_size128是常见默认值显存 4G 以上的机器都跑得动shuffleTrue只在训练集上用因为每个 epoch 需要重新打乱样本顺序而测试集顺序无所谓保持shuffleFalse反而方便逐批统计正确数。这里还有一个新手容易忽略的点transformtransforms.ToTensor()会在加载途中把 PIL 图像转成(1, 28, 28)的浮点张量。如果不写 transform拿到的就是 PIL.Image 对象直接丢进DataLoader会在第一个 batch 报类型错误。后面 2.2 小节会在这个 transform 上继续叠加归一化。Fashion-MNIST 的标签不是字符串是 0 到 9 的整数索引需要和类别名对应起来。常见做法是在训练前打印一遍映射关系确认尤其注意 6 号类别是 Shirt衬衫它和 0 号 T-shirt、2 号 Pullover 视觉上很接近是后期分类错误的主要来源之一。索引英文类别中文含义0T-shirt/topT 恤 / 上衣1Trouser裤子2Pullover套头衫3Dress连衣裙4Coat外套5Sandal凉鞋6Shirt衬衫7Sneaker运动鞋8Bag包9Ankle boot短靴这份映射表建议直接抄进代码注释里后面分析错误样本时predicted 6这种结果对应的是哪一类扫一眼表就知道不用去翻原论文。2.2 归一化ToTensor 和 Normalize 合并成一条流水线刚用 ToTensor 转出来的张量像素值范围在[0, 1]但直接这样喂给网络也能训只是收敛会慢一点。Fashion-MNIST 的像素均值约等于 0.286标准差约等于 0.353常见做法是拿这两个统计量做标准化让输入分布接近标准正态。transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.286,), (0.353,)) ]) train_dataset datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransform )transforms.Normalize做的事情是(x - mean) / std这里mean和std必须传元组因为 PyTorch 按通道维度对齐参数。灰度图只有 1 个通道所以写成(0.286,)而不是0.286写错会在运行时抛参数数量不匹配的异常。用这两个值归一化之后输入张量的分布基本落在 -1 到 1 之间卷积层的权重初始化后面 6.1 会提在这个范围内更容易生效。还有一个细节Compose里的顺序不能反必须先ToTensor再Normalize。ToTensor把 PIL 图像从[0, 255]的 uint8 拉到[0, 1]的 float32Normalize在这个基础上做减均值除标准差。反过来的话PIL 图像根本没有__sub__和__truediv__的向量化运算Transform 协议会直接报错。做完这一步每个 batch 的张量形状是(128, 1, 28, 28)。128是 batch 大小1是灰度通道28x28是宽高。后面所有卷积层的输入输出维度都围绕这个形状展开。提示如果手里没有预先统计的均值和标准差也可以直接用transforms.Normalize((0.5,), (0.5,))它把像素从 [0,1] 映射到 [-1,1]训练效果略差一点但不会崩。真正不能做的是不归一化就直接训那样梯度更新很容易在浅层震荡。3. 模型构建卷积、池化、全连接到底怎么拼3.1 为什么用卷积而不是把 784 个像素直接铺进全连接28x28 的图展平后是 784 个数值。理论上一个三层的全连接网络就能拟合参数数量却完全不是一个量级单层隐藏层 128 个神经元784*128 128约等于 10 万个可训练参数而一个3x3卷积层输入 1 通道输出 32 通道3*3*1*32 32只有 320 个参数。参数量少了两个数量级还顺带把邻近像素相关性这个空间结构用权重共享的方式编码进去了。卷积层的核心逻辑是滑动窗口一个3x3的卷积核在 28x28 的图像上扫一遍每次只关注周围 9 个像素的局部特征比如边缘、纹理、角点。堆两层卷积之后第二层卷积的每个神经元能看到第一层输出上的一个 3x3 区域等效于原图上更大的感受野。池化层跟在卷积后面2x2的最大值池化把特征图宽高各缩一半保留每个小区域里最强的响应顺带把平移带来的微小扰动抹掉。严格来说这个结构属于前馈神经网络FNN的一种——数据从输入层流向输出层没有循环连接。CNN 只是在前馈结构里加上了卷积和池化这两种特殊层。Fashion-MNIST 是灰度图不需要处理 RGB 三通道所以第一层卷积的in_channels1。3.2 一个能跑到 92% 的 CNN 结构两个卷积块加两个全连接下面这份结构是 Fashion-MNIST 最常用的 baseline两个卷积块后面接两个全连接层最后一个全连接输出 10 个类别的 logits。import torch.nn as nn class FashionCNN(nn.Module): def __init__(self, num_classes10): super(FashionCNN, self).__init__() # 第一个卷积块1 通道 - 32 个特征图 self.conv1 nn.Conv2d( in_channels1, out_channels32, kernel_size3, padding1 ) self.pool1 nn.MaxPool2d(kernel_size2, stride2) # 第二个卷积块32 通道 - 64 个特征图 self.conv2 nn.Conv2d( in_channels32, out_channels64, kernel_size3, padding1 ) self.pool2 nn.MaxPool2d(kernel_size2, stride2) # 全连接部分 self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, num_classes) self.relu nn.ReLU() self.dropout nn.Dropout(p0.25) def forward(self, x): # 28x28 - 14x14 x self.pool1(self.relu(self.conv1(x))) # 14x14 - 7x7 x self.pool2(self.relu(self.conv2(x))) # 展平保留 batch 维度 x x.view(x.size(0), -1) x self.dropout(self.relu(self.fc1(x))) x self.fc2(x) return x每层输出尺寸推一遍输入(128, 1, 28, 28)conv1 的kernel_size3, padding1保持宽高不变输出(128, 32, 28, 28)pool1 之后变成(128, 32, 14, 14)。conv2 同理pool2 后是(128, 64, 7, 7)。全连接的输入特征数就是64*7*73136这就是fc1 nn.Linear(64 * 7 * 7, 128)里那个表达式的来源。如果改动卷积核大小或池化步长这个数要重新算第 5.3 小节专门讲这种情况。层名输出形状作用conv1 relu(128, 32, 28, 28)提取低级边缘和纹理pool1(128, 32, 14, 14)降采样扩大感受野conv2 relu(128, 64, 14, 14)组合局部特征为语义特征pool2(128, 64, 7, 7)进一步降采样fc1 relu(128, 128)把空间特征映射到隐向量fc2(128, 10)输出每类得分dropout放在全连接层激活函数后面上面代码里只对 fc1 的输出做了 Dropout。不要对 conv 层的输出做 Dropout那种做法通常需要配合 spatial dropout 才有意义初学者直接把标准 Dropout 用在卷积输出上反而会损失空间信息。p0.25是个保守数值训练集充足时可以保持第 6 章会说什么时候该往上调。写模型时有一个习惯值得一开始就建立在forward里临时打印 shape 做自检。很多维度错误在第一个 batch 前向时就能暴露不用等到 loss.backward() 报错。4. 训练与评估交叉熵加 Adam一个循环跑完4.1 损失函数和优化器为什么默认选 CrossEntropyLoss 和 AdamFashion-MNIST 是多分类任务PyTorch 的nn.CrossEntropyLoss把 Softmax 和交叉熵合并算好了输入给它的是网络最后一层原始输出logits不是过了 Softmax 的概率。这点很重要nn.CrossEntropyLoss内部会对 logits 做一次 Softmax再用 one-hot 形式的标签算损失。如果在模型 forward 里提前手动torch.softmax(x, dim1)损失数值会变得不正常因为对已经是概率的输出再做一次 Softmax等于把分布又压了一轮。标签的形式也不需要手动转 one-hot。文档里经常提到把标签转换为 one-hot 编码但在 PyTorch 的分类任务里直接用整数标签就行nn.CrossEntropyLoss内部会做索引匹配。手动做 one-hot 反而要多处理argmax还原的步骤多一层出错风险。优化器选 Adam学习率默认1e-3这是中小型视觉任务的标准起点。Adam 对学习率的敏感度远低于 SGD不需要手动设计学习率退火策略就能稳定收敛初学阶段用它是性价比最高的选择。SGD 调好之后上限可能更高但需要搭配 momentum 和学习率调度等 Adam 跑通基线再换不迟。批量大小 128、训练 10 个 epoch单张消费级显卡一分钟左右一轮整个训练过程十分钟内能完成。4.2 训练循环前向、反向、更新三个动作的完整写法import torch import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model FashionCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 correct 0 total 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) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) avg_loss total_loss / total accuracy correct / total return avg_loss, accuracy for epoch in range(10): train_loss, train_acc train_one_epoch( model, train_loader, optimizer, criterion, device ) print(fEpoch {epoch1}/10 | loss: {train_loss:.4f} | acc: {train_acc:.4f})代码里的model.train()必须在每个 epoch 开头调用。它切换的是 BatchNorm 和 Dropout 的行为Dropout 在训练模式下随机丢神经元在评估模式下什么都不做。如果训练完直接评估忘记切回model.eval()Dropout 会继续丢弃神经元测试准确率会被随机性拉低 2 到 5 个百分点。optimizer.zero_grad()放在每个 batch 的前向之前。PyTorch 的梯度默认是累积的不手动清零的话第二次 backward 的梯度会叠加到第一次的梯度上权重更新就会偏离正确方向。这是个特别隐蔽的坑初学阶段很难从日志上判断出来因为损失看起来还在降只是降到某个平台就再也上不去了。torch.max(outputs, 1)返回两个张量第二个1是指定维度意思是在类别维度上取最大值的位置索引。predicted就是模型预测的类别编号和labels做比较得到布尔张量.sum().item()转成正确数量。这里的total用labels.size(0)而不是整个 loader 的长度是为了防止最后一个 batch 不足 128 时统计出错。评估阶段几乎同样的代码但必须包在torch.no_grad()里def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return correct / total test_acc evaluate(model, test_loader, device) print(fTest accuracy: {test_acc:.4f})torch.no_grad()告诉 PyTorch 不需要在反向传播阶段保留中间变量的梯度图。评估阶段只做前向推理不更新权重保留梯度图既占显存又额外耗计算。不写这个上下文管理器测试阶段显卡显存可能会比训练时还要高小显存显卡很容易在这里 Out of Memory。4.3 模型保存与加载别只在内存里留模型训练结束后模型还停留在内存里脚本一退出就全没了。用state_dict保存有两种常见粒度只存权重或者连优化器状态一起存。推荐至少存一份权重因为后面要加载做推理或者继续训练只需要这一份文件。torch.save(model.state_dict(), fashion_cnn.pt)model FashionCNN() model.load_state_dict(torch.load(fashion_cnn.pt)) model.to(device) model.eval()加载后立刻补一行model.eval()这是一个最容易踩的后续推理坑。load_state_dict只恢复权重不恢复运行状态模型默认还是在 train 模式Dropout 依然生效。如果加载模型是为了部署或者测试漏掉这一行预测结果每次跑都不一样正好对应那句玄学模型明明加载成功了输出结果却每次都不稳定。继续训练的场景下保存优化器状态能避免学习率等参数被重置torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch }, checkpoint.pt)这个 checkpoint 文件相当于后悔药。训练到第 8 个 epoch 发现过拟合想退回第 6 个 epoch 的权重重新调参没有 checkpoint 就只能从头重跑。训练时间长的任务每隔两个 epoch 存一份磁盘开销几乎可以忽略。5. 常见问题与避坑五个让作业翻车的细节下面五条都是我在类似代码包里见过最多的问题按现象、原因、解决的顺序逐个拆。每一条都对应真实运行时报错或训练指标异常不是理论推演。5.1 数据集下载失败root 目录下只有半截文件现象第一次运行downloadTrue程序卡在进度条或者直接抛ConnectionError命令行提示无法连接。第二次再跑报错说Dataset already downloaded或者文件损坏。原因PyTorch 会先读raw目录下的文件名如果上次下载中断留下了.gz文件的半截副本它还以为是完整的继续解压就会报格式错误。还有一种常见情况是校园网限制外网访问torchvision 访问下载源被墙在中间环节。解决删除root/FashionMNIST/raw目录下所有文件重新跑一次下载或者手动找到四个.gz文件train-images、train-labels、t10k-images、t10k-labels下载后按原始文件名放进raw目录再跑代码。下载源连不上时先找个网络稳定的环境把文件下载好再拷到目标机器比反复重试省时间。注意不要自己把.gz解压出.idx3-ubyte文件再放进raw目录。torchvision 的FashionMNIST类只认.gz压缩包它内部有自己的解压流程。5.2 Normalize 参数填错损失卡在 2.3 附近准确率稳定在 10%现象训练 loss 从第一个 epoch 开始就一直在 2.3026 附近徘徊不降反升准确率始终卡在 10%——多类别随机猜测的正确率。原因NN.CrossEntropyLoss对于 10 分类任务随机猜测的熵值就是ln(10)2.3026。loss 卡在这个数值意味着模型一直输出均匀分布完全没有学到任何区分性特征。最常见的诱发因素是把Normalize的均值和标准差填成三通道 ImageNet 的数值或者把元组(0.286,)写成了裸数字0.286导致归一化后的输入分布严重偏离预期。解决灰度图统一用transforms.Normalize((0.286,), (0.353,))或者干脆只保留ToTensor()不归一化训练照样能收敛到 90% 左右。先排除归一化问题再考虑改网络结构是排查这类指标异常的正确顺序。5.3 展平维度不匹配mat1 and mat2 shapes cannot be multiplied现象前几个 batch 正常跑到fc1那行报mat1 and mat2 shapes cannot be multiplied (128x1960 and 3136x128)。1960 是64*5*5之类的结果。原因池化层输出尺寸比预期小了。最常见是改动了卷积核大小或池化步长比如把MaxPool2d(kernel_size2, stride2)改成了kernel_size3, stride2特征图维度不再等于 7x7而fc1的输入维度还写死为64*7*7。解决先把网络要用的每层输出尺寸按公式算一遍。单维度公式是(H - kernel_size 2*padding) / stride 1两层卷积和两层池化按顺序叠算。另一种更快的做法是在forward里加一行print(x.shape)打印展平前的实际维度把看到的数值填进nn.Linear的输入参数。从那以后我养成一个习惯每改一次网络结构先跑一个 batch 打印 shape确认了再开完整训练。5.4 训练 Loss 降了、测试 Acc 不涨过拟合的三个信号现象训练损失一路降到 0.1 左右训练准确率接近 99%但测试准确率停在 87% 上不去甚至随着 epoch 增加出现轻微下降。原因这是典型的过拟合信号。Fashion-MNIST 虽然只有 28x28但衣服类别之间的视觉差异比手写数字更微妙——衬衫和 T 恤在外观上高度重叠模型在训练集上死记了这些样本的细节测试集上一遇到变体就翻车。网络容量越大、训练轮数越多这个 gap 越大。解决三个手段按顺序尝试。第一把Dropout的p0.25调到0.5强迫全连接层不能过度依赖单一路径。第二优化器加weight_decay参数Adam 可以设weight_decay1e-4相当于给大权重加惩罚。第三在第 6.1 小节的数据增强里加随机水平翻转增加训练样本的多样性。测试准确率从 87% 往 92% 走主要靠第三个手段。5.5 训练速度奇慢或者报 device mismatch设备没有统一起点现象模型明明在 GPU 上跑但每个 batch 前向都报Expected all tensors to be on the same device或者训练速度比预期慢十倍。原因最常见的版本是数据在 CPU、模型在 GPUDataLoader拿出来的 batch 没有调用.to(device)只有模型权重在 GPU 上。还有一种是代码里写死了model.cuda()但本机只有 CPU虽然不一定报错训练速度却回到了 CPU 时代。解决全程只用一个device变量模型加载和数据搬运都从它取值。上面 4.2 的代码就是这种写法torch.device(cuda if torch.cuda.is_available() else cpu)训练循环里对images和labels各加一行.to(device)。在 Colab 这种环境切换 GPU 后这种写法不用改代码直接生效。6. 再进一步四个把准确率从 90% 提到 93% 的改动6.1 数据增强、Dropout、学习率衰减、权重初始化基线模型在这个数据集上的测试准确率大约 90% 到 92%再往上走靠的是几个小改动的叠加。第一数据增强。Fashion-MNIST 的物体大多是垂直摆放的衣物随机水平翻转不会产生语义错误加这一行就能让训练样本量等效翻倍transform transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.286,), (0.353,)) ])RandomHorizontalFlip传给训练集测试集的 transform 不要加否则每次评估的输入都不一样指标波动变大。第二把 Dropout 的p从 0.25 调到 0.5只动全连接层。卷积层本身参数量小正则化需求弱Dropout 调高主要压的是fc1那 128 个神经元的过拟合。第三学习率在第 5 个 epoch 和 8 个 epoch 各衰减一次scheduler torch.optim.lr_scheduler.StepLR( optimizer, step_size5, gamma0.1 ) for epoch in range(10): # ...训练代码... scheduler.step()step_size5表示每 5 个 epoch 把学习率乘以0.1从 1e-3 降到 1e-4 再到 1e-5。训练后期学习率调小权重更新步长变短能沿着损失平面滑进更平稳的局部极小点。最后是权重初始化PyTorch 的nn.Conv2d和nn.Linear默认初始化对这个小网络已经足够但如果换了大网络常见做法是用 kaiming 初始化配合 ReLUdef init_weights(m): if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) model.apply(init_weights)这四个改动按顺序叠加前两个在数据加载和模型定义里改一行后两个在训练循环里加几行总代码量不超过二十行。改完再训十个 epoch测试准确率大概率落在 92% 到 94% 区间。调参到这里基本就到头了剩下的差距往往来自模型结构本身的表达上限换 ResNet 或者加注意力机制是一条新路但那就超出了这份作业代码的范畴。从那以后我每写完一个网络都会在forward里先加一行 shape 打印和上面 5.3 的做法一样先拿一个 batch 跑前向确认维度没错再开长训练。这个习惯帮我省掉了至少十次训练到一半报错重开的折腾。希望帮到你。本文还有配套的精品资源点击获取