FashionMNIST 这个数据集,在圈子里基本属于“人手一份”的入门标配。它的定位很明确:10 个类别的服饰灰度图,60000 张训练 + 10000 张测试,每张 28×28,格式与手写数字数据集完全一致。它能干的事情是让你在几分钟内跑通一条完整的图像分类流水线——下载、加载、归一化、训练、评估、看混淆矩阵,全程不需要 GPU,也不需要标注团队。它解决的问题也很具体:手写数字那套数据太“干净”了,一个两层卷积就能刷到 99% 以上,你根本看不出自己改的网络结构、损失函数、优化器到底有没有用;而 FashionMNIST 的难度刚好卡在一个能拉开差距的区间,改一个 trick 通常能看到 0.5 到 1 个点的变化,反馈足够灵敏。适合谁来参考:刚学卷积网络想找个能跑通的练手项目的、要快速验证某个结构改动是否值得深挖的、给学生或新人做教学演示的、以及需要一套稳定 baseline 来做对比实验的。如果你只是想找一份“下载 + 加载 + 跑通”的可抄作业,下面这些内容基本能覆盖你 90% 的疑问。
1. 为什么我还在用这套数据做基准:定位、能力边界与选型逻辑
1.1 换掉的不只是图片,是模型的区分度
很多人第一次接触它是因为“手写数字刷腻了”。这个直觉是对的,但原因值得说清楚。手写数字的类内差异极小——同一个人写的 3 和另一个人写的 3,笔画结构高度相似;类间差异极大——0 和 1 几乎不会有歧义。结果就是任务的决策边界很“平”,一个线性分类器加一点手工特征就能到 92%,两层卷积直接 99% 往上。当所有模型都在 99% 附近挤成一团时,你根本没有信号判断自己的改动是好是坏,实验就变成了自欺欺人。
FashionMNIST 的设计者做的事情其实很克制:保持图像尺寸、样本数量、标签格式、甚至文件二进制结构完全不变,只把内容从数字换成服饰。这个“只换内容不换接口”的选择非常关键——你原来处理手写数字的代码,改一个类名就能直接跑。但它把难度抬到了一个新层次:类内差异被显著放大,一件 T 恤可以是修身款、oversize、带印花、带拉链;类间差异被压缩,T 恤、套头衫、衬衫、外套这四类,在 28×28 的灰度图上经常只剩一个模糊的轮廓加几条褶皱。这就逼着模型去学真正的形状特征,而不是记住几个固定模式。
我实测下来的经验区间是这样的:逻辑回归这类线性模型大概 84%–86%,一个不加任何技巧的三层卷积网络 91%–92%,加上批归一化和合理的数据增强能到 93%–94%,再往上每 0.5 个点都要付出明显代价。这个梯度分布非常“健康”,意味着你的每一次改动都有可观测的反馈。
1.2 它的能力边界,最好在动手之前就认清
用错数据集比不用更浪费时间。这套数据的局限我总结成四条,都是踩过之后才认的。
第一是分辨率。28×28 意味着总像素才 784 个,一件衣服的袖口、领型、纽扣这些真正有判别力的细节,基本都被下采样抹平了。你想做细粒度分类——比如区分不同款式的运动鞋——在这个分辨率下是没有出路的,硬做只会得到一堆噪声。第二是灰度。颜色信息被完全丢弃,一条红裙子和一条黑裙子在模型眼里一模一样。如果你的任务里颜色是强特征(比如区分交通标志、区分水果成熟度),这套数据给不了你任何参考。第三是构图。所有样本都是单物体居中、纯黑背景、无遮挡、无旋转、无透视变化。真实场景里的图像不是这样的,背景杂物、光照、拍摄角度都会带来额外挑战。第四是类别分布。10 个类别严格均衡,每类 6000 张训练样本。而真实业务里的类别分布往往严重长尾,头部类占九成、尾部类只有几十张。在这套数据上训练的模型,换到长尾场景会立刻露馅。
所以我的定位判断很明确:它是验证流程和结构的工具,不是证明落地能力的工具。你要跑通训练管线、要对比两个网络结构、要教别人什么是过拟合,用它;你要证明模型能解决实际业务问题,别用它。
1.3 一次下载到底拿到了什么:文件清单与体积核对
自动下载这件事,很多人是“点一下就不管了”,出了问题完全不知道从哪查。我建议你至少清楚一次完整下载会落地哪些文件。
| 文件名 | 内容 | 解压后大小(字节) | 说明 |
|---|---|---|---|
| train-images-idx3-ubyte | 训练图像 | 47,040,016 | 60000×784 + 16 字节头 |
| train-labels-idx1-ubyte | 训练标签 | 60,008 | 60000 + 8 字节头 |
| t10k-images-idx3-ubyte | 测试图像 | 7,840,016 | 10000×784 + 16 字节头 |
| t10k-labels-idx1-ubyte | 测试标签 | 10,008 | 10000 + 8 字节头 |
那几个“+16”“+8”不是随便加的,是 idx 格式的头部开销。图像文件的头 16 字节结构是:4 字节魔数(图像固定为 0x00000803)、4 字节维度数(值为 3)、4 字节行数、4 字节列数,之后才是连续的像素数据。标签文件的头 8 字节是:4 字节魔数(标签固定为 0x00000801)、4 字节样本数。知道这个结构之后,你就能自己写解析器,也能一眼看出文件是不是被截断了——用文件大小核对完整性,比看下载进度条靠谱得多。
注意:压缩包的大小和上面解压后的大小是两回事,压缩包会小很多(图像压缩包大概 26MB 出头)。核对完整性时一定看清你手上的是压缩包还是解压后的文件,用错基准去比对只会把自己绕进去。
2. 把数据摸透:类别、编码与那些反直觉的细节
2.1 十个类别与标签的顺序,别自己重排
标签就是 0 到 9 的整数,顺序是固定的,模型的输出向量第几位就对应第几类。这个顺序一旦你自己重排过,后面所有可视化、混淆矩阵、误差分析全部错位,而且很难发现。
| 标签 | 英文名 | 中文含义 | 标签 | 英文名 | 中文含义 |
|---|---|---|---|---|---|
| 0 | T-shirt/top | T 恤/上衣 | 5 | Sandal | 凉鞋 |
| 1 | Trouser | 裤子 | 6 | Shirt | 衬衫 |
| 2 | Pullover | 套头衫 | 7 | Sneaker | 运动鞋 |
| 3 | Dress | 连衣裙 | 8 | Bag | 包 |
| 4 | Coat | 外套 | 9 | Ankle boot | 短靴 |
我习惯在代码里把这张表写成一个常量列表,任何需要显示标签的地方都从它取,绝不手写字符串。这个习惯救过我很多次——尤其是在做批量可视化和生成报告的时候。
2.2 最容易混淆的几组,和它们背后的现实原因
如果你只看准确率,会错过最重要的信息。真正该看的是混淆矩阵,而这里面有规律可循。
第一组是上衣四兄弟:T 恤(0)、套头衫(2)、外套(4)、衬衫(6)。这四类占了全部错误的一大半。原因很朴素:在 28×28 的灰度图上,一件修身衬衫和一件薄外套的区别,可能就体现在领口那一两个像素的走向上。模型看到的是“一个 torso 形状 + 两条袖子”,它没有足够的信息去判断这块布是棉的还是羊毛的、有没有开襟。
第二组是鞋类三兄弟:凉鞋(5)、运动鞋(7)、短靴(9)。这三类的混淆主要发生在轮廓上——从正面或侧面看,一双低帮运动鞋和一双短靴的剪影非常接近。有意思的是,凉鞋很少和短靴混,因为凉鞋的镂空结构在灰度图上会形成明显的暗区,反而成了辨识特征。
第三组是裤子(1)和连衣裙(3)。这两类在全身长度上接近,主要差别在腰部是否有分割。图像太小时这个分割经常消失,于是裤子被误判成连衣裙。
这个规律对调参有直接指导意义。如果你发现模型整体 92%,但混淆矩阵显示上衣四类之间互相错得一塌糊涂,那你去加正则、调学习率意义都不大,应该考虑的是如何在有限分辨率下强化局部结构特征——比如加深网络感受野、引入注意力、或者在数据增强上做文章。反过来,如果错误均匀散布在所有类别上,那更可能是欠拟合或者训练不充分。
2.3 归一化参数:千万别直接抄手写数字那一套
这是新手最常犯、也最隐蔽的错误。网上大量教程里写的Normalize((0.1307,), (0.3081,))是手写数字数据集的统计值,直接套到服饰数据上是不对的。
两个数据集的内容分布差别很大:手写数字的笔画细、留白多,整体偏暗;服饰图像的主体占据了画面的大部分面积,整体明显偏亮。社区里广泛引用的服饰数据统计值是均值约 0.2860、标准差约 0.3530(单通道)。用错的后果不是报错,而是准确率莫名其妙低一两个点、loss 曲线抖得厉害,你会在网络结构上瞎找原因,最后发现是归一化背锅。
我的做法是自己算一遍再写死,这样心里有底,也顺便完成了一次数据体检:
import torch from torchvision import datasets, transforms # 第一步:只做 ToTensor,把 0-255 的 uint8 变成 0-1 的 float raw = datasets.FashionMNIST( root="./data", train=True, download=True, transform=transforms.ToTensor() ) loader = torch.utils.data.DataLoader(raw, batch_size=1000, shuffle=False, num_workers=0) total, total_sq, count = 0.0, 0.0, 0 for imgs, _ in loader: total += imgs.sum().item() total_sq += (imgs ** 2).sum().item() count += imgs.numel() mean = total / count std = (total_sq / count - mean ** 2) ** 0.5 print(f"mean={mean:.4f}, std={std:.4f}") # 大致落在 0.2860 / 0.3530 附近这段代码跑一遍大概十几秒,跑完你就有了自己数据上的真实统计值。为什么要做归一化?说白了就是让输入分布接近零均值、单位方差,这样各层的梯度尺度更一致,学习率更好调,收敛也更稳。不做归一化也能跑,但你会被迫用很小的学习率,训练轮数蹭蹭往上涨。
2.4 数据本身的瑕疵:别死磕那几个点上不去
有个事实得提前接受:这套数据里存在标注本身就有歧义的样本。有些图你放大看半天也说不清是衬衫还是外套,人和人之间都会有分歧。测试集里同样有这类噪声样本。这意味着模型的上限是被数据本身限制住的,公开的高分结果大多在 94%–96% 这个区间(不加外部数据、不做集成)。
我见过有人为了从 94.2% 刷到 94.8% 连续调了两周参,最后发现是把测试集当验证集反复调,模型已经悄悄过拟合测试集了。这个坑后面第 5 节会细说。另外还有少量样本几乎是纯黑或者只剩一个模糊轮廓,这类图在训练时会贡献很大的梯度噪声,如果你的 loss 曲线时不时出现尖峰,很可能就是它们造成的。不用特意去清洗,量太小,影响有限,但知道有这回事能让你少怀疑自己。
3. 自动下载的几条路径与它们的取舍
3.1 torchvision 路线:一行 download=True 背后发生了什么
这是最常用的方式,也是我推荐给新人的默认选择。核心代码就一行,但它的内部逻辑值得搞清楚,否则出了问题你完全无从下手。
from torchvision import datasets, transforms train_set = datasets.FashionMNIST( root="./data", # 数据落地目录,建议用绝对路径或固定相对路径 train=True, # True 取 60000 张训练集,False 取 10000 张测试集 download=True, # 文件不存在时才下载 transform=transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)), ]), ) test_set = datasets.FashionMNIST( root="./data", train=False, download=True, transform=transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)), ]), )它的执行顺序大致是:先检查root/FashionMNIST/raw/下有没有那四个解压后的文件;如果没有,就去下载对应的压缩包;下载完成后解压、校验;最后在raw目录下生成解压文件,并写入一份处理完成的标记。
这里有三个必须知道的行为特征。第一,download=True只在文件缺失时才真正发起下载,已经存在的文件不会重复下载,所以你不用担心每次运行都重复拉一遍。第二,它不校验已有文件的完整性——如果你的文件之前下了一半被中断,下次运行它会认为“文件在”,然后在校验阶段直接报错,而不是自动重新下载。这就是为什么第 5 节里“文件损坏”是最常见的问题。第三,root参数决定了整个目录结构,我强烈建议一开始就用一个固定的、明确的路径,不要用默认的相对路径./data,否则你在不同工作目录下跑脚本,会得到好几份重复的数据,白占几百兆空间。
3.2 Keras 路线:更省事,但少了点控制力
如果你用的是 Keras 那一套,加载会更简单,一行就返回四个 numpy 数组:
import tensorflow as tf (x_train, y_train), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data() print(x_train.shape, x_train.dtype) # (60000, 28, 28) uint8 print(x_train.min(), x_train.max()) # 0 255注意几个差异点:返回的是uint8 的 0–255 整数,没有归一化;形状是(N, 28, 28),没有通道维,用卷积层之前要自己扩一维;缓存位置默认在用户主目录下的隐藏目录里,不在你当前项目目录,换机器或者清缓存时要记得这一点。
两条路线的差异我整理成一张表,省得你来回翻文档:
| 对比项 | torchvision 路线 | Keras 路线 |
|---|---|---|
| 返回类型 | Dataset 对象,可迭代 | 四个 numpy 数组 |
| 数值范围 | ToTensor 后 0–1 浮点 | 原始 0–255 uint8 |
| 形状 | (N, 1, 28, 28) | (N, 28, 28) |
| 归一化 | transform 里显式指定 | 需手动除以 255 |
| 缓存位置 | 由 root 决定,可控 | 默认在用户目录 |
| 离线兜底 | 可手动放入 raw 目录 | 需按其约定放置 |
我的建议是:如果你后续要做数据增强、要自定义采样策略、要接 DataLoader 的多进程预取,走 torchvision;如果你只是想快速验证一个想法、画几张图,Keras 更省事。两者之间没有优劣,只有场景匹配。
3.3 手动下载与离线目录组织:自动下载失败时的兜底方案
有些环境访问不到下载源,或者下载速度慢到不可用。这时候最稳的做法是手动把文件放到位,然后关掉自动下载。关键中的关键:目录结构和文件名必须一丝不差。
正确的结构是这样的:
./data/ └── FashionMNIST/ └── raw/ ├── train-images-idx3-ubyte ├── train-labels-idx1-ubyte ├── t10k-images-idx3-ubyte └── t10k-labels-idx1-ubyte放好之后,把download参数改成False,代码其余部分不变。这里有两个高频错误我必须强调。
错误一:放的是压缩包不是解压后的文件。不同版本的库对.gz压缩包的处理行为不一致,有的版本能自动解压,有的版本要求raw目录里直接是解压后的文件。为了跨版本稳定,统一放解压后的文件,别偷懒。
错误二:多套了一层目录。从压缩包里解压出来,很容易变成./data/FashionMNIST/raw/FashionMNIST/raw/...这种嵌套结构。库找不到文件,就会重新尝试下载,然后失败,然后你再手动放,形成死循环。放完之后用ls -R(Windows 用dir /s)看一眼真实结构,三十秒的事。
3.4 自己写一个 idx 解析器:不依赖框架也能读
我一直建议手写一遍解析器,原因有两个:一是你对数据格式的理解会立刻上一个台阶,二是遇到“框架报错但不知道数据哪里有问题”的时候,裸读一遍是最快的排查手段。
import gzip import numpy as np def read_idx(path, is_image=True): """读取 idx 格式文件,返回 numpy 数组""" opener = gzip.open if path.endswith(".gz") else open with opener(path, "rb") as f: magic = int.from_bytes(f.read(4), "big") ndim = int.from_bytes(f.read(4), "big") dims = [int.from_bytes(f.read(4), "big") for _ in range(ndim)] data = np.frombuffer(f.read(), dtype=np.uint8) expected_magic = 0x00000803 if is_image else 0x00000801 assert magic == expected_magic, f"魔数不匹配: {hex(magic)}" if is_image: return data.reshape(dims) # (N, 28, 28) return data # (N,) x = read_idx("./data/FashionMNIST/raw/train-images-idx3-ubyte") y = read_idx("./data/FashionMNIST/raw/train-labels-idx1-ubyte", is_image=False) print(x.shape, y.shape, x.dtype) # (60000, 28, 28) (60000,) uint8这段代码的好处是:它同时做了校验。魔数不对,说明文件根本不是这个数据;frombuffer读出来的元素个数和形状对不上,说明文件被截断了。这两条断言能帮你在一分钟内定位“到底是数据坏了还是代码写错了”。
3.5 一次下载,多项目复用
同一台机器上跑多个实验,没必要每个项目都存一份数据。我的做法是建一个公共数据目录,比如~/datasets/FashionMNIST,所有项目通过配置指向它。这样能省下几百兆空间,更重要的是能保证不同实验用的是完全相同的一份数据——这对复现实验结果是必需的,否则你很难解释两次结果差异是来自模型改动还是数据版本。
这里有个需要注意的点:某些版本在第一次运行时需要在root下写入处理完成的标记文件。如果你把公共目录设成只读,第一次运行就会失败。解决办法是先用一次可写权限把数据完整跑通,生成好全部产物,再改成只读。之后再怎么跑都不会有问题。
4. 从下载到跑通:一份可以直接抄的完整流程
4.1 环境与版本:先把依赖对齐
这套数据的来源很基础,但框架本身的版本匹配是另一个常见陷阱。尤其是 torch 和 torchvision,两者版本强绑定,装了不匹配的组合,导入时就会报undefined symbol之类的错误,而且报错信息完全指不到问题根因。
我的建议是直接按照对应版本的官方推荐组合一起装,不要单独升级其中某一个:
pip install torch torchvision --index-url <与你环境匹配的官方源> pip install numpy matplotlib python -c "import torch, torchvision; print(torch.__version__, torchvision.__version__)"最后那行打印一定要跑,确认两个版本都正常导入。很多人跳过了这一步,然后在训练脚本跑了两分钟后突然崩掉,白等。
4.2 加载与归一化:把参数算出结果再固定
完整流程分两步走,第一步用不带归一化的 transform 读原始数据,第二步换成带归一化的 transform。这样你既能拿到统计值,又能验证归一化后的数值分布是否符合预期。
from torchvision import datasets, transforms IMG_SIZE = 28 MEAN, STD = 0.2860, 0.3530 train_tf = transforms.Compose([ transforms.ToTensor(), # HWC uint8[0,255] -> CHW float[0,1] transforms.Normalize((MEAN,), (STD,)), # 逐通道标准化 ]) test_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((MEAN,), (STD,)), ]) train_set = datasets.FashionMNIST("./data", train=True, download=True, transform=train_tf) test_set = datasets.FashionMNIST("./data", train=False, download=True, transform=test_tf)ToTensor()这一步做了两件容易被忽略的事:把 HWC 布局转成 CHW(这是卷积层的输入约定),以及把 0–255 的整数缩放到 0–1 的浮点(避免大数值把梯度炸掉)。很多“训练不收敛”的问题,追到根上就是漏了这一步。
跑完之后强烈建议加两行检查:
imgs, labels = next(iter(torch.utils.data.DataLoader(train_set, batch_size=8))) print(imgs.shape, imgs.dtype, imgs.min().item(), imgs.max().item()) print(labels)期望看到形如torch.Size([8, 1, 28, 28])、float32,数值范围大概在-0.81到2.02之间(因为做了标准化,超出 0–1 是正常的)。如果 min 是 0、max 是 255,说明 ToTensor 没生效;如果形状是[8, 28, 28],说明通道维丢了。
4.3 样本抽查与类别均值图:三分钟确认数据没坏
数据加载完,先看一眼再训练,这个习惯能省掉大量无用功。
import matplotlib.pyplot as plt CLASSES = ["T-shirt", "Trouser", "Pullover", "Dress", "Coat", "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"] fig, axes = plt.subplots(4, 4, figsize=(8, 8)) for ax, (img, label) in zip(axes.ravel(), zip(imgs, labels)): ax.imshow(img.squeeze(), cmap="gray") ax.set_title(CLASSES[label], fontsize=9) ax.axis("off") plt.tight_layout() plt.show()看什么?三件事:图是不是正常能看出衣服轮廓(全黑说明解码错了)、标题的类别和图片内容是否对得上(对不上说明图像和标签配错了)、灰度是不是有层次(纯黑白二值说明被错误地做了阈值化)。
再进一步,把每个类别的所有训练样本按像素求平均,画出 10 张“类别平均图”。这个操作很朴素,但信息量极大:你能一眼看出哪些类别之间的平均图长得很像——那些就是模型会混的地方,和第 2 节里的规律能对上。我在做任何新的图像分类任务时都会先画这一张图,它对判断任务难度极其有效。
4.4 DataLoader 参数:几个容易踩坑的开关
from torch.utils.data import DataLoader train_loader = DataLoader( train_set, batch_size=128, shuffle=True, # 训练集必须打乱 num_workers=0, # 先用 0 保证能跑通 pin_memory=False, # 有 GPU 且显存充裕时可开 drop_last=False, ) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=0)参数怎么选,我给一张表:
| 参数 | 建议值 | 理由 |
|---|---|---|
| batch_size | 64 / 128 / 256 | 太小梯度噪声大,太大泛化变差,128 是最稳的起点 |
| shuffle | 训练 True,测试 False | 测试集打乱只会让结果无法复现 |
| num_workers | 先 0,稳定后试 2–4 | 数据本身小,多进程收益有限,反而容易在 Windows 上崩 |
| drop_last | 训练可 True | 避免最后一个不完整批次影响批归一化统计 |
| pin_memory | 用 GPU 时可开 | 加速主机到显存的数据搬运 |
注意:在 Windows 上把
num_workers设成大于 0,多进程启动方式会变成 spawn,如果你的训练代码没有放在if __name__ == "__main__":保护块里,会直接无限递归创建进程然后崩掉。这个报错信息非常吓人,但原因就是这个。
4.5 一个最小可跑的基线网络
基线的作用是给你一个参照系。后面任何改动,都跟这个数比。我这套结构很朴素,没什么花哨的东西:
import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 28 -> 14 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 14 -> 7 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), ) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.head(self.features(x))为什么这么设计:三层卷积的感受野从 3 逐步扩到 7 以上,刚好覆盖一件衣服的主要结构;每次卷积后接批归一化,是为了让各层输入分布稳定,这在只有 60000 张样本的情况下能显著加快收敛;用自适应平均池化代替全连接,参数从几十万降到几乎没有,过拟合风险大幅下降。优化器用 Adam,学习率 1e-3,交叉熵损失,训练 5 个 epoch。我实测的预期结果是:第 1 个 epoch 结束测试准确率约 89%,第 3 个约 91.5%,第 5 个在 92% 上下。如果你的曲线明显偏离这个走势,先别改模型,回去检查数据和归一化。
5. 踩坑记录与排查手册
5.1 下载卡住、超时、断在半路
现象通常是:终端停在下载提示上不动,或者报连接超时、读取超时。排查顺序我建议这样走。
先确认是不是目录权限问题。有些环境下对目标目录没有写权限,库尝试创建目录时静默失败,然后一直重试,看起来就像卡在下载。手动在目标路径下touch一个文件试试,能不能创建成功。
再确认已有文件是否完整。很多时候你以为在下载,实际上raw目录里已经有一份半成品,库在解压阶段卡住了。先看看raw里有什么、每个文件多大。
最后才是走离线兜底:手动把四个文件放到位,download=False。这条路是一劳永逸的,我建议所有需要反复跑实验的人都提前准备好一份离线文件,放在公共目录里。
5.2 校验失败:提示文件与预期不符
这个报错信息一般会带上具体路径。逐条对照检查:
- 文件大小是否和解压后的理论值一致(参考 1.3 节的表格)
- 文件是不是被截断了(大小明显小于理论值)
.gz文件是不是真的 gzip 格式——有些下载工具在失败时会把一个错误页面保存成目标文件名,文件后缀对,内容是 HTML- 文件头和魔数对不对(用 3.4 节的解析器一跑就知道)
提示:遇到校验失败,最省时间的做法是把
raw目录下那一个报错的文件删掉,只删这一个,然后重跑。不要一上来就删整个数据目录重下,那样既慢又容易把好的文件也弄没。
5.3 手动放了文件,它还是重新下载
这是最高频的问题,几乎全是目录层级或文件命名的问题。三个检查点:
第一,层级对不对。正确是root/FashionMNIST/raw/四个文件,不是root/raw/,也不是root/FashionMNIST/raw/FashionMNIST/raw/。多一层少一层都会导致找不到。
第二,文件名对不对。四个文件名必须逐字符一致,包括连字符的位置。常见的错法是train_image_idx3_ubyte(下划线代替连字符)、train-images-idx3-ubyte.gz(多了后缀)。
第三,放的是不是压缩包。参照 3.3 节,统一放解压后的。
5.4 归一化写错导致的“能跑但效果差”
这类问题最折磨人,因为程序不报错,你只会觉得“这个模型怎么这么差”。
| 写法 | 现象 | 判断方法 |
|---|---|---|
| 套用手写数字的 0.1307/0.3081 | 准确率低 1–2 个点,loss 抖 | 打印输入的最小值最大值,看是否偏离零均值 |
| 只做 ToTensor 不做 Normalize | 能收敛,但需要更小学习率 | 输入范围是 0–1,均值约 0.29 |
| 用了 0.5/0.5 | 通常没事,收敛略慢 | 输入范围变成 -1 到 1 |
| 训练用了一套参数,测试用了另一套 | 测试结果异常差 | 检查两个 transform 是否一致 |
第四条我单独拎出来说:训练集和测试集的 transform 必须保持一致(除了数据增强部分)。我见过有人训练时加了 Normalize,测试时忘了加,测试准确率直接从 92% 掉到 30%,然后怀疑是模型崩了。
5.5 准确率卡在 91% 上不去
先别急着改网络。按这个顺序排查:
打印混淆矩阵,看错误集中在哪。如果集中在上衣四类之间,那是任务本身的难点,需要从特征表达上下功夫;如果均匀分布,多半是欠拟合——检查学习率是不是太小、epoch 是不是不够、批归一化是不是被误关了。
检查训练集和验证集的准确率差距。训练 99% 测试 91%,是过拟合,加 dropout、加数据增强、减参数量;训练 91% 测试 91%,是欠拟合,反过来做。
检查标签和数据是否对齐。把数据和标签一起可视化几批,确认没有错位。这个错误发生概率不高,但一旦发生,表现就是“怎么调都上不去”。
5.6 几个反直觉但有价值的经验
测试准确率比训练准确率高,是正常的。训练时开着 dropout、批归一化用的是当前批的统计量,模型处于“被削弱”状态;测试时这些层切到推理模式,反而发挥更完整。看到训练 90%、测试 91% 不要慌,不是代码写错了。
别把测试集当验证集用。这套数据只给了训练集和测试集,没有独立验证集。很多人直接拿测试集反复调参,调了二十次挑最好的一次,这个数字已经没有参考意义了。正确做法是从 60000 张训练集里切出 5000 张做验证,测试集只在最后评估一次。
数据增强在这套数据上收益有限,而且容易做错。灰度图做颜色抖动毫无意义;随机裁剪要小心,28×28 本来就没多少边距,裁狠了会把袖子裁掉;水平翻转对鞋子、包、裤子是合理的,对某些有强方向性的款式可能引入噪声。我实测下来,翻转加小幅度旋转(±10 度)是比较稳的组合,能带来 0.5 个点左右的提升,再多就容易伤到准确率。
batch_size 对结果的影响比你想的大。同一套代码,batch 从 128 换成 32,最终准确率可能差 1 个点,因为批归一化的统计稳定性变了。做对比实验时,除了你要验证的那一个变量,其余全部固定。
5.7 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 卡在下载不动 | 目录无写权限 / 网络不可达 | 检查权限,改用手动放置 |
| 报文件校验不符 | 文件被截断 / 存成了错误页面 | 删掉该文件重下,或用解析器验证魔数 |
| 手动放了还重新下载 | 目录层级多套一层 / 文件名不符 | 逐字符核对路径和文件名 |
| 输入形状是 (N,28,28) | 缺通道维 | 加unsqueeze(1)或检查 ToTensor |
| 训练 loss 不降 | 忘了归一化 / 学习率太小 / 标签错位 | 依次排查这三项 |
| 测试准确率异常低 | 训练和测试 transform 不一致 | 统一两边的归一化参数 |
| Windows 上多进程崩溃 | 缺__main__保护 | 把入口代码包进保护块 |
| 准确率 91% 上不去 | 欠拟合 / 混淆集中在上衣类 | 看混淆矩阵,针对性调整 |
6. 把它当跳板:三个具体可执行的下一步
跑到 92% 之后很多人会陷入迷茫——往上刷收益递减,往下又觉得没意思。我给三个我自己走过的方向,都是能在一两天内看到结果的。
第一个方向:拿同一套代码换数据。你现在这套加载、归一化、训练、评估的流程是通用的,换成一个彩色数据集(比如同样是 10 类但带颜色的服饰数据,或者更高分辨率的数据),你会发现代码几乎不用改,只有Normalize的参数和输入通道数要动。这个迁移过程本身就是最好的练习——你会真正理解哪些部分是数据相关的、哪些部分是模型相关的。我建议至少做一次这个迁移,做完你对整条流水线的掌控感会完全不一样。
第二个方向:从零训练切到迁移学习。找一个在更大图像数据上预训练的骨干网络,把第一层改成单通道输入(或者把灰度图复制三份变三通道),冻结前面几层只训最后的分类头。你会发现即使只有 60000 张 28×28 的小图,迁移学习也能把准确率推到 94% 以上,而且收敛速度快得多。这里有个细节要注意:预训练模型的输入归一化参数和这套数据不一样,得单独处理,否则效果会打折扣。
第三个方向:做一个能看的交互 demo。训练完的模型如果只停留在一个准确率数字上,价值是有限的。把它导出成一个可以加载的文件,配一个画板让人手写或者上传一张服饰图,实时输出十个类别的概率条。这个过程会让你遇到一堆训练时根本不会想的问题:推理时要关掉梯度、要切 eval 模式、输入要做和训练时完全一致的预处理、输出要过 softmax 才能当概率看。这些问题在真实项目里天天出现,提前踩一遍比什么都值。
我个人的习惯是,每学一个新数据集,最后一定落到一个能点开看的小东西上。纯粹盯着终端里的 loss 数字,很难建立起对数据的直觉;而当你亲手画的东西被模型判成“外套”的时候,很多之前模糊的概念会一下子清晰起来。