实战演练:把 MLP 训到 98% 并画诊断曲线
前两篇解决的是「知道该动哪个旋钮」,这一篇解决「把旋钮拧到位,并且证明它到位」。
目标写死:在 MNIST 测试集上,把 784→256→128→10 的全连接网络训到 98% 以上。只开一次测试集,训练过程可复现,每个结论都有一条曲线或一张表支撑。
这一篇不换模型族,不加卷积,不引入新数据。所有代码都在 assets/run_experiments.py 里,所有数字都在 assets/metrics.json 里。下面的代码块只删掉与本节无关的实验分支,训练与评估逻辑没有改写。
| 阶段 | 要回答的问题 | 对应小节 |
|---|---|---|
| 数据 | 三份数据各自能做什么、不能做什么 | 第一节 |
| 流水线 | 一个 epoch 里顺序错了会怎样 | 第二节 |
| 模型 | 235,934 个参数从哪来 | 第三节 |
| 训练 | 每一轮的 loss、acc、梯度怎么记 | 第四、五节 |
| 选模型 | 早停看 loss 还是看 acc | 第六、七节 |
| 错误分析 | 180 个错误长什么样 | 第八、九节 |
| 诊断 | 曲线异常时先查什么 | 第十、十二节 |
| 验收 | 98% 是运气还是可复现 | 第十一节 |
一、先把数据切干净
运行环境固定,避免「换了台机器数字就对不上」:
| 项 | 值 | 为什么写出来 |
|---|---|---|
| Python 解释器 | /private/tmp/dnn-venv/bin/python | 隔离依赖,不污染系统环境 |
| PyTorch | 2.8.0 | BN、Dropout、Adam 的默认行为随版本变过 |
| NumPy | 2.0.2 | 划分与初始化都用它 |
| 线程数 | torch.set_num_threads(4) | 线程数不同,浮点累加顺序不同,末位会有差异 |
| 随机种子 | 20260815 | 划分、初始化、batch 顺序共用 |
种子在三个地方各设一次:
torch.manual_seed(SEED)
np.random.seed(SEED)
rng = np.random.default_rng(SEED)前两个管 PyTorch 与 NumPy 的全局随机状态,第三个是划分数据用的独立生成器。三个都要设,只设一个的话,另一条路径仍然每次跑出不同结果。
MNIST 一共 70,000 张:官方训练集 60,000,官方测试集 10,000。本实验从 60,000 里再切出 10,000 做验证,剩下 50,000 才用来更新参数。

<!-- 配图:70,000 张按 50k/10k/10k 分成三条,下面三张卡片写各自职责;底部写标准化统计量与数据泄漏 -->
| 数据 | 数量 | 每轮用几次 | 能做什么 | 不能做什么 |
|---|---|---|---|---|
| 训练集 | 50,000 | 781 步,每步 64 张 | 更新参数、算 BN 的 batch 统计、算标准化 mean/std | 不能用来报最终指标 |
| 验证集 | 10,000 | 1 次前向 | 早停、选模型、调超参 | 不能反传,不能打乱后参与训练 |
| 测试集 | 10,000 | 全程 1 次 | 只报最终指标 | 不参与任何决策 |
划分只做一次,种子固定为 20260815:
rng = np.random.default_rng(SEED)
perm = rng.permutation(len(xa))
va_idx, tr_idx = perm[:10000], perm[10000:]
xr_tr, y_tr = xa[tr_idx], ya[tr_idx]
xr_va, y_va = xa[va_idx], ya[va_idx]注意顺序:先划分索引,再在训练子集上算标准化统计量。
xf_tr = xr_tr.astype(np.float32) / 255.0
xf_va = xr_va.astype(np.float32) / 255.0
xf_te = xt.astype(np.float32) / 255.0
mean = float(xf_tr.mean()) # 0.130612
std = float(xf_tr.std()) # 0.308064
x_std_tr = (xf_tr - mean) / std
x_std_va = (xf_va - mean) / std
x_std_te = (xf_te - mean) / std如果先对 70,000 张一起算 mean/std,再划分,验证集和测试集的像素分布已经进入了训练流程。这叫数据泄漏。它不会让当次指标变差,只会让指标变得不可信。
50,000 张、batch 64,一轮的步数是:
⌊50,000 / 64⌋ = 781
781 × 64 = 49,984每轮有 16 张凑不满一个 batch,被丢弃。30 轮累计的样本前向次数:
781 × 64 × 30 = 1,499,520这个数字后面用来解释训练时间,不用来吹吞吐。
设计取舍:50k/10k/10k 还是 60k/10k
| 方案 | 训练集 | 验证集 | 代价 |
|---|---|---|---|
| A:60k 训练 + 10k 验证 | 60,000 | 10,000 | 没有独立测试集,最后只能拿验证集报成绩 |
| B:50k/10k/10k(本章) | 50,000 | 10,000 | 少 10,000 张训练样本,训练指标会略低 |
本章选 B。原因是验收需要一个从未参与任何决策的集合。验证集一旦被用来早停和调参,它就已经是训练的一部分;再从验证集里报 98%,说服力会打折。代价是训练集少了六分之一,收敛会慢一点——这个代价在第五节的曲线上能直接看到。
二、一个 epoch 的流水线
一个 epoch 不是「把所有图片喂一遍」这么简单。它是一串有顺序的操作,顺序错了,指标会骗你。

<!-- 配图:两行流程,前向四个节点、验证与早停四个节点;底部列四个顺序问题与总样本前向次数 -->
训练部分的代码:
def train_one_epoch(model, opt, xtr, ytr, perm, n_batches):
model.train()
total_loss, correct, seen = 0.0, 0, 0
for b in range(n_batches):
idx = perm[b * BATCH:(b + 1) * BATCH]
xb, yb = xtr[idx], ytr[idx]
opt.zero_grad()
logits = model(xb)
loss = F.cross_entropy(logits, yb)
if not torch.isfinite(loss):
return None
loss.backward()
opt.step()
total_loss += float(loss.item()) * len(idx)
correct += int((logits.argmax(1) == yb).sum())
seen += len(idx)
return total_loss / seen, correct / seen这段代码里有四个容易写反的地方。
① 先 zero_grad() 再 backward()
PyTorch 的 .grad 是累加的。如果不清零,上一批的梯度会加进来,等效学习率翻倍。短跑可能只是 loss 抖,长跑会直接发散。
② 训练用 train(),评估用 eval()
model.train() 打开 Dropout,并让 BN 用当前 batch 的均值方差;model.eval() 关闭 Dropout,并让 BN 用训练期间累计的 running stats。漏掉 eval(),验证集每轮都会随机丢 20% 的隐单元,指标会偏低而且抖动。
③ 验证集只做前向
验证集的作用是「裁判」。任何基于验证集的 backward() 都会让裁判变成运动员。本实验的 evaluate() 包在 torch.no_grad() 里,只返回 loss、acc 和 logits。
④ 早停后回滚 best 权重
训练到第 30 轮,不代表第 30 轮的权重最好。第 25 轮验证 loss 见底,后面 5 轮在往回走。如果只记「停在第几轮」而不保存权重,最终用的是更差的那一版。本实验在每次验证 loss 创新低时克隆一份 state_dict,训练结束再 load_state_dict() 回去。
评估函数很短:
def evaluate(model, x, y):
model.eval()
with torch.no_grad():
logits = model(x)
loss = float(F.cross_entropy(logits, y).item())
acc = float((logits.argmax(1) == y).float().mean().item())
return loss, acc, logits本实验 30 轮总训练时间 23.103 秒,平均每轮:
23.103 / 30 = 0.770 秒单轮在 0.587–1.46 秒之间波动,差异主要来自机器负载,不是模型在某几轮突然变慢。这个量级说明:本章的瓶颈在设计实验和读结果,不在算力。
三、模型与 235,934 个参数
最终配置是 784→256→128→10,每个 Linear 后面接 BN,隐层接 ReLU 和 Dropout 0.2。

<!-- 配图:左侧三层网络结构与每层参数,右侧参数表与优化正则配置;底部写 BN 输出层 20 个参数 -->
模型类比之前多了两个东西:BN 和 Dropout。
class MLP(nn.Module):
"""全连接网络;BN 放在 Linear 之后、激活之前。"""
def __init__(self, sizes, act="relu", norm=None, dropout=0.0):
super().__init__()
self.linears = nn.ModuleList(
[nn.Linear(fi, fo) for fi, fo in zip(sizes[:-1], sizes[1:])]
)
self.bns = nn.ModuleList(
[nn.BatchNorm1d(fo) for fi, fo in zip(sizes[:-1], sizes[1:])]
) if norm == "bn" else None
self.act_name = act
self.act = ACTS[act]()
self.dropout = dropout
self.drop = nn.Dropout(dropout)
self.n_layers = len(self.linears)
def forward(self, x, stats=False):
h = x
out = {"z": [], "a": []}
for i, lin in enumerate(self.linears):
z = lin(h)
if self.bns is not None:
z = self.bns[i](z)
if i == self.n_layers - 1:
return (z, out) if stats else z
a = self.act(z)
out["z"].append(z)
out["a"].append(a)
h = self.drop(a) if (self.training and self.dropout > 0) else a
return (h, out) if stats else h参数量的手算:
| 层 | 形状 | 权重 | 偏置 | BN | 小计 |
|---|---|---|---|---|---|
| Linear 1 | 784 → 256 | 200,704 | 256 | 512 | 201,472 |
| Linear 2 | 256 → 128 | 32,768 | 128 | 256 | 33,152 |
| Linear 3 | 128 → 10 | 1,280 | 10 | 20 | 1,310 |
| 合计 | — | 234,752 | 394 | 788 | 235,934 |
踩坑记录
- 现象:手算
200,960 + 32,896 + 1,290 = 235,146,再加上 BN 的 768,得到 235,914;模型sum(p.numel())却报 235,934,差 20。 - 原因:
self.bns是对zip(sizes[:-1], sizes[1:])的每一层都建 BN,输出层 10 维也建了一个BatchNorm1d(10),多出 10 个 scale 和 10 个 shift。手算时只给两个隐层算了 BN。 - 修复:不改代码,接受这 20 个参数。它让输出 logits 也被归一化,训练更稳;如果要去掉,就在
bns里跳过最后一层,但那会让输出尺度重新暴露给 CrossEntropy,需要重新调 lr。本章选择保留,并在参数表里如实写出 BN 3 = 20。
逐层参数量:
784 × 256 + 256 = 200,960
256 × 128 + 128 = 32,896
128 × 10 + 10 = 1,290
BN: 256×2 + 128×2 + 10×2 = 788
合计 = 235,934设计取舍:235,934 带正则,还是 535,818 不带正则
第六节的过拟合实验给了一个对照:784→512→256→10,535,818 个参数,不加 BN、不加 Dropout、不早停,30 轮后训练 loss 0.0114、验证 loss 0.1753、测试 97.96%。
| 方案 | 参数量 | 正则 | 测试 acc | 代价 |
|---|---|---|---|---|
| A:535,818 无正则 | 535,818 | 无 | 97.96% | 训练 loss 0.0114,验证 loss 0.1753,过拟合明显 |
| B:235,934 带正则(本章) | 235,934 | BN + Dropout 0.2 + 早停 | 98.20% | 多三个组件,调参面变大;BN 在 batch=1 时不可用 |
本章选 B。它不是「更大的网络一定更好」,恰恰相反:B 的参数不到 A 的一半,测试高 0.24 个百分点。代价是组件更多,任何一个用错(比如评估忘 eval())都会让结果失真。这也是第二节要先讲流水线顺序的原因。
构建与优化器:
model = MLP(cfg["sizes"], act=cfg["act"], norm=cfg.get("norm"),
dropout=cfg.get("dropout", 0.0))
init_weights(model, cfg["init"], SEED)
opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)init_weights 用 He 初始化,Var(w) = 2 / fan_in。BN 的 weight 初始化为 1、bias 为 0,这两组参数不参与 He 初始化。
四、训练循环:每个数字都在这里产生
完整循环比 train_one_epoch 多三件事:记录历史、跑探针、早停与回滚。
best = {"epoch": 0, "val_loss": float("inf"), "val_acc": 0.0}
best_state = None
history = []
for ep in range(cfg["epochs"]):
perm = data["epochs_idx"][ep % len(data["epochs_idx"])]
out = train_one_epoch(model, opt, xtr, ytr, perm, n_batches)
if out is None:
break
train_loss, train_acc = out
val_loss, val_acc, _ = evaluate(model, xva, yva)
pr = probe(model, probe_x, probe_y)
history.append({
"epoch": ep + 1,
"train_loss": round(train_loss, 6),
"train_acc": round(train_acc, 6),
"val_loss": round(val_loss, 6),
"val_acc": round(val_acc, 6),
"grad_norm": pr["grad_norm"],
"act_std": pr["act_std"],
"sat_ratio": pr["sat_ratio"],
"weight_norm": pr["weight_norm"],
})
if val_loss < best["val_loss"] - 1e-6:
best = {"epoch": ep + 1, "val_loss": round(val_loss, 6),
"val_acc": round(val_acc, 6)}
best_state = {k: v.detach().clone()
for k, v in model.state_dict().items()}
if ep + 1 - best["epoch"] >= cfg["patience"]:
break
model.load_state_dict(best_state)几个细节值得单独说。
batch 顺序是预生成的
epochs_idx = [rng.permutation(len(xf_tr)) for _ in range(40)]40 轮顺序在训练前一次性生成,所有配置共用同一组。这样 C0 与 C4 的差异只来自配置,不来自「这一轮碰巧抽到难样本」。如果每轮现抽,单次实验的方差会混进对照结果。
探针用固定 512 张验证样本
probe_x = xva[:512]
probe_y = yva[:512]探针不是用来选模型的,是用来观察内部状态的。它固定不变,所以第 1 轮到第 30 轮的梯度范数、激活标准差可以直接相减。如果每轮换一批样本,「梯度变小」可能只是这批样本更简单。
探针函数的关键一行:
g = torch.autograd.grad(loss, lin.weight, retain_graph=True)[0]
out["grad_norm"].append(round(float(g.norm()), 6))它只求梯度,不更新参数。@torch.enable_grad() 是必需的,因为外层评估通常关梯度;探针要的正是梯度。
更新比例
除了梯度范数,循环还在每轮最后一个 batch 记录了「权重更新量 / 更新前权重范数」:
before = [lin.weight.detach().clone() for lin in model.linears]
opt.step()
upd_ratio = []
for i, lin in enumerate(model.linears):
d = (lin.weight.detach() - before[i]).norm()
wn = before[i].norm() + 1e-12
upd_ratio.append(round(float(d / wn), 6))实测:
| 层 | 第 1 轮 | 第 30 轮 | 变化 |
|---|---|---|---|
| Linear 1 | 0.003954 | 0.003623 | −8.4% |
| Linear 2 | 0.002204 | 0.002805 | +27.3% |
| Linear 3 | 0.001711 | 0.001589 | −7.1% |
三层都停在 1e−3 量级,没有一层掉到 1e−6。这直接排除了「梯度消失到无法更新」。第 2 层的更新比例反而上升,是因为 BN 的 scale 在训练中逐渐放大,同样的梯度经过 BN 后对权重的影响更大。这个解释不是猜的:第十节的权重范数显示第 2 层权重几乎没变(15.95→15.60),但它的激活标准差从 0.576 降到 0.242,BN 把信号压紧后,相对的更新比例自然上升。
五、30 轮 loss 与准确率
先看 loss。

<!-- 配图:左图全程 0.02–0.56,右图放大第 5–30 轮的 0.04–0.13;best ep25 竖线与末轮标注 -->
关键节点:
| 轮次 | 训练 loss | 验证 loss | 训练 acc | 验证 acc |
|---|---|---|---|---|
| 1 | 0.5437 | 0.2350 | 89.39% | 96.02% |
| 5 | 0.1229 | 0.0788 | 96.93% | 97.78% |
| 10 | 0.0743 | 0.0660 | 97.95% | 98.07% |
| 15 | 0.0600 | 0.0578 | 98.30% | 98.37% |
| 20 | 0.0513 | 0.0636 | 98.41% | 98.08% |
| 25 | 0.0487 | 0.0505 | 98.58% | 98.46% |
| 30 | 0.0438 | 0.0563 | 98.70% | 98.31% |
训练 loss 从 0.5437 降到 0.0438,降幅:
(0.5437 − 0.0438) / 0.5437 = 91.94%验证 loss 最低是第 25 轮的 0.050489,比第 1 轮降了 78.51%。之后 5 轮回升到 0.056275:
(0.056275 − 0.050489) / 0.050489 = +11.46%训练 loss 还在往下走,验证 loss 已经掉头。这是过拟合的标准形状,只是幅度不大:训练与验证的 loss 差从第 1 轮的 0.309(训练反而更高)变成第 30 轮的 0.0125。
早期训练 loss 比验证 loss 高,不是 bug。训练 loss 是整个 epoch 的平均,第 1 轮前半段权重还很差,把平均值拉高了;验证 loss 是 epoch 结束后用最终权重算的。第 2 轮开始这个关系就翻过来了。
再看准确率。

<!-- 配图:训练、验证、测试三条线;测试 98.20% 画成水平线;右侧标 best val 98.46% 与末轮 98.31% -->
30 轮的端点:
| 指标 | 第 1 轮 | 最好 | 第 30 轮 |
|---|---|---|---|
| 训练 acc | 89.39% | 98.70% | 98.70% |
| 验证 acc | 96.02% | 98.46%(第 25 轮) | 98.31% |
| 测试 acc | — | — | 98.20% |
测试集只在前 25 轮的 best 权重恢复之后开一次:
test_loss, test_acc, logits = evaluate(model, x_std_te, y_test)结果是 test loss 0.052155,test acc 98.20%。10,000 张里对了 9,820 张,错 180 张。
验证 98.31% 与测试 98.20% 差 0.11 个百分点,在 10,000 张的抽样误差范围内。验证最好那一轮 98.46%,测试低 0.26 个百分点,也正常:best 是按验证 loss 选的,选中的那一轮在验证集上本来就会略好看。
六、gap:为什么验证一开始比训练高
把每一轮的「训练 acc − 验证 acc」画出来,会看到一条从负到正的线。

<!-- 配图:gap 曲线从 −6.63 回到 +0.39;零线;标注第 12 轮首次转正、第 14 轮后基本为正 -->
| 轮次 | 训练 acc | 验证 acc | gap |
|---|---|---|---|
| 1 | 89.39% | 96.02% | −6.63 |
| 2 | 94.60% | 97.06% | −2.46 |
| 3 | 95.86% | 97.47% | −1.61 |
| 5 | 96.93% | 97.78% | −0.85 |
| 9 | 97.92% | 98.24% | −0.32 |
| 12 | 98.12% | 98.08% | +0.04 |
| 13 | 98.20% | 98.27% | −0.07 |
| 14 | 98.30% | 98.19% | +0.11 |
| 25 | 98.58% | 98.46% | +0.12 |
| 30 | 98.70% | 98.31% | +0.39 |
30 轮 gap 的均值是 −0.42 个百分点,最后停在 +0.39。整条曲线没有出现「训练 99.9、验证 96」那种大开口。
前 11 轮 gap 为负,原因有两个,都跟测量方式有关,不是模型「验证比训练强」。
① 训练 acc 是边训边算的
训练 acc 在每步更新前算:第 1 轮的前 100 步,权重只见过 6,400 张图,准确率很低;这些低分样本拉低了整轮平均。验证 acc 是整轮结束后用最终权重算的。
② Dropout 与 BN 的模式不同
训练时 Dropout 随机丢 20% 的隐单元,BN 用当前 batch 的统计量;验证时两者都切到推理模式,相当于用了一个「完整且统计更稳」的网络。所以同一组权重,验证 acc 通常会略高。
第 12 轮之后 gap 转正,但一直在 ±0.4 个百分点内。它说明模型没有严重过拟合,也说明不能用 gap 的正负判断好坏:早期为负是测量顺序造成的,晚期为正才是容量开始记住训练集。
七、早停与模型选择
早停有两个问题:看哪个指标,等多少轮。
先看一个「该停没停」的反例。784→512→256→10、535,818 参数、无 BN、无 Dropout,跑 30 轮,patience=4。

<!-- 配图:大网络训练/验证 loss 曲线;best ep4、stop ep8;第 8 轮训练 0.0302、验证 0.1079 -->
| 轮次 | 训练 loss | 验证 loss | 训练 acc | 验证 acc |
|---|---|---|---|---|
| 1 | 0.2070 | 0.1172 | 93.55% | 96.32% |
| 4 | — | 0.1015 | — | 97.30% |
| 8 | 0.0302 | 0.1079 | 99.06% | 97.32% |
第 4 轮验证 loss 见底 0.101518,之后 4 轮没再创新低,第 8 轮停止并回滚到第 4 轮。测试集 96.86%。如果不停,训练 loss 会继续掉,验证 acc 基本不动,测试只会更差。
早停代码就是第四节里的那段,核心是两个条件:
if val_loss < best["val_loss"] - 1e-6:
best = {"epoch": ep + 1, "val_loss": val_loss, "val_acc": val_acc}
best_state = {k: v.detach().clone() for k, v in model.state_dict().items()}
if ep + 1 - best["epoch"] >= cfg["patience"]:
break- 1e-6 是容差,防止浮点噪声把「没进步」误判成「进步」。patience=4 表示连续 4 轮没有更低就停;最终模型用 6,因为它更大、验证曲线更平,需要更长窗口。
设计取舍:早停看 val loss 还是 val acc
这不是理论问题,用小数据(10,000 张训练)能直接测出来。

<!-- 配图:同一份 10k 数据,按 loss 停在第 4 轮测试 95.40%,按 acc 跑到第 30 轮测试 96.71% -->
| 方案 | 停在哪轮 | 训练 acc | 验证 acc | 测试 acc | 验证 loss |
|---|---|---|---|---|---|
| A:按 val loss | 4 | 98.34% | 95.33% | 95.40% | 0.1705 |
| B:按 val acc | 30 | 100.00% | 96.69% | 96.71% | 0.2062 |
| 方案 | 依据 | 优点 | 代价 |
|---|---|---|---|
| A:val loss | 连续 4 轮 loss 不降就停 | 对概率质量敏感,早停更早,省算力 | 小数据上容易过早;本实验测试低 1.31 个百分点 |
| B:val acc | acc 创新高才更新 best | 直接对齐分类验收指标 | 对置信度不敏感;本实验训练 acc 已 100%,验证 loss 涨了 20.9% 仍在「进步」 |
本章的最终模型两个标准都指向第 25 轮:val loss 0.050489、val acc 98.46% 都是第 25 轮最高。所以这次没有出现冲突。但小数据实验说明:如果验收只看 acc,就按 acc 选模型;如果要输出概率或做校准,loss 必须一起看。 只按 loss 早停,可能停在一个 acc 还差 1.31 个百分点的位置。
最终模型的早停参数:
patience = 6
best = ep25, val_loss = 0.050489, val_acc = 0.9846
stopped_at = ep30
restored = Truerestored=True 表示测试前确实把权重回滚到了第 25 轮,而不是用了第 30 轮的权重。
八、混淆矩阵:180 个错误分布在哪
测试集 9,820 对、180 错。混淆矩阵按「真实行、预测列」统计。

<!-- 配图:10×10 混淆矩阵热力图,对角线深色;格内写数量,颜色按行归一化 -->
cm = np.zeros((10, 10), dtype=int)
for t, p in zip(y_test, logits.argmax(1)):
cm[t, p] += 1对角线是各类召回:
| 真实类 | 样本数 | 召回 | 精确率 | F1 |
|---|---|---|---|---|
| 0 | 980 | 99.49% | 98.19% | 98.83% |
| 1 | 1,135 | 99.30% | 98.86% | 99.08% |
| 2 | 1,032 | 98.16% | 97.97% | 98.06% |
| 3 | 1,010 | 97.82% | 98.41% | 98.11% |
| 4 | 982 | 98.37% | 97.58% | 97.97% |
| 5 | 892 | 97.98% | 98.09% | 98.04% |
| 6 | 958 | 98.33% | 98.43% | 98.38% |
| 7 | 1,028 | 97.37% | 98.23% | 97.80% |
| 8 | 974 | 98.15% | 97.95% | 98.05% |
| 9 | 1,009 | 96.93% | 98.19% | 97.56% |
最低的三项:
- 召回最低:类 9,96.93%(978/1,009),有 31 张 9 被分到别的类。
- 精确率最低:类 4,97.58%(966/990),有 24 张别的类被当成 4。
- F1 最低:类 9,97.56%。
每类的三项指标画在一起:

<!-- 配图:10 类三组柱状图(精确率、召回、F1),类 9 召回最低,类 4 精确率最低 -->
类 9 的问题不是「认不出 9」,而是「9 被认成 4」。列 4 的假阳性来源里,9→4 有 13 张,是所有单对错误里最多的。类 4 的精确率因此被拉低。
把非对角线的错误按数量排序:

<!-- 配图:前 8 个「真实→预测」对的水平条形图;9→4 13 张最高,前 8 对合计 67 张 -->
| 排名 | 真实 → 预测 | 数量 |
|---|---|---|
| 1 | 9 → 4 | 13 |
| 2 | 7 → 2 | 10 |
| 3 | 4 → 9 | 9 |
| 4 | 3 → 5 | 9 |
| 5 | 5 → 3 | 7 |
| 6 | 2 → 7 | 7 |
| 7 | 7 → 1 | 6 |
| 8 | 6 → 4 | 6 |
前 8 对一共 67 张,占 180 个错误的:
67 / 180 = 37.2%这些错误有个共同点:成对出现。
| 对 | 双向合计 | 共同结构 |
|---|---|---|
| 9 ↔ 4 | 13 + 9 = 22 | 上面一个圈,右边一条竖;4 的竖线更长,9 的圈更圆 |
| 7 ↔ 2 | 10 + 7 = 17 | 顶部横线 + 斜线;2 的底部横线在 7 里没有 |
| 3 ↔ 5 | 9 + 7 = 16 | 两个弧;3 的下弧向左,5 的上弧向左 |
这就是全连接网络的盲区:28×28 被拉平成 784 维,相邻像素的空间关系在权重里要靠数据自己学。9 和 4 的差别只在「圈和竖线怎么接」,这个局部结构对全连接层不友好。第 4 章的卷积就是为这件事准备的。
九、置信度:错的时候有多确定
准确率只回答「对了多少」,置信度回答「错的时候有多自信」。对客服、医疗这类场景,后者更危险。

<!-- 配图:正确与错误样本的置信度分布,对数刻度;错误样本在 0.60–1.00 之间铺开,40 张 ≥0.95 -->
| 统计量 | 数值 |
|---|---|
| 正确样本平均置信度 | 0.990107 |
| 错误样本平均置信度 | 0.682185 |
| 错误样本置信度中位数 | 0.644212 |
| 错误数 | 180(1.8%) |
| 错误且置信度 ≥0.95 | 40 张 |
| 错误且置信度 ≥0.90 | 64 张 |
错误样本里,超过三分之一(64/180 = 35.6%)的置信度在 0.90 以上。模型并不是「错得犹犹豫豫」,有一部分是「非常确定地错」。
可靠性的分箱统计更细:

<!-- 配图:7 个置信度箱的 acc vs conf 对角线;底部标注 ECE=0.29%、N=9,941;样本量条 -->
| 置信度箱 | 样本数 | 实际 acc | 平均 conf | acc − conf |
|---|---|---|---|---|
| 0.50–0.60 | 67 | 46.27% | 54.96% | −8.69 |
| 0.60–0.70 | 57 | 57.89% | 64.60% | −6.70 |
| 0.70–0.80 | 67 | 73.13% | 75.21% | −2.08 |
| 0.80–0.90 | 135 | 82.22% | 85.37% | −3.15 |
| 0.90–0.95 | 163 | 89.57% | 92.96% | −3.39 |
| 0.95–0.99 | 596 | 96.98% | 97.73% | −0.75 |
| 0.99–1.00 | 8,856 | 99.94% | 99.90% | +0.04 |
校准误差:
ECE = Σ (n / N) · |acc − conf|
= 0.2897% (N = 9,941)它看起来很小,但有两个原因,不能直接当成「校准很好」。
① 样本几乎都挤在最后一箱
8,856 / 9,941 = 89.1% 的测试样本置信度 ≥0.99。ECE 被这一箱主导,低置信度箱的偏差被平均掉了。
② 低置信度箱偏差很大
0.50–0.60 箱里,模型平均说 54.96% 会对了,实际只有 46.27%,差 8.69 个百分点。也就是说,在模型「不太确定」的时候,它的置信度仍然偏高。
最后看高置信度错例:

<!-- 配图:12 张 28×28 像素图,标题写真实→预测与置信度;按置信度从高到低排列 -->
| 序号 | 真实 | 预测 | 置信度 |
|---|---|---|---|
| 9729 | 5 | 6 | 0.9993 |
| 3503 | 9 | 1 | 0.9984 |
| 1112 | 4 | 6 | 0.9949 |
| 2387 | 9 | 1 | 0.9937 |
| 1226 | 7 | 2 | 0.9909 |
| 3422 | 6 | 0 | 0.9900 |
| 9009 | 7 | 2 | 0.9899 |
| 3520 | 6 | 4 | 0.9890 |
| 2654 | 6 | 1 | 0.9887 |
| 9770 | 5 | 0 | 0.9880 |
| 4176 | 2 | 7 | 0.9797 |
| 9015 | 7 | 2 | 0.9746 |
第一张 5→6 置信度 0.9993,是 180 个错误里最确定的一个。看像素图,它的上半圈几乎闭合,下半部分又细又直,确实更像 6。这类样本不是「模型笨」,而是标注与视觉证据本身有歧义。
对线上系统,结论是:阈值 0.95 不能保证正确。 错误样本里仍有 40 张超过 0.95,单靠置信度拦不住。要么加拒识(低置信度转人工),要么接受这部分错误并用业务规则兜底。
十、内部诊断:梯度和激活
前面的图看的都是输出。内部诊断看两样:梯度还在不在,激活有没有死。

<!-- 配图:三层梯度范数随轮次变化;第 1 与第 30 轮标注;所有值在 0.05–0.22 之间 -->
| 轮次 | Linear 1 | Linear 2 | Linear 3 |
|---|---|---|---|
| 1 | 0.214864 | 0.138068 | 0.133823 |
| 5 | 0.150684 | 0.065321 | 0.056312 |
| 25 | 0.174907 | 0.078736 | 0.053525 |
| 30 | 0.189589 | 0.083634 | 0.062559 |
第 1 轮到第 30 轮的变化:
第 1 层:−11.76%
第 2 层:−39.43%
第 3 层:−53.25%三层都在下降,但绝对值没有一个掉到 1e−6 以下。最深的第 3 层还剩 0.0626,足以继续更新。这排除了「梯度消失」:如果 BN 或初始化用错,这里会出现 1e−7、1e−8 量级的数。
再看激活。

<!-- 配图:两层激活标准差与 ReLU 负值比例随轮次变化;std 下降、饱和率上升 -->
| 指标 | 第 1 轮 | 第 30 轮 | 变化 |
|---|---|---|---|
| 第 1 层激活 std | 0.546 | 0.175 | −67.94% |
| 第 2 层激活 std | 0.576 | 0.242 | −57.96% |
| 第 1 层 ReLU 负值比例 | 53.51% | 75.36% | +21.85 |
| 第 2 层 ReLU 负值比例 | 56.90% | 66.01% | +9.11 |
ReLU 负值比例上升,意思是越来越多的单元输出 0。75.36% 听起来像「四分之三的单元死了」,但在 ReLU 网络里这是特征稀疏化:每个样本只激活一小部分单元,不同样本激活不同的子集。只要梯度还在、准确率还在涨,就不能把它当成故障。
判断「真死」和「稀疏」的区别:
- 真死:某个单元在所有样本上都输出 0,梯度恒为 0,权重再也不更新。
- 稀疏:某个单元在部分样本上输出 0,在其他样本上仍有正输出,梯度仍能回传。
本实验的探针统计的是全 batch 的平均负值比例,不是单样本。它只能说明稀疏程度,不能直接判定死单元。要判定死单元,得看单个单元在验证集上的激活是否恒为 0。
权重范数的变化也能交叉验证:
| 层 | 第 1 轮 | 第 30 轮 | 变化 |
|---|---|---|---|
| Linear 1 | 19.48 | 25.61 | +31.47% |
| Linear 2 | 15.95 | 15.60 | −2.20% |
| Linear 3 | 4.29 | 5.20 | +21.13% |
没有爆炸(没有 1e+3 量级),也没有塌缩到 0。结合梯度与更新比例,可以确认:这个网络的训练过程是健康的,98.20% 不是靠某一层意外地记住了测试集。
十一、验收:98.20% 是怎么算出来的

<!-- 配图:四个验收卡片:测试 acc 98.20、测试 loss 0.052155、参数量 235,934、训练 23.103 秒;底部写相对 C4 的 +0.92 -->
| 验收项 | 目标 | 实际 | 结论 |
|---|---|---|---|
| 测试准确率 | ≥98.00% | 98.20% | 通过,余量 0.20 |
| 测试 loss | 记录 | 0.052155 | — |
| 参数量 | 记录 | 235,934 | — |
| 训练时间 | 记录 | 23.103 秒 / 30 轮 | 0.770 秒/轮 |
| 测试集开启次数 | 1 次 | 1 次 | 通过 |
| 权重恢复 | best checkpoint | ep25,restored=True | 通过 |
98.20% 对应 9,820 对、180 错。98.00% 的门槛允许 200 个错误,所以余量是 20 个错误:
10,000 × 2.00% = 200
200 − 180 = 20相对案例篇最好的单配置 C4(97.28%),最终模型高了 0.92 个百分点。但这个比较要加一句限制:C4 是 8 轮、109,386 参数、无 Dropout 的 Adam 基线;最终模型是 30 轮、235,934 参数、BN + Dropout + 早停。它同时改了容量、轮数和正则,不是单变量对照。 0.92 个百分点是「整套方案」的收益,不能拆给某一个组件。
单变量能说的是案例篇那六组:在 8 轮、同一网络、同一 batch 顺序下,C4 比 C0 高 3.53 个百分点,C3(BN)高 2.91,C1(标准化)高 2.25。这些差值可以分别归因,但不能相加——加出来 8.69 个百分点,C0 的 93.75% 会变成 102.44%,不可能。
复现命令:
/private/tmp/dnn-venv/bin/python assets/run_experiments.py运行完会写 metrics.json 和 misclassified.json。本笔记里的每个数字都从这两个文件来,没有手填的指标。
十二、诊断表:现象 → 原因 → 动作
把这一章的所有实验压成一张表。遇到新网络时,按「现象」查,不按「技巧」查。

<!-- 配图:9 行诊断表,四列:现象、可能原因、下一步动作、本章实测 -->
| 现象 | 可能原因 | 下一步动作 | 本章实测 |
|---|---|---|---|
| loss 平线,acc 停在 10% 附近 | 零初始化、梯度为 0、对称性未打破 | 查首层梯度范数与单元余弦相似度 | 零初始化 11.20%,cos=1.000,grad=0 |
| 第 1 轮 loss 变 NaN | 学习率过大、梯度爆炸 | 学习率降 10 倍,开梯度裁剪 | SGD lr=1.0 第 1 轮 NaN |
| acc 先升后掉头 | 学习率过大、优化器与 lr 不匹配 | 扫 lr,换优化器再扫 | Adam lr=0.1 掉到 16.94% |
| 到 97% 太慢 | 输入未标准化、没有 BN | 先加输入标准化,再加 BN | 7 轮 → 4 轮 → 3 轮 |
| train 与 val 越拉越开 | 容量过大、无正则 | Dropout、WD、早停 | train 0.0114 / val 0.1753,test 97.96% |
| val loss 见底后回升 | 过拟合、早停太晚 | 保存 best 并回滚 | best ep25 0.050489,末轮 0.056275(+11.46%) |
| 验证高、测试低 | 测试集泄漏、按验证集反复调参 | 检查划分顺序与决策记录 | val 98.31% vs test 98.20%(−0.11) |
| 某一类召回明显低 | 类间形近、样本不足 | 看混淆矩阵的该行 | 类 9 召回 96.93%,9→4 13 张 |
| 错例置信度很高 | 校准差、分布外样本 | 看可靠性图,加拒识或人工兜底 | 错误均值 0.6822,40 张 ≥0.95,ECE 0.29% |
使用顺序建议:
- 先看 loss 有没有变成 NaN 或平线,排除前两行。
- 再看 train/val 的 gap 与 val loss 拐点,排除过拟合。
- 最后看混淆矩阵与置信度,决定是改模型还是改业务规则。
不要反过来。先看混淆矩阵再去调学习率,很容易把一个「数据或结构问题」当成「优化问题」。
十三、这一篇没解决的问题
拿到 98.20% 之后,剩下的 180 个错误里,前 8 对占了 67 张(37.2%),而且集中在 9↔4、7↔2、3↔5 这种「只差一笔」的形状上。
全连接层把 28×28 拉平成 784 维,丢掉了「哪个像素挨着哪个像素」。它可以靠大量参数硬记住一些局部模式,但记不住「平移后还是同一个数字」:把 9 往右挪 2 个像素,784 维向量里几乎每个分量都变了,对 MLP 来说这是一张新图。
这不是调参能修的。要修它,得让网络在看图时保留二维结构,并且让同一组权重在图像的不同位置复用。那是第 4 章卷积要回答的问题:卷积为什么能看见图像。
自检
合上笔记,试着回答:
- 为什么先划分再算 mean/std?反过来会发生什么?
- 50,000 张、batch 64,一轮为什么是 781 步而不是 782 步?16 张尾批去哪了?
train_one_epoch里四个顺序问题分别是什么?漏掉model.eval()会让哪个指标失真?- 235,934 与手算的 235,914 差在哪 20 个参数?为什么输出层也会有 BN?
- 训练 loss 第 1 轮比验证 loss 高,是过拟合还是测量方式造成的?
- 30 轮里 val loss 最低在第几轮?末轮回升了多少?早停为什么要回滚权重?
- 早停按 val loss 与按 val acc,在小数据上测试差了多少?各自适合什么验收目标?
- 180 个错误里前 8 对占多少?9→4 与 4→9 合计多少张?这说明全连接层缺什么?
- 错误样本平均置信度 0.6822,但仍有 40 张 ≥0.95,单靠阈值 0.95 能拦住错误吗?
- 最终 98.20% 比 C4 的 97.28% 高 0.92,为什么不能说这 0.92 是 BN 或 Dropout 单独带来的?