Skip to content

实战演练:用 PyTorch 搭出第一个 MLP ​

这是整套笔记第一次真的训练一个网络。

目标很具体:在 MNIST 子集上,用 784 → 256 → 10 的两层 MLP,5 个 epoch 内把测试准确率推到 95% 以上,并且能从日志里解释每一个数字。验收线不是「跑通就行」,而是「跑完之后,你知道它为什么是这个结果,也知道怎么把它弄坏」。

这一篇会按真实执行的顺序写:装环境、看数据、搭网络、算损失、写训练循环、跑基线、做三组失败实验、拆结果、最后验收。所有数字都来自 assets/metrics.json 和 train.log,你可以重跑一遍对账。

![MNIST 样本](/ai-four-kings/第 1 章:DNN 基础/assets/data_samples.svg)

<!-- 配图:5 张 28×28 手写数字,标注标签与 784 维输入 -->

一、环境与验收线 ​

1.1 为什么单独建一个 venv ​

系统 Python 里通常没有 PyTorch,也不该为了这一个项目污染全局环境。建一个临时 venv:

bash
python3 -m venv /tmp/dnn-venv
/tmp/dnn-venv/bin/pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
  torch torchvision

这次装到的是:

text
torch       2.8.0
torchvision 0.23.0
Python      3.12
平台        macOS arm64

确认一下:

bash
/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 个文件组成:

text
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 镜像之后,四个文件几十秒就下完了。

镜像地址的格式是:

text
https://cdn.jsdelivr.net/gh/leo-mao/MNIST_data@master/<文件名>

把 4 个文件(.gz 和解压后的文件都可以)放进 data/MNIST/raw/,再跑脚本就不会重新下载。目录长这样:

text
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.py

train_mlp.py 里有一行 os.chdir(here),把工作目录切到脚本所在目录,所以 ./data 永远指向 assets/data,不管你在哪里执行命令。

1.3 验收线 ​

这一章的验收不是「损失降了」,而是四条硬指标:

指标目标这次实测
训练集10,000 张(全量 60,000 的子集)10,000
测试集2,000 张(全量 10,000 的子集)2,000
训练轮数55
训练耗时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() 做两件事:

  1. 把 PIL.Image 变成 torch.Tensor,形状从 (28,28) 变成 (1,28,28);
  2. 把像素值从 0–255 缩放到 0–1。

然后 transforms.Normalize((0.1307,), (0.3081,)) 再做一次标准化:

text
x' = (x / 255 − 0.1307) / 0.3081

0.1307 是 MNIST 训练集的全局均值,0.3081 是全局标准差。标准化之后,像素大致落在 −0.42 到 2.82 之间,均值 0、标准差 1。

为什么非要这一步?因为第一层的预激活是 Σ wᵢxᵢ。如果 x 还在 0–255,同样一组权重算出来的 z 会大 255 倍,梯度也跟着放大;学习率稍微大一点就 NaN。理论篇提过这个坑,这里直接用标准化绕开它。

2.2 切分:10,000 / 2,000 ​

![数据切分与 batch](/ai-four-kings/第 1 章:DNN 基础/assets/split_diagram.svg)

<!-- 配图:60,000 训练集抽 10,000,10,000 测试集抽 2,000,batch=64 -->

代码:

python
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 结构 ​

![MLP 前向结构](/ai-four-kings/第 1 章:DNN 基础/assets/mlp_pipeline.svg)

<!-- 配图:784→256→10 的 MLP 流水线,标出每层参数量 -->

python
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)

四层,逐层看形状:

text
输入 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 参数量核对 ​

![参数构成](/ai-four-kings/第 1 章:DNN 基础/assets/param_breakdown.svg)

<!-- 配图:第一层 200,960,第二层 2,570,合计 203,530 -->

手算:

text
第一层:784×256 + 256 = 200,960
第二层:256×10 + 10   =   2,570
合计:203,530

代码核对:

python
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()))

输出:

text
net.1.weight (256, 784) 200704
net.1.bias   (256,)        256
net.3.weight (10, 256)    2560
net.3.bias   (10,)          10
total 203530

Sequential 的下标从 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 降得很慢、准确率卡住,但不会报错。

正确写法只有两种:

python
# 写法 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) 进来:

text
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 交叉熵 ​

![交叉熵曲线](/ai-four-kings/第 1 章:DNN 基础/assets/ce_diagram.svg)

<!-- 配图:−log p 曲线,标出 p=0.9873 与 p=0.1086 两个点 -->

对单张图:

text
L = −log p_y

p_y 是 softmax 之后正确类别的概率。预测对且自信,p_y 接近 1,L 接近 0;预测错,p_y 很小,L 很大。

对一个 batch,nn.CrossEntropyLoss() 默认对 batch 取平均:

text
L_batch = (1/B) Σᵢ −log p_{yᵢ}

代码:

python
ce = nn.CrossEntropyLoss()
logits = model(x)
loss = ce(logits, y)

想验证它内部真的做了 softmax,可以自己算一遍:

python
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:

python
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 评估函数 ​

python
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 次 ​

![训练循环](/ai-four-kings/第 1 章:DNN 基础/assets/training_loop.svg)

<!-- 配图:zero_grad → forward → loss → backward → step 五步循环 -->

python
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)

五步的顺序不能反:

  1. opt.zero_grad():清空上一轮的梯度。PyTorch 的梯度是累加的,不清零就会把上一个 batch 的梯度带进来。
  2. logits = model(x):前向。
  3. loss = ce(logits, y):算损失。
  4. loss.backward():反向,把 ∂L/∂θ 写进每个参数的 .grad。
  5. opt.step():按 θ ← θ − lr × grad 更新。

这一篇只关心这五步的顺序和形状;backward() 里面怎么展开链式法则,是第 2 章的主题。

优化器用 Adam:

python
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 日志 ​

text
[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耗时权重范数
10.491485.10%0.288291.30%0.337 s11.21
20.228793.11%0.208393.90%0.309 s12.30
30.159195.19%0.180494.95%0.309 s13.29
40.111196.86%0.166594.95%0.309 s14.18
50.081697.60%0.148395.45%0.313 s14.98

![基线训练曲线](/ai-four-kings/第 1 章:DNN 基础/assets/train_curves.svg)

<!-- 配图: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。训练在把权重推向更大的绝对值,让决策边界更「自信」。这不是坏事,但如果范数爆炸式增长,通常意味着学习率太大或正则不够。

![权重范数曲线](/ai-four-kings/第 1 章:DNN 基础/assets/weight_norm.svg)

<!-- 配图:基线 Adam 的权重范数随轮数上升,对照全零初始化几乎不动 -->

6.3 为什么 1.58 秒能跑完 ​

5 个 epoch 的纯训练时间是:

text
0.337 + 0.309 + 0.309 + 0.309 + 0.313 = 1.577 s

每个 epoch 10,000 张,单样本 203,264 次乘加:

text
10,000 × 203,264 = 2,032,640,000 ≈ 20.3 亿 MAC

1.58 秒做完 5 轮,等效约 64 亿 MAC/s。这不是 PyTorch 有多神,是矩阵乘的形状太适合 BLAS:(64,784) @ (784,256) 和 (64,256) @ (256,10),两个都是大块连续内存的 GEMM,CPU 的 SIMD 和缓存能吃满。

这也解释了为什么这一章敢用 CPU。真正的瓶颈不是这个网络,是后面的 CNN 和数据加载。

6.4 验收卡 ​

![验收结果](/ai-four-kings/第 1 章:DNN 基础/assets/acceptance.svg)

<!-- 配图:训练耗时、准确率、参数量、错例数、轮数的验收卡 -->

四条硬指标全部达标:

text
训练耗时    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 怎么把初始化改成全零 ​

python
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 结果 ​

text
[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

![全零初始化曲线](/ai-four-kings/第 1 章:DNN 基础/assets/zero_init.svg)

<!-- 配图: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 为什么 ​

![全零初始化的对称性](/ai-four-kings/第 1 章:DNN 基础/assets/zero_init_symmetry.svg)

<!-- 配图:三个隐藏单元权重完全相同,输出、梯度、更新都相同 -->

一层 256 个隐藏单元,如果权重和偏置全是 0:

  1. 每个单元收到的输入相同,z = 0,输出 ReLU(0) = 0 相同;
  2. 反向时,每个单元收到的上游梯度相同;
  3. 因为输入相同、上游梯度相同,每个单元的 ∂L/∂W 和 ∂L/∂b 完全相同;
  4. 梯度下降给它们的更新量一模一样,更新后仍然完全相同。

一轮之后,256 个单元还是 256 个副本。它们永远保持相同,实际只有一个单元在工作。这叫「对称性无法打破」。

输出层的偏置会动,因为不同类别的梯度不同;所以模型最终学会的是类别先验(把所有人都猜成最多的那一类),而不是图像特征。这就是为什么准确率恰好等于 1 类的占比,loss 略低于 ln 10(2.3016 < 2.3026)但又降不下去。

7.4 正确做法 ​

PyTorch 的 nn.Linear 默认用 Kaiming Uniform 初始化,每个单元的权重从不同的随机数开始,对称性自然就破了。你不需要自己写初始化,只要别手贱把它清零。

初始化的方差怎么选、为什么 ReLU 配 Kaiming、深层网络为什么要控制方差,是第 3 章的内容。这一章只需要记住:全零初始化不是「起点对称」,是「起点重合」。

八、失败实验二:学习率 5.0 ​

8.1 怎么设 ​

python
opt = torch.optim.SGD(model.parameters(), lr=5.0)

换成 SGD,学习率 5.0,跑 3 个 epoch。为什么不用 Adam?因为 Adam 会自适应缩放,lr=5.0 在 Adam 里可能直接被裁剪成小步;SGD 是裸步长,能把「步长太大」这件事演得最清楚。

8.2 结果 ​

text
[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

![学习率 5.0 的 loss](/ai-four-kings/第 1 章:DNN 基础/assets/large_lr.svg) <!-- 配图:对数轴,第一轮 loss 9,529,之后回落到 2.4,准确率仍 11.8% -->

第一轮 loss 是 9,529.50,不是 9.5,是九千五百。权重范数从正常的十几跳到了 35,157。第二轮 loss 回落到 2.41,但准确率还是 11% 左右,说明权重已经被推到一个很差的区域,来回震荡,回不来了。

8.3 为什么会到 9,529 ​

SGD 的更新是:

text
θ ← θ − 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.10.542393.80%
0.50.799794.70%
1.03.011153.20%
2.09.213314.20%
5.09,529.499111.80%

![学习率对比](/ai-four-kings/第 1 章:DNN 基础/assets/lr_compare.svg)

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

![首轮 loss 与学习率](/ai-four-kings/第 1 章:DNN 基础/assets/lr_trajectory.svg)

<!-- 配图:首轮 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 怎么造 ​

在训练循环里改标签:

python
if shift:
    y = (y + shift) % 10

shift=1 的意思是:训练时把 0 当 1、1 当 2、……、9 当 0。测试时不改标签,仍然用原始 0–9。

![标签整体 +1](/ai-four-kings/第 1 章:DNN 基础/assets/label_shift_diagram.svg)

<!-- 配图:原标签 0–9 映射到 1–0,测试仍用原标签 -->

9.2 结果 ​

text
[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

![标签错位的训练/测试曲线](/ai-four-kings/第 1 章:DNN 基础/assets/label_shift.svg)

<!-- 配图:训练 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 张碰巧被预测对了。原因有两类:

  1. 模型对某些图本来就不确定,argmax 没落在「真标签+1」上;
  2. 个别图在 +1 之后恰好和另一个类的特征重合。

这不影响结论:标签错位是灾难性的,而且训练指标完全看不出来。

9.4 怎么防 ​

三个检查,训练开始前做一遍:

python
# 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 混淆矩阵 ​

![混淆矩阵](/ai-four-kings/第 1 章:DNN 基础/assets/confusion_matrix.svg)

<!-- 配图: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%。

![每类准确率](/ai-four-kings/第 1 章:DNN 基础/assets/per_class_acc.svg)

<!-- 配图:0–9 每类准确率柱状图,9 类最低 -->

几个高频混淆:

text
9 → 4:6 张
9 → 7:5 张
9 → 3:4 张
8 → 4:3 张
2 → 7:5 张

这不是随机错误。9、8、2 都是「上半圈 + 下半笔」的结构,4 是「斜线 + 横线」,在 784 维的线性边界里,它们的投影很近。两层 MLP 只能画一条(或几段)线性边界,遇到写法模糊的样本就会站错边。

10.2 错例墙 ​

![8 个错例](/ai-four-kings/第 1 章:DNN 基础/assets/misclassified.svg)

<!-- 配图:8 张错例,真实标签、预测标签、p_true、p_pred -->

挑第一张:真实 8,预测 4:

text
p_true  = 0.1086
p_pred  = 0.8465
loss    = −log 0.1086 = 2.2201

模型不是「完全不会」,它给真类留了 10.9% 的概率,只是把 4 排在了第一。看 argmax 是「错」,看概率是「犹豫」。

第二张:真实 3,预测 5:

text
p_true  = 0.1146
p_pred  = 0.8849

第三张:真实 2,预测 3:

text
p_true  = 0.1193
p_pred  = 0.6135

第三张的 p_pred 只有 0.61,是典型的「边界样本」:模型在 2 和 3 之间摇摆,最后选了 3。

10.3 一张正确样本的完整账 ​

![正确预测示例](/ai-four-kings/第 1 章:DNN 基础/assets/prediction_example.svg)

<!-- 配图:一张真实 4 的图,10 个 softmax 概率,p4=0.9873 -->

text
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:

text
(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 文件清单 ​

text
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
MLP784→256→10hidden
evaluate测试集 loss/accshift
weight_norm所有参数的二范数无
run一次完整实验epochs、lr、opt_name、zero_init、shift
confusion_matrix10×10 混淆矩阵无
misclassified取前 8 个错例limit
sample_digits取前 5 张测试图limit
example_prediction取一张正确样本的 logits/probs无
main跑基线 + 三组失败 + 扫描无

run 每次开始都调用 set_seed(),所以每个实验的初始化、数据打乱顺序都一样。这是能对比的前提:如果每个实验的随机种子不同,0.5 个百分点的差异可能只是噪声。

11.3 重跑 ​

bash
cd ".../第 1 章:DNN 基础/assets"
/tmp/dnn-venv/bin/python train_mlp.py

整套脚本跑完大约 10–15 秒(29 个 epoch,每轮 0.3 秒,加上数据加载和评估)。输出会写到 train.log 和 metrics.json。

只想跑基线,改 main():

python
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)

想换隐藏宽度:

python
model = MLP(hidden=128)   # 参数量变成 101,770

想换优化器:

python
opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)

改完记得重跑,并检查 metrics.json 里的 params 和 baseline[0]。

11.4 metrics.json 的字段 ​

text
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% 左右 ​

先查三件事:

  1. 初始化是不是全零?看 weight_norm,如果 3 轮后还不到 1,基本就是。
  2. 标签是不是错位?看训练 acc 和测试 acc 的差距,训练高、测试接近 0,就是错位。
  3. 学习率是不是太大?看首轮 loss,如果到几千,就是步长炸了。

这三条正好对应这一章的三组失败实验。

12.2 loss 变成 nan ​

常见原因:

  • 输入没归一化,像素还是 0–255;
  • 学习率太大,权重一步飞到 inf;
  • 自定义损失里写了 log(0) 或 0/0;
  • 半精度训练时数值溢出(这一章用 float32,不涉及)。

排查顺序:先打印一个 batch 的 x.min()/x.max(),再打印首轮 loss,最后把学习率降 10 倍重跑。

12.3 形状报错 ​

最常见的三个:

text
RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x28x28 and 784x256)

忘了 nn.Flatten(),或者把 Flatten 放在了 Linear 后面。

text
RuntimeError: Expected 4-dimensional input for 4-dimensional weight ...

把 CNN 的输入直接喂给了 Linear,或者相反。第 1 章只处理 (B,1,28,28) → (B,784),记住这个转换。

text
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 训练很慢 ​

三个方向:

  1. DataLoader 的 num_workers 设成 0(macOS 上有时多进程反而慢),这个网络数据量小,加载不是瓶颈;
  2. torch.set_num_threads(4) 限制线程数,避免小矩阵乘在多线程下抢锁;
  3. 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() 开始,把这个黑箱拆成一层层的链式法则。