Skip to content

实战演练:把 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隔离依赖,不污染系统环境
PyTorch2.8.0BN、Dropout、Adam 的默认行为随版本变过
NumPy2.0.2划分与初始化都用它
线程数torch.set_num_threads(4)线程数不同,浮点累加顺序不同,末位会有差异
随机种子20260815划分、初始化、batch 顺序共用

种子在三个地方各设一次:

python
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 才用来更新参数。

![数据三分](/ai-four-kings/第 3 章:训练工程/assets/ch3_split.svg)

<!-- 配图:70,000 张按 50k/10k/10k 分成三条,下面三张卡片写各自职责;底部写标准化统计量与数据泄漏 -->

数据数量每轮用几次能做什么不能做什么
训练集50,000781 步,每步 64 张更新参数、算 BN 的 batch 统计、算标准化 mean/std不能用来报最终指标
验证集10,0001 次前向早停、选模型、调超参不能反传,不能打乱后参与训练
测试集10,000全程 1 次只报最终指标不参与任何决策

划分只做一次,种子固定为 20260815:

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

注意顺序:先划分索引,再在训练子集上算标准化统计量。

python
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,一轮的步数是:

text
⌊50,000 / 64⌋ = 781
781 × 64 = 49,984

每轮有 16 张凑不满一个 batch,被丢弃。30 轮累计的样本前向次数:

text
781 × 64 × 30 = 1,499,520

这个数字后面用来解释训练时间,不用来吹吞吐。

设计取舍:50k/10k/10k 还是 60k/10k

方案训练集验证集代价
A:60k 训练 + 10k 验证60,00010,000没有独立测试集,最后只能拿验证集报成绩
B:50k/10k/10k(本章)50,00010,000少 10,000 张训练样本,训练指标会略低

本章选 B。原因是验收需要一个从未参与任何决策的集合。验证集一旦被用来早停和调参,它就已经是训练的一部分;再从验证集里报 98%,说服力会打折。代价是训练集少了六分之一,收敛会慢一点——这个代价在第五节的曲线上能直接看到。

二、一个 epoch 的流水线 ​

一个 epoch 不是「把所有图片喂一遍」这么简单。它是一串有顺序的操作,顺序错了,指标会骗你。

![一个 epoch 的流水线](/ai-four-kings/第 3 章:训练工程/assets/ch3_pipeline.svg)

<!-- 配图:两行流程,前向四个节点、验证与早停四个节点;底部列四个顺序问题与总样本前向次数 -->

训练部分的代码:

python
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() 回去。

评估函数很短:

python
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 秒,平均每轮:

text
23.103 / 30 = 0.770 秒

单轮在 0.587–1.46 秒之间波动,差异主要来自机器负载,不是模型在某几轮突然变慢。这个量级说明:本章的瓶颈在设计实验和读结果,不在算力。

三、模型与 235,934 个参数 ​

最终配置是 784→256→128→10,每个 Linear 后面接 BN,隐层接 ReLU 和 Dropout 0.2。

![最终配置](/ai-four-kings/第 3 章:训练工程/assets/ch3_final_config.svg)

<!-- 配图:左侧三层网络结构与每层参数,右侧参数表与优化正则配置;底部写 BN 输出层 20 个参数 -->

模型类比之前多了两个东西:BN 和 Dropout。

python
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 1784 → 256200,704256512201,472
Linear 2256 → 12832,76812825633,152
Linear 3128 → 101,28010201,310
合计—234,752394788235,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。

逐层参数量:

text
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,934BN + Dropout 0.2 + 早停98.20%多三个组件,调参面变大;BN 在 batch=1 时不可用

本章选 B。它不是「更大的网络一定更好」,恰恰相反:B 的参数不到 A 的一半,测试高 0.24 个百分点。代价是组件更多,任何一个用错(比如评估忘 eval())都会让结果失真。这也是第二节要先讲流水线顺序的原因。

构建与优化器:

python
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 多三件事:记录历史、跑探针、早停与回滚。

python
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 顺序是预生成的

python
epochs_idx = [rng.permutation(len(xf_tr)) for _ in range(40)]

40 轮顺序在训练前一次性生成,所有配置共用同一组。这样 C0 与 C4 的差异只来自配置,不来自「这一轮碰巧抽到难样本」。如果每轮现抽,单次实验的方差会混进对照结果。

探针用固定 512 张验证样本

python
probe_x = xva[:512]
probe_y = yva[:512]

探针不是用来选模型的,是用来观察内部状态的。它固定不变,所以第 1 轮到第 30 轮的梯度范数、激活标准差可以直接相减。如果每轮换一批样本,「梯度变小」可能只是这批样本更简单。

探针函数的关键一行:

python
g = torch.autograd.grad(loss, lin.weight, retain_graph=True)[0]
out["grad_norm"].append(round(float(g.norm()), 6))

它只求梯度,不更新参数。@torch.enable_grad() 是必需的,因为外层评估通常关梯度;探针要的正是梯度。

更新比例

除了梯度范数,循环还在每轮最后一个 batch 记录了「权重更新量 / 更新前权重范数」:

python
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 10.0039540.003623−8.4%
Linear 20.0022040.002805+27.3%
Linear 30.0017110.001589−7.1%

三层都停在 1e−3 量级,没有一层掉到 1e−6。这直接排除了「梯度消失到无法更新」。第 2 层的更新比例反而上升,是因为 BN 的 scale 在训练中逐渐放大,同样的梯度经过 BN 后对权重的影响更大。这个解释不是猜的:第十节的权重范数显示第 2 层权重几乎没变(15.95→15.60),但它的激活标准差从 0.576 降到 0.242,BN 把信号压紧后,相对的更新比例自然上升。

五、30 轮 loss 与准确率 ​

先看 loss。

![30 轮 loss](/ai-four-kings/第 3 章:训练工程/assets/ch3_final_loss.svg)

<!-- 配图:左图全程 0.02–0.56,右图放大第 5–30 轮的 0.04–0.13;best ep25 竖线与末轮标注 -->

关键节点:

轮次训练 loss验证 loss训练 acc验证 acc
10.54370.235089.39%96.02%
50.12290.078896.93%97.78%
100.07430.066097.95%98.07%
150.06000.057898.30%98.37%
200.05130.063698.41%98.08%
250.04870.050598.58%98.46%
300.04380.056398.70%98.31%

训练 loss 从 0.5437 降到 0.0438,降幅:

text
(0.5437 − 0.0438) / 0.5437 = 91.94%

验证 loss 最低是第 25 轮的 0.050489,比第 1 轮降了 78.51%。之后 5 轮回升到 0.056275:

text
(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 轮开始这个关系就翻过来了。

再看准确率。

![30 轮准确率](/ai-four-kings/第 3 章:训练工程/assets/ch3_final_acc.svg)

<!-- 配图:训练、验证、测试三条线;测试 98.20% 画成水平线;右侧标 best val 98.46% 与末轮 98.31% -->

30 轮的端点:

指标第 1 轮最好第 30 轮
训练 acc89.39%98.70%98.70%
验证 acc96.02%98.46%(第 25 轮)98.31%
测试 acc——98.20%

测试集只在前 25 轮的 best 权重恢复之后开一次:

python
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](/ai-four-kings/第 3 章:训练工程/assets/ch3_gap.svg)

<!-- 配图:gap 曲线从 −6.63 回到 +0.39;零线;标注第 12 轮首次转正、第 14 轮后基本为正 -->

轮次训练 acc验证 accgap
189.39%96.02%−6.63
294.60%97.06%−2.46
395.86%97.47%−1.61
596.93%97.78%−0.85
997.92%98.24%−0.32
1298.12%98.08%+0.04
1398.20%98.27%−0.07
1498.30%98.19%+0.11
2598.58%98.46%+0.12
3098.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。

![早停曲线](/ai-four-kings/第 3 章:训练工程/assets/ch3_earlystop.svg)

<!-- 配图:大网络训练/验证 loss 曲线;best ep4、stop ep8;第 8 轮训练 0.0302、验证 0.1079 -->

轮次训练 loss验证 loss训练 acc验证 acc
10.20700.117293.55%96.32%
4—0.1015—97.30%
80.03020.107999.06%97.32%

第 4 轮验证 loss 见底 0.101518,之后 4 轮没再创新低,第 8 轮停止并回滚到第 4 轮。测试集 96.86%。如果不停,训练 loss 会继续掉,验证 acc 基本不动,测试只会更差。

早停代码就是第四节里的那段,核心是两个条件:

python
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 张训练)能直接测出来。

![早停看 loss 还是 acc](/ai-four-kings/第 3 章:训练工程/assets/ch3_earlystop_table.svg)

<!-- 配图:同一份 10k 数据,按 loss 停在第 4 轮测试 95.40%,按 acc 跑到第 30 轮测试 96.71% -->

方案停在哪轮训练 acc验证 acc测试 acc验证 loss
A:按 val loss498.34%95.33%95.40%0.1705
B:按 val acc30100.00%96.69%96.71%0.2062
方案依据优点代价
A:val loss连续 4 轮 loss 不降就停对概率质量敏感,早停更早,省算力小数据上容易过早;本实验测试低 1.31 个百分点
B:val accacc 创新高才更新 best直接对齐分类验收指标对置信度不敏感;本实验训练 acc 已 100%,验证 loss 涨了 20.9% 仍在「进步」

本章的最终模型两个标准都指向第 25 轮:val loss 0.050489、val acc 98.46% 都是第 25 轮最高。所以这次没有出现冲突。但小数据实验说明:如果验收只看 acc,就按 acc 选模型;如果要输出概率或做校准,loss 必须一起看。 只按 loss 早停,可能停在一个 acc 还差 1.31 个百分点的位置。

最终模型的早停参数:

text
patience = 6
best = ep25, val_loss = 0.050489, val_acc = 0.9846
stopped_at = ep30
restored = True

restored=True 表示测试前确实把权重回滚到了第 25 轮,而不是用了第 30 轮的权重。

八、混淆矩阵:180 个错误分布在哪 ​

测试集 9,820 对、180 错。混淆矩阵按「真实行、预测列」统计。

![混淆矩阵](/ai-four-kings/第 3 章:训练工程/assets/ch3_confusion.svg)

<!-- 配图:10×10 混淆矩阵热力图,对角线深色;格内写数量,颜色按行归一化 -->

python
cm = np.zeros((10, 10), dtype=int)
for t, p in zip(y_test, logits.argmax(1)):
    cm[t, p] += 1

对角线是各类召回:

真实类样本数召回精确率F1
098099.49%98.19%98.83%
11,13599.30%98.86%99.08%
21,03298.16%97.97%98.06%
31,01097.82%98.41%98.11%
498298.37%97.58%97.97%
589297.98%98.09%98.04%
695898.33%98.43%98.38%
71,02897.37%98.23%97.80%
897498.15%97.95%98.05%
91,00996.93%98.19%97.56%

最低的三项:

  • 召回最低:类 9,96.93%(978/1,009),有 31 张 9 被分到别的类。
  • 精确率最低:类 4,97.58%(966/990),有 24 张别的类被当成 4。
  • F1 最低:类 9,97.56%。

每类的三项指标画在一起:

![每类指标](/ai-four-kings/第 3 章:训练工程/assets/ch3_per_class.svg)

<!-- 配图:10 类三组柱状图(精确率、召回、F1),类 9 召回最低,类 4 精确率最低 -->

类 9 的问题不是「认不出 9」,而是「9 被认成 4」。列 4 的假阳性来源里,9→4 有 13 张,是所有单对错误里最多的。类 4 的精确率因此被拉低。

把非对角线的错误按数量排序:

![错误对排名](/ai-four-kings/第 3 章:训练工程/assets/ch3_error_pairs.svg)

<!-- 配图:前 8 个「真实→预测」对的水平条形图;9→4 13 张最高,前 8 对合计 67 张 -->

排名真实 → 预测数量
19 → 413
27 → 210
34 → 99
43 → 59
55 → 37
62 → 77
77 → 16
86 → 46

前 8 对一共 67 张,占 180 个错误的:

text
67 / 180 = 37.2%

这些错误有个共同点:成对出现。

对双向合计共同结构
9 ↔ 413 + 9 = 22上面一个圈,右边一条竖;4 的竖线更长,9 的圈更圆
7 ↔ 210 + 7 = 17顶部横线 + 斜线;2 的底部横线在 7 里没有
3 ↔ 59 + 7 = 16两个弧;3 的下弧向左,5 的上弧向左

这就是全连接网络的盲区:28×28 被拉平成 784 维,相邻像素的空间关系在权重里要靠数据自己学。9 和 4 的差别只在「圈和竖线怎么接」,这个局部结构对全连接层不友好。第 4 章的卷积就是为这件事准备的。

九、置信度:错的时候有多确定 ​

准确率只回答「对了多少」,置信度回答「错的时候有多自信」。对客服、医疗这类场景,后者更危险。

![置信度直方图](/ai-four-kings/第 3 章:训练工程/assets/ch3_confidence_hist.svg)

<!-- 配图:正确与错误样本的置信度分布,对数刻度;错误样本在 0.60–1.00 之间铺开,40 张 ≥0.95 -->

统计量数值
正确样本平均置信度0.990107
错误样本平均置信度0.682185
错误样本置信度中位数0.644212
错误数180(1.8%)
错误且置信度 ≥0.9540 张
错误且置信度 ≥0.9064 张

错误样本里,超过三分之一(64/180 = 35.6%)的置信度在 0.90 以上。模型并不是「错得犹犹豫豫」,有一部分是「非常确定地错」。

可靠性的分箱统计更细:

![可靠性图](/ai-four-kings/第 3 章:训练工程/assets/ch3_reliability.svg)

<!-- 配图:7 个置信度箱的 acc vs conf 对角线;底部标注 ECE=0.29%、N=9,941;样本量条 -->

置信度箱样本数实际 acc平均 confacc − conf
0.50–0.606746.27%54.96%−8.69
0.60–0.705757.89%64.60%−6.70
0.70–0.806773.13%75.21%−2.08
0.80–0.9013582.22%85.37%−3.15
0.90–0.9516389.57%92.96%−3.39
0.95–0.9959696.98%97.73%−0.75
0.99–1.008,85699.94%99.90%+0.04

校准误差:

text
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 个百分点。也就是说,在模型「不太确定」的时候,它的置信度仍然偏高。

最后看高置信度错例:

![高置信度错例](/ai-four-kings/第 3 章:训练工程/assets/ch3_misclassified.svg)

<!-- 配图:12 张 28×28 像素图,标题写真实→预测与置信度;按置信度从高到低排列 -->

序号真实预测置信度
9729560.9993
3503910.9984
1112460.9949
2387910.9937
1226720.9909
3422600.9900
9009720.9899
3520640.9890
2654610.9887
9770500.9880
4176270.9797
9015720.9746

第一张 5→6 置信度 0.9993,是 180 个错误里最确定的一个。看像素图,它的上半圈几乎闭合,下半部分又细又直,确实更像 6。这类样本不是「模型笨」,而是标注与视觉证据本身有歧义。

对线上系统,结论是:阈值 0.95 不能保证正确。 错误样本里仍有 40 张超过 0.95,单靠置信度拦不住。要么加拒识(低置信度转人工),要么接受这部分错误并用业务规则兜底。

十、内部诊断:梯度和激活 ​

前面的图看的都是输出。内部诊断看两样:梯度还在不在,激活有没有死。

![梯度范数](/ai-four-kings/第 3 章:训练工程/assets/ch3_grad_diag.svg)

<!-- 配图:三层梯度范数随轮次变化;第 1 与第 30 轮标注;所有值在 0.05–0.22 之间 -->

轮次Linear 1Linear 2Linear 3
10.2148640.1380680.133823
50.1506840.0653210.056312
250.1749070.0787360.053525
300.1895890.0836340.062559

第 1 轮到第 30 轮的变化:

text
第 1 层:−11.76%
第 2 层:−39.43%
第 3 层:−53.25%

三层都在下降,但绝对值没有一个掉到 1e−6 以下。最深的第 3 层还剩 0.0626,足以继续更新。这排除了「梯度消失」:如果 BN 或初始化用错,这里会出现 1e−7、1e−8 量级的数。

再看激活。

![激活诊断](/ai-four-kings/第 3 章:训练工程/assets/ch3_activation_diag.svg)

<!-- 配图:两层激活标准差与 ReLU 负值比例随轮次变化;std 下降、饱和率上升 -->

指标第 1 轮第 30 轮变化
第 1 层激活 std0.5460.175−67.94%
第 2 层激活 std0.5760.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 119.4825.61+31.47%
Linear 215.9515.60−2.20%
Linear 34.295.20+21.13%

没有爆炸(没有 1e+3 量级),也没有塌缩到 0。结合梯度与更新比例,可以确认:这个网络的训练过程是健康的,98.20% 不是靠某一层意外地记住了测试集。

十一、验收:98.20% 是怎么算出来的 ​

![验收](/ai-four-kings/第 3 章:训练工程/assets/ch3_acceptance.svg)

<!-- 配图:四个验收卡片:测试 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 checkpointep25,restored=True通过

98.20% 对应 9,820 对、180 错。98.00% 的门槛允许 200 个错误,所以余量是 20 个错误:

text
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%,不可能。

复现命令:

bash
/private/tmp/dnn-venv/bin/python assets/run_experiments.py

运行完会写 metrics.json 和 misclassified.json。本笔记里的每个数字都从这两个文件来,没有手填的指标。

十二、诊断表:现象 → 原因 → 动作 ​

把这一章的所有实验压成一张表。遇到新网络时,按「现象」查,不按「技巧」查。

![诊断表](/ai-four-kings/第 3 章:训练工程/assets/ch3_diag_table.svg)

<!-- 配图: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先加输入标准化,再加 BN7 轮 → 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%

使用顺序建议:

  1. 先看 loss 有没有变成 NaN 或平线,排除前两行。
  2. 再看 train/val 的 gap 与 val loss 拐点,排除过拟合。
  3. 最后看混淆矩阵与置信度,决定是改模型还是改业务规则。

不要反过来。先看混淆矩阵再去调学习率,很容易把一个「数据或结构问题」当成「优化问题」。

十三、这一篇没解决的问题 ​

拿到 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 单独带来的?