实战演练:用 PyTorch 搭出第一个 MLP
这是整套笔记第一次真的训练一个网络。
目标很具体:在 MNIST 子集上,用 784 → 256 → 10 的两层 MLP,5 个 epoch 内把测试准确率推到 95% 以上,并且能从日志里解释每一个数字。验收线不是「跑通就行」,而是「跑完之后,你知道它为什么是这个结果,也知道怎么把它弄坏」。
这一篇会按真实执行的顺序写:装环境、看数据、搭网络、算损失、写训练循环、跑基线、做三组失败实验、拆结果、最后验收。所有数字都来自 assets/metrics.json 和 train.log,你可以重跑一遍对账。

<!-- 配图:5 张 28×28 手写数字,标注标签与 784 维输入 -->
一、环境与验收线
1.1 为什么单独建一个 venv
系统 Python 里通常没有 PyTorch,也不该为了这一个项目污染全局环境。建一个临时 venv:
python3 -m venv /tmp/dnn-venv
/tmp/dnn-venv/bin/pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
torch torchvision这次装到的是:
torch 2.8.0
torchvision 0.23.0
Python 3.12
平台 macOS arm64确认一下:
/tmp/dnn-venv/bin/python -c "import torch, torchvision; \
print(torch.__version__, torchvision.__version__)"输出 2.8.0 0.23.0 就对了。版本不必完全一致,PyTorch 2.x + torchvision 0.2x 都能跑;如果差得太多,nn.Flatten、CrossEntropyLoss 这些接口一般不会变,但 torchvision.datasets.MNIST 的参数偶有调整,遇到报错先看官方文档。
1.2 数据放在哪
MNIST 由 4 个文件组成:
train-images-idx3-ubyte 60,000 张训练图
train-labels-idx1-ubyte 60,000 个训练标签
t10k-images-idx3-ubyte 10,000 张测试图
t10k-labels-idx1-ubyte 10,000 个测试标签torchvision.datasets.MNIST("./data", download=True) 会自动下载并解压到 data/MNIST/raw/。官方地址在 S3 上,国内可能很慢。这次实测直接从官方下载,180 秒只走到 28%;换成 jsDelivr 镜像之后,四个文件几十秒就下完了。
镜像地址的格式是:
https://cdn.jsdelivr.net/gh/leo-mao/MNIST_data@master/<文件名>把 4 个文件(.gz 和解压后的文件都可以)放进 data/MNIST/raw/,再跑脚本就不会重新下载。目录长这样:
assets/
├── data/
│ └── MNIST/
│ └── raw/
│ ├── train-images-idx3-ubyte
│ ├── train-images-idx3-ubyte.gz
│ ├── train-labels-idx1-ubyte
│ ├── train-labels-idx1-ubyte.gz
│ ├── t10k-images-idx3-ubyte
│ ├── t10k-images-idx3-ubyte.gz
│ ├── t10k-labels-idx1-ubyte
│ └── t10k-labels-idx1-ubyte.gz
├── train_mlp.py
├── metrics.json
├── train.log
└── draw_ch1_practice.pytrain_mlp.py 里有一行 os.chdir(here),把工作目录切到脚本所在目录,所以 ./data 永远指向 assets/data,不管你在哪里执行命令。
1.3 验收线
这一章的验收不是「损失降了」,而是四条硬指标:
| 指标 | 目标 | 这次实测 |
|---|---|---|
| 训练集 | 10,000 张(全量 60,000 的子集) | 10,000 |
| 测试集 | 2,000 张(全量 10,000 的子集) | 2,000 |
| 训练轮数 | 5 | 5 |
| 训练耗时 | 10 分钟以内 | 1.58 秒 |
| 测试准确率 | ≥ 95% | 95.45% |
| 参数量 | 能手算并和代码对上 | 203,530 |
最后一条「能手算」是这篇的重点。参数量、单样本乘加次数、单张图的 softmax 概率,都要能在纸上重算一遍;否则网络跑通了,你也只是会按按钮。
二、先看数据,再谈模型
2.1 一张图是怎么变成 784 个数的
MNIST 的每张图是 28×28 的灰度网格,每个像素 0–255。0 是黑,255 是白。图里 5 张测试图,标签分别是 4、3、4、7、4。
送进网络之前,transforms.ToTensor() 做两件事:
- 把
PIL.Image变成torch.Tensor,形状从(28,28)变成(1,28,28); - 把像素值从 0–255 缩放到 0–1。
然后 transforms.Normalize((0.1307,), (0.3081,)) 再做一次标准化:
x' = (x / 255 − 0.1307) / 0.30810.1307 是 MNIST 训练集的全局均值,0.3081 是全局标准差。标准化之后,像素大致落在 −0.42 到 2.82 之间,均值 0、标准差 1。
为什么非要这一步?因为第一层的预激活是 Σ wᵢxᵢ。如果 x 还在 0–255,同样一组权重算出来的 z 会大 255 倍,梯度也跟着放大;学习率稍微大一点就 NaN。理论篇提过这个坑,这里直接用标准化绕开它。
2.2 切分:10,000 / 2,000

<!-- 配图:60,000 训练集抽 10,000,10,000 测试集抽 2,000,batch=64 -->
代码:
def load_data(train_size, test_size):
tf = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train = datasets.MNIST("./data", train=True, download=True, transform=tf)
test = datasets.MNIST("./data", train=False, download=True, transform=tf)
g = torch.Generator().manual_seed(SEED)
tr = torch.randperm(len(train), generator=g)[:train_size].tolist()
te = torch.randperm(len(test), generator=g)[:test_size].tolist()
return Subset(train, tr), Subset(test, te)三个细节:
第一,训练集和测试集分开抽。 训练子集从 60,000 里抽,测试子集从 10,000 里抽,两个 randperm 用同一个 generator 但作用在不同数据集上,不会重叠。
第二,Subset 只包索引,不复制数据。 底层还是完整的 MNIST,Subset[i] 会去原数据集取第 indices[i] 张。这样内存里只有一份图。
第三,随机种子固定。 SEED = 20260731,每次重跑抽到的子集完全一样,实验之间才能对比。换种子会换一批图,准确率会有零点几个百分点的波动。
训练集 10,000 张,batch=64,DataLoader 默认 drop_last=False,所以每个 epoch 是 ceil(10000/64) = 157 个 batch,最后一个 batch 只有 32 张。
测试集 2,000 张,batch=256,评估时一次前向就够,不需要打乱。
2.3 为什么不用全量
全量 60,000 张也能跑,但一个 epoch 要 6 倍时间。这一章要做的不是刷榜,是把整条链路跑通、把失败实验做完。10,000 张已经足够让测试准确率到 95%,也足够让三组失败实验的差别显现出来。
后面第 4 章做 CNN 时,会换成更大的子集或全量,因为卷积网络需要更多数据才能看出优势。
三、搭网络:四行代码,两次矩阵乘
3.1 结构

<!-- 配图:784→256→10 的 MLP 流水线,标出每层参数量 -->
class MLP(nn.Module):
def __init__(self, hidden=256):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(784, hidden),
nn.ReLU(),
nn.Linear(hidden, 10),
)
def forward(self, x):
return self.net(x)四层,逐层看形状:
输入 x: (64, 1, 28, 28)
Flatten: (64, 784) 28×28 拉平,batch 不变
Linear(784,256): (64, 256) 200,960 个参数
ReLU: (64, 256) 形状不变,逐元素
Linear(256,10): (64, 10) 2,570 个参数nn.Flatten() 默认从第 1 维开始拉平,保留第 0 维(batch)。所以 (64,1,28,28) 变成 (64,784),不是 (64,)。
nn.Linear(784, 256) 内部存的是 weight (256,784) 和 bias (256,),计算的是 x @ W.T + b。这和第 1 章案例里手写的 W @ x + b 是同一件事,只是转置了一下,让 batch 维留在前面。
3.2 参数量核对

<!-- 配图:第一层 200,960,第二层 2,570,合计 203,530 -->
手算:
第一层:784×256 + 256 = 200,960
第二层:256×10 + 10 = 2,570
合计:203,530代码核对:
model = MLP()
for name, p in model.named_parameters():
print(name, tuple(p.shape), p.numel())
print("total", sum(p.numel() for p in model.parameters()))输出:
net.1.weight (256, 784) 200704
net.1.bias (256,) 256
net.3.weight (10, 256) 2560
net.3.bias (10,) 10
total 203530Sequential 的下标从 0 开始:0 是 Flatten,1 是第一个 Linear,2 是 ReLU,3 是第二个 Linear。Flatten 和 ReLU 没有参数,所以 named_parameters() 里看不到它们。
如果总数不是 203,530,先检查三件事:是不是多写了一个 nn.Linear、是不是把 Flatten 当成了可学习层、是不是在末尾加了 nn.Softmax(它也没有参数,但会让损失算错)。
3.3 为什么末尾不接 softmax
nn.CrossEntropyLoss 的输入是 logits,内部会做 log_softmax 再取负对数。所以网络最后直接输出 10 个实数,不接 softmax。
如果你在 Sequential 末尾加一个 nn.Softmax(dim=1),再喂给 CrossEntropyLoss,等于做了两次 softmax:概率被压得更平,梯度变小,训练明显变慢。现象是 loss 降得很慢、准确率卡住,但不会报错。
正确写法只有两种:
# 写法 A:网络输出 logits,损失内部做 softmax
logits = model(x)
loss = nn.CrossEntropyLoss()(logits, y)
# 写法 B:网络输出 log_softmax,损失用 NLLLoss
log_probs = nn.LogSoftmax(dim=1)(model(x))
loss = nn.NLLLoss()(log_probs, y)这一章用 A。后面所有章节也用 A。
四、前向、损失、准确率
4.1 一次前向在算什么
一个 batch x (64,1,28,28) 进来:
Flatten → (64,784)
Linear → z₁ = xW₁ᵀ + b₁ (64,256)
ReLU → h = max(0, z₁) (64,256)
Linear → z₂ = hW₂ᵀ + b₂ (64,10)z₂ 就是 10 个 logits。对 batch 里第 i 张图,z₂[i] 是一个长度为 10 的向量,对应数字 0–9 的分数。
4.2 交叉熵

<!-- 配图:−log p 曲线,标出 p=0.9873 与 p=0.1086 两个点 -->
对单张图:
L = −log p_yp_y 是 softmax 之后正确类别的概率。预测对且自信,p_y 接近 1,L 接近 0;预测错,p_y 很小,L 很大。
对一个 batch,nn.CrossEntropyLoss() 默认对 batch 取平均:
L_batch = (1/B) Σᵢ −log p_{yᵢ}代码:
ce = nn.CrossEntropyLoss()
logits = model(x)
loss = ce(logits, y)想验证它内部真的做了 softmax,可以自己算一遍:
logits = torch.tensor([[2.0, 1.0, 0.1]])
p = torch.softmax(logits, dim=1)
print(p) # tensor([[0.6590, 0.2424, 0.0986]])
print(-torch.log(p[0, 0])) # tensor(0.4170)
print(ce(logits, torch.tensor([0]))) # tensor(0.4170)两个数一样,说明 CrossEntropyLoss = log_softmax + NLLLoss。
4.3 准确率
准确率不看概率,只看 argmax:
pred = logits.argmax(dim=1)
correct = (pred == y).sum().item()
acc = correct / y.numel()注意 argmax 返回的是类别下标,不是概率。logits.argmax(1) 和 probs.argmax(1) 结果一样,因为 softmax 是单调的;但用 logits 少一次计算。
4.4 评估函数
def evaluate(model, loader, shift=0):
model.eval()
ce = nn.CrossEntropyLoss(reduction="sum")
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for x, y in loader:
if shift:
y = (y + shift) % 10
logits = model(x)
total_loss += ce(logits, y).item()
correct += (logits.argmax(1) == y).sum().item()
total += y.numel()
return total_loss / total, correct / total三个点:
model.eval():切换到评估模式。这个网络没有 Dropout/BatchNorm,切不切结果一样;但养成习惯,后面 CNN 会用。torch.no_grad():关闭梯度记录。评估不需要反向,关掉能省显存和时间。reduction="sum":先累加再除以总数,避免每个 batch 平均后再平均带来的权重误差(最后一个 batch 只有 32 张,直接平均会偏大)。
五、训练循环:五步,重复 157 次

<!-- 配图:zero_grad → forward → loss → backward → step 五步循环 -->
for ep in range(1, epochs + 1):
model.train()
running_loss, running_correct, seen = 0.0, 0, 0
for x, y in train_loader:
opt.zero_grad()
logits = model(x)
loss = ce(logits, y)
loss.backward()
opt.step()
running_loss += loss.item() * y.numel()
running_correct += (logits.argmax(1) == y).sum().item()
seen += y.numel()
train_loss = running_loss / seen
train_acc = running_correct / seen
test_loss, test_acc = evaluate(model, test_loader)五步的顺序不能反:
opt.zero_grad():清空上一轮的梯度。PyTorch 的梯度是累加的,不清零就会把上一个 batch 的梯度带进来。logits = model(x):前向。loss = ce(logits, y):算损失。loss.backward():反向,把∂L/∂θ写进每个参数的.grad。opt.step():按θ ← θ − lr × grad更新。
这一篇只关心这五步的顺序和形状;backward() 里面怎么展开链式法则,是第 2 章的主题。
优化器用 Adam:
opt = torch.optim.Adam(model.parameters(), lr=1e-3)Adam 和 SGD 的差别先记住一句话:Adam 会给每个参数维护一阶矩和二阶矩,自动调整每个方向的有效步长。所以 lr=1e-3 的 Adam 和 lr=0.1 的 SGD 不能直接比大小,它们不是同一把尺子。
六、跑基线:5 轮,95.45%
6.1 日志
[baseline] epoch 1/5 train_loss=0.4914 train_acc=0.8510 test_loss=0.2882 test_acc=0.9130 0.3s
[baseline] epoch 2/5 train_loss=0.2287 train_acc=0.9311 test_loss=0.2083 test_acc=0.9390 0.3s
[baseline] epoch 3/5 train_loss=0.1591 train_acc=0.9519 test_loss=0.1804 test_acc=0.9495 0.3s
[baseline] epoch 4/5 train_loss=0.1111 train_acc=0.9686 test_loss=0.1665 test_acc=0.9495 0.3s
[baseline] epoch 5/5 train_loss=0.0816 train_acc=0.9760 test_loss=0.1483 test_acc=0.9545 0.3s整理成表:
| epoch | 训练 loss | 训练 acc | 测试 loss | 测试 acc | 耗时 | 权重范数 |
|---|---|---|---|---|---|---|
| 1 | 0.4914 | 85.10% | 0.2882 | 91.30% | 0.337 s | 11.21 |
| 2 | 0.2287 | 93.11% | 0.2083 | 93.90% | 0.309 s | 12.30 |
| 3 | 0.1591 | 95.19% | 0.1804 | 94.95% | 0.309 s | 13.29 |
| 4 | 0.1111 | 96.86% | 0.1665 | 94.95% | 0.309 s | 14.18 |
| 5 | 0.0816 | 97.60% | 0.1483 | 95.45% | 0.313 s | 14.98 |

<!-- 配图:5 轮 loss 与 accuracy 双面板,训练/测试各一条线 -->
6.2 四个观察
第一,第一轮就到 91.30%。 这不是模型聪明,是 MNIST 太容易。10 个类别、笔画差异明显、图像已经居中裁剪,随机权重的 256 个隐藏单元已经能抓到一些粗特征。第一轮 157 次更新,足够把「明显不是这个类」的 logits 压下去。
第二,训练和测试的差距在拉大。 第 1 轮差 6.2 个百分点(85.10 vs 91.30,训练还低于测试,因为训练 loss 是在线统计、测试是轮末评估);到第 5 轮,训练 97.60、测试 95.45,训练比测试高 2.15 个百分点。模型开始记住训练集的细节,但还没到严重过拟合。
第三,测试 loss 在降,测试 acc 在第 4 轮停了一下。 第 3、4 轮都是 94.95%,第 5 轮才到 95.45%。准确率是离散的(2,000 张里 1 张 = 0.05%),loss 是连续的,所以 loss 还在降、acc 不动,是正常的平台期。
第四,权重范数一直在涨。 从 11.21 到 14.98。训练在把权重推向更大的绝对值,让决策边界更「自信」。这不是坏事,但如果范数爆炸式增长,通常意味着学习率太大或正则不够。

<!-- 配图:基线 Adam 的权重范数随轮数上升,对照全零初始化几乎不动 -->
6.3 为什么 1.58 秒能跑完
5 个 epoch 的纯训练时间是:
0.337 + 0.309 + 0.309 + 0.309 + 0.313 = 1.577 s每个 epoch 10,000 张,单样本 203,264 次乘加:
10,000 × 203,264 = 2,032,640,000 ≈ 20.3 亿 MAC1.58 秒做完 5 轮,等效约 64 亿 MAC/s。这不是 PyTorch 有多神,是矩阵乘的形状太适合 BLAS:(64,784) @ (784,256) 和 (64,256) @ (256,10),两个都是大块连续内存的 GEMM,CPU 的 SIMD 和缓存能吃满。
这也解释了为什么这一章敢用 CPU。真正的瓶颈不是这个网络,是后面的 CNN 和数据加载。
6.4 验收卡

<!-- 配图:训练耗时、准确率、参数量、错例数、轮数的验收卡 -->
四条硬指标全部达标:
训练耗时 1.58 s (上限 10 分钟)
测试准确率 95.45% (验收线 95%)
参数量 203,530 (手算 200,960 + 2,570)
测试错例 91 / 2,000 (错误率 4.55%)注意「95.45%」不是终点。第 4 章的 CNN 要在同样的数据上把错误率从 4.55% 压到 1% 左右;这一章的任务是建立一个可信的基线,让你知道后面的提升到底来自哪里。
七、失败实验一:全零初始化
7.1 怎么把初始化改成全零
model = MLP()
for m in model.modules():
if isinstance(m, nn.Linear):
nn.init.zeros_(m.weight)
nn.init.zeros_(m.bias)只改初始化,其他超参不变:Adam、lr=1e-3、batch=64、3 个 epoch。
7.2 结果
[zero_init] epoch 1/3 train_loss=2.3023 train_acc=0.1040 test_loss=2.3016 test_acc=0.1180
[zero_init] epoch 2/3 train_loss=2.3018 train_acc=0.1064 test_loss=2.3013 test_acc=0.1180
[zero_init] epoch 3/3 train_loss=2.3016 train_acc=0.1089 test_loss=2.3009 test_acc=0.1180
<!-- 配图:loss 停在 ln10,准确率停在 11.80%,与基线对比 -->
三个数字要一起看:
- loss 停在 2.302 左右。
ln 10 = 2.302585,这是 10 类均匀猜测的交叉熵。loss 几乎不降,说明模型没有学到任何类别信息。 - 准确率停在 11.80%。2,000 张测试图里,数字 1 有 236 张,占 11.80%。模型把所有图都猜成 1,准确率就等于 1 类的占比。
- 权重范数只有 0.06 → 0.11。正常初始化 3 轮后是 13.29,全零初始化只有它的零头。
7.3 为什么

<!-- 配图:三个隐藏单元权重完全相同,输出、梯度、更新都相同 -->
一层 256 个隐藏单元,如果权重和偏置全是 0:
- 每个单元收到的输入相同,
z = 0,输出ReLU(0) = 0相同; - 反向时,每个单元收到的上游梯度相同;
- 因为输入相同、上游梯度相同,每个单元的
∂L/∂W和∂L/∂b完全相同; - 梯度下降给它们的更新量一模一样,更新后仍然完全相同。
一轮之后,256 个单元还是 256 个副本。它们永远保持相同,实际只有一个单元在工作。这叫「对称性无法打破」。
输出层的偏置会动,因为不同类别的梯度不同;所以模型最终学会的是类别先验(把所有人都猜成最多的那一类),而不是图像特征。这就是为什么准确率恰好等于 1 类的占比,loss 略低于 ln 10(2.3016 < 2.3026)但又降不下去。
7.4 正确做法
PyTorch 的 nn.Linear 默认用 Kaiming Uniform 初始化,每个单元的权重从不同的随机数开始,对称性自然就破了。你不需要自己写初始化,只要别手贱把它清零。
初始化的方差怎么选、为什么 ReLU 配 Kaiming、深层网络为什么要控制方差,是第 3 章的内容。这一章只需要记住:全零初始化不是「起点对称」,是「起点重合」。
八、失败实验二:学习率 5.0
8.1 怎么设
opt = torch.optim.SGD(model.parameters(), lr=5.0)换成 SGD,学习率 5.0,跑 3 个 epoch。为什么不用 Adam?因为 Adam 会自适应缩放,lr=5.0 在 Adam 里可能直接被裁剪成小步;SGD 是裸步长,能把「步长太大」这件事演得最清楚。
8.2 结果
[large_lr] epoch 1/3 train_loss=9529.4991 train_acc=0.1110 test_loss=2.4642 test_acc=0.1110
[large_lr] epoch 2/3 train_loss=2.4116 train_acc=0.1067 test_loss=2.4136 test_acc=0.1015
[large_lr] epoch 3/3 train_loss=2.3374 train_acc=0.1061 test_loss=2.4108 test_acc=0.1180 <!-- 配图:对数轴,第一轮 loss 9,529,之后回落到 2.4,准确率仍 11.8% -->
第一轮 loss 是 9,529.50,不是 9.5,是九千五百。权重范数从正常的十几跳到了 35,157。第二轮 loss 回落到 2.41,但准确率还是 11% 左右,说明权重已经被推到一个很差的区域,来回震荡,回不来了。
8.3 为什么会到 9,529
SGD 的更新是:
θ ← θ − lr × ∂L/∂θlr=5.0,如果某个梯度是 100,一步就把权重推了 500。权重变大 → logits 变大 → 错类的 logit 可能变成很大的负数 → −log p_y 里 p_y 接近 0 → loss 爆到几千。下一步梯度更大,权重再被推到另一个极端。
这就是「步子太大,跨过了谷底,落到对面的陡坡上」。学习率不是越小越稳,也不是越大越快,它要匹配损失曲面的曲率。
8.4 学习率扫描
为了找到边界,用 SGD 扫了 5 个学习率,每个跑 3 个 epoch:
| 学习率 | 首轮 loss | 最终测试 acc |
|---|---|---|
| 0.1 | 0.5423 | 93.80% |
| 0.5 | 0.7997 | 94.70% |
| 1.0 | 3.0111 | 53.20% |
| 2.0 | 9.2133 | 14.20% |
| 5.0 | 9,529.4991 | 11.80% |

<!-- 配图:5 个学习率的最终测试准确率柱状图,基线 95.45% 虚线 -->

<!-- 配图:首轮 loss 随学习率上升的对数曲线,5.0 处标注梯度爆炸 -->
这张表的信息量比单条曲线大:
- 0.1 和 0.5 都能收敛。0.5 的终值 94.70% 比 0.1 的 93.80% 高,因为只跑了 3 轮,0.5 走得更快;给 0.1 更多轮,它可能追上来。
- 1.0 开始崩。首轮 loss 3.01,最终 53.20%,模型还能学一部分,但已经不稳定。
- 2.0 和 5.0 直接退化。首轮 loss 9.21 和 9,529,最终 14.20% 和 11.80%,和随机猜差不多。
这也解释了一个常见误区:看到 loss 不降,第一反应是「学习率太小」,于是往上调;实际上从 1.0 往上调,只会更糟。正确的顺序是先在 1e-3 到 1e-2 之间试,再按数量级放大或缩小,不要一次跳 5 倍。
8.5 和 Adam 的关系
基线用 Adam lr=1e-3,5 轮到 95.45%;SGD 用 0.5,3 轮到 94.70%。不能直接说「Adam 比 SGD 好」,因为轮数、学习率、优化器状态都不同。
这一章只用 Adam 跑基线、SGD 做失败实验,目的是让现象清楚,不是做优化器对比。第 3 章会固定其他变量,单独比较 SGD、Momentum、Adam 的收敛曲线。
九、失败实验三:训练标签整体 +1
9.1 怎么造
在训练循环里改标签:
if shift:
y = (y + shift) % 10shift=1 的意思是:训练时把 0 当 1、1 当 2、……、9 当 0。测试时不改标签,仍然用原始 0–9。

<!-- 配图:原标签 0–9 映射到 1–0,测试仍用原标签 -->
9.2 结果
[label_shift] epoch 1/3 train_loss=0.4924 train_acc=0.8498 test_loss=8.8883 test_acc=0.0150
[label_shift] epoch 2/3 train_loss=0.2290 train_acc=0.9322 test_loss=10.0998 test_acc=0.0085
[label_shift] epoch 3/3 train_loss=0.1602 train_acc=0.9529 test_loss=10.4184 test_acc=0.0085
<!-- 配图:训练 acc 升到 95.29%,测试 acc 掉到 0.85% -->
这张图是三个失败实验里最值得贴在墙上的:
- 训练 loss 0.1602,训练 acc 95.29%,比基线第 3 轮的 95.19% 还高;
- 测试 loss 10.4184,测试 acc 0.85%。
如果只看训练日志,你会以为这个模型很好。它确实学得很好,只是学的是「图 → 标签+1」这条映射,而测试集问的是「图 → 原标签」。两套账对不上,测试准确率当然接近 0。
9.3 为什么不是 0%
0.85% 不是精确的 0,因为 2,000 张里有 17 张碰巧被预测对了。原因有两类:
- 模型对某些图本来就不确定,argmax 没落在「真标签+1」上;
- 个别图在 +1 之后恰好和另一个类的特征重合。
这不影响结论:标签错位是灾难性的,而且训练指标完全看不出来。
9.4 怎么防
三个检查,训练开始前做一遍:
# 1. 看一个 batch 的图和标签
x, y = next(iter(train_loader))
print(y[:16])
# 2. 看类别的取值范围
print(y.min().item(), y.max().item()) # 应该是 0 9
# 3. 训练一轮后,抽 10 张图看预测
for i in range(10):
print(dataset[i][1].item(), model(dataset[i][0].unsqueeze(0)).argmax(1).item())标签错位的常见来源:CSV 第一行是表头被当成数据、类别编号从 1 开始、数据增强时标签没跟着变换、训练/测试的类别映射不一致。它们都不会报错,只会让测试准确率莫名地低。
十、结果分析:91 个错例长什么样
10.1 混淆矩阵

<!-- 配图:10×10 混淆矩阵,对角线 1,909 张,非对角线 91 张 -->
2,000 张测试图,对角线合计 1,909 张,错误 91 张,准确率 95.45%。
从矩阵里读几件事:
- 错得最多的是 9 类:204 张里错 22 张,准确率 89.22%;
- 其次是 8 类:177 张里错 14 张,92.09%;
- 2 类:199 张里错 14 张,92.96%;
- 0 类和 1 类最好,分别是 98.21% 和 98.73%。

<!-- 配图:0–9 每类准确率柱状图,9 类最低 -->
几个高频混淆:
9 → 4:6 张
9 → 7:5 张
9 → 3:4 张
8 → 4:3 张
2 → 7:5 张这不是随机错误。9、8、2 都是「上半圈 + 下半笔」的结构,4 是「斜线 + 横线」,在 784 维的线性边界里,它们的投影很近。两层 MLP 只能画一条(或几段)线性边界,遇到写法模糊的样本就会站错边。
10.2 错例墙

<!-- 配图:8 张错例,真实标签、预测标签、p_true、p_pred -->
挑第一张:真实 8,预测 4:
p_true = 0.1086
p_pred = 0.8465
loss = −log 0.1086 = 2.2201模型不是「完全不会」,它给真类留了 10.9% 的概率,只是把 4 排在了第一。看 argmax 是「错」,看概率是「犹豫」。
第二张:真实 3,预测 5:
p_true = 0.1146
p_pred = 0.8849第三张:真实 2,预测 3:
p_true = 0.1193
p_pred = 0.6135第三张的 p_pred 只有 0.61,是典型的「边界样本」:模型在 2 和 3 之间摇摆,最后选了 3。
10.3 一张正确样本的完整账

<!-- 配图:一张真实 4 的图,10 个 softmax 概率,p4=0.9873 -->
logits = [−6.8564, −6.5353, −4.6914, −3.7176, 6.6028,
−2.5948, −3.9363, 1.2287, −3.5173, 1.7785]
p = [0.0000, 0.0000, 0.0000, 0.0000, 0.9873,
0.0001, 0.0000, 0.0046, 0.0000, 0.0079]
loss = −log 0.9873 = 0.0128这张图的 loss 只有 0.0128,而 5 轮平均测试 loss 是 0.1483。为什么差这么多?
因为平均 loss 被 91 个错例拉高了。错例的 loss 都在 2 以上,正确样本大多在 0.0x。假设 91 个错例平均 loss 2.2,1,909 个正确样本平均 loss 0.05:
(91 × 2.2 + 1909 × 0.05) / 2000 ≈ 0.148和实测 0.1483 对得上。这就是「平均损失」和「单样本损失」的区别:前者是全局指标,后者要看具体样本。
10.4 错误率 4.55% 意味着什么
2,000 张里错 91 张。放到全量 10,000 张测试集,大约错 455 张。这个量级不算差,但对 MNIST 来说也不算好——一个简单的 CNN 能把这个错误率压到 1% 以下。
错误集中在 9、8、2 三类,说明模型缺的是「局部形状」的辨别能力:9 的上半圈是闭的还是开的、8 的两个圈是否连笔、2 的底部有没有横线。全连接层把 28×28 拉平成 784 维,空间关系当场丢失,这正是第 4 章 CNN 要补的。
十一、脚本结构与重跑
11.1 文件清单
assets/
├── train_mlp.py 训练与全部实验,约 280 行
├── metrics.json 所有指标,供正文和画图脚本读取
├── train.log 最近一次运行的完整输出
├── draw_ch1_practice.py 19 张实战图,读取 metrics.json
├── draw_ch1_concepts.py 19 张概念图
└── data/MNIST/raw/ MNIST 四个文件draw_ch1_practice.py 和 train_mlp.py 是分开的:训练只负责产出 metrics.json,画图只负责读它。这样你可以换模型、换超参,只要 metrics.json 的字段不变,图就能重新生成。
11.2 函数分工
| 函数 | 做什么 | 关键参数 |
|---|---|---|
load_data | 下载、标准化、抽子集 | train_size、test_size |
MLP | 784→256→10 | hidden |
evaluate | 测试集 loss/acc | shift |
weight_norm | 所有参数的二范数 | 无 |
run | 一次完整实验 | epochs、lr、opt_name、zero_init、shift |
confusion_matrix | 10×10 混淆矩阵 | 无 |
misclassified | 取前 8 个错例 | limit |
sample_digits | 取前 5 张测试图 | limit |
example_prediction | 取一张正确样本的 logits/probs | 无 |
main | 跑基线 + 三组失败 + 扫描 | 无 |
run 每次开始都调用 set_seed(),所以每个实验的初始化、数据打乱顺序都一样。这是能对比的前提:如果每个实验的随机种子不同,0.5 个百分点的差异可能只是噪声。
11.3 重跑
cd ".../第 1 章:DNN 基础/assets"
/tmp/dnn-venv/bin/python train_mlp.py整套脚本跑完大约 10–15 秒(29 个 epoch,每轮 0.3 秒,加上数据加载和评估)。输出会写到 train.log 和 metrics.json。
只想跑基线,改 main():
base_model, base_hist, test_ds, test_loader = run(
"baseline", epochs=5, lr=1e-3, opt_name="adam",
train_size=10000, test_size=2000, batch=64)想换隐藏宽度:
model = MLP(hidden=128) # 参数量变成 101,770想换优化器:
opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)改完记得重跑,并检查 metrics.json 里的 params 和 baseline[0]。
11.4 metrics.json 的字段
seed 随机种子
config 训练集/测试集/epoch/batch/hidden/optimizer/lr
params 203530
baseline 5 轮记录,每轮含 train/test loss、acc、耗时、权重范数
zero_init 3 轮记录
large_lr 3 轮记录
lr_sweep 5 组,每组 lr + 3 轮记录
label_shift 3 轮记录
confusion 10×10 矩阵
misclassified 8 个错例,含 pixels、logits、probs
samples 5 张测试图,含 pixels
example 1 张正确样本,含 pixels、logits、probs正文里的每个数字都能在这个 JSON 里找到。如果你改动了训练脚本,先确认这些字段还在,否则画图脚本会报 KeyError。
十二、常见坑与排查表
这一节按报错现象倒推,都是这次实际踩过或验证过的。
12.1 loss 不降,准确率停在 10% 左右
先查三件事:
- 初始化是不是全零?看
weight_norm,如果 3 轮后还不到 1,基本就是。 - 标签是不是错位?看训练 acc 和测试 acc 的差距,训练高、测试接近 0,就是错位。
- 学习率是不是太大?看首轮 loss,如果到几千,就是步长炸了。
这三条正好对应这一章的三组失败实验。
12.2 loss 变成 nan
常见原因:
- 输入没归一化,像素还是 0–255;
- 学习率太大,权重一步飞到 inf;
- 自定义损失里写了
log(0)或0/0; - 半精度训练时数值溢出(这一章用 float32,不涉及)。
排查顺序:先打印一个 batch 的 x.min()/x.max(),再打印首轮 loss,最后把学习率降 10 倍重跑。
12.3 形状报错
最常见的三个:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x28x28 and 784x256)忘了 nn.Flatten(),或者把 Flatten 放在了 Linear 后面。
RuntimeError: Expected 4-dimensional input for 4-dimensional weight ...把 CNN 的输入直接喂给了 Linear,或者相反。第 1 章只处理 (B,1,28,28) → (B,784),记住这个转换。
RuntimeError: CrossEntropyLoss: Expected target size ...标签形状不对。CrossEntropyLoss 要 logits (B,10) + target (B,),target 是 long 类型。如果 target 是 (B,1),先 squeeze(1);如果是 one-hot,用 softmax + NLLLoss 或把 one-hot 转成 index。
12.4 评估结果和训练结果差很多
先确认:
- 评估时有没有
model.eval(); - 有没有
torch.no_grad()(这个只影响速度和显存,不影响结果); - 测试集有没有被打乱或标签被改;
- 训练时用的预处理和测试时是否一致(Normalize 的均值/方差要相同)。
这一章的 evaluate 统一用 test_loader,预处理和训练集完全一致,所以不存在这个问题。但后面做数据增强时,训练用随机裁剪、测试用中心裁剪,如果写错,测试准确率会掉得很厉害。
12.5 训练很慢
三个方向:
DataLoader的num_workers设成 0(macOS 上有时多进程反而慢),这个网络数据量小,加载不是瓶颈;torch.set_num_threads(4)限制线程数,避免小矩阵乘在多线程下抢锁;- batch 太小,矩阵乘的形状撑不满 SIMD。64 已经够用,32 以下会明显变慢。
这一章实测 4 线程、batch=64,每轮 0.31 秒。如果你的机器更慢,10 分钟验收线仍然很宽松。
十三、这一章没解决的问题
网络跑通了,但有三件事仍然是黑箱:
第一,loss.backward() 到底算了什么。 它一行就把 203,530 个参数的梯度写好了。链式法则怎么在两层矩阵乘上展开、为什么 ReLU 的 0 会让一整条路径的梯度归零、为什么层数一深梯度就消失——这些要等第 2 章把反向传播手推一遍。
第二,为什么是 256。 这个宽度是拍的。128 也能到 94% 左右,512 会更慢但未必更准。宽度、深度、初始化方差、学习率调度,这些超参的选择需要第 3 章的方法。
第三,为什么测试准确率停在 95.45%。 91 个错例集中在 9、8、2,说明模型缺的是局部形状辨别。全连接层把图像拉平,空间结构丢了;第 4 章的 CNN 会用局部连接和参数共享把这块补上。
第 2 章的第一个实验,就是把这一章的 backward() 拆开:拿一个 2 输入、2 隐藏、1 输出的微型网络,手推一遍梯度,再和 PyTorch 的 .grad 逐个对比。如果这一章的 loss.backward() 你能接受它是黑箱,第 2 章会让你看到黑箱里面的齿轮。
自检
合上笔记,试着回答:
- 为什么第一层是 200,960 个参数,而不是 200,704 个?
nn.CrossEntropyLoss的输入是 logits 还是概率?在末尾加 softmax 会发生什么?- 训练循环的五步顺序是什么?
zero_grad放在backward后面会怎样? - 全零初始化时,为什么 loss 停在 2.3026、准确率停在 11.80%?
- 学习率 5.0 的第一轮 loss 为什么能到 9,529?
- 标签整体 +1 时,训练 acc 95.29%、测试 acc 0.85%,这两个数为什么能同时成立?
- 2,000 张测试图错 91 张,错误率是多少?错得最多的是哪一类?
- 这张 4 的 p₄=0.9873,它的交叉熵是多少?和平均测试 loss 0.1483 差在哪?
能答上六条,这一章的验收就通过了。答不上来的那条,回到对应小节,把数字重新代一遍,或者把脚本重跑一次,对照 metrics.json 看。
下一篇从 loss.backward() 开始,把这个黑箱拆成一层层的链式法则。