Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

训练一个可靠的神经网络

知道怎样求梯度以后,训练循环看起来只剩几行:预测、算损失、反向传播、更新参数。

但同一份数据,为什么有时很快学会,有时损失几乎不动,有时又突然出现 NaN?这一章把训练看成一项可以观察和诊断的实验。先让小网络稳定工作,再考虑增加规模。

1. 从一个能看见形状的任务开始

我们生成 600 个二维点,分成两条带噪声的弯月。这个例子没有实际业务单位,适合观察非线性分类边界。

划分仍然先做:360 条训练、120 条验证、120 条测试。标准化器只拟合训练集。网络为 2→32→32→2,两个隐藏层使用 ReLU,输出两个类别分数。

对一批 BB 个样本,各步形状为:

步骤形状
输入B×2B\times2
第一隐藏层B×32B\times32
第二隐藏层B×32B\times32
输出分数B×2B\times2
类别标签BB,每项为 0 或 1

我们用 PyTorch 的 CrossEntropyLoss 对应接口计算交叉熵。它需要未经 Softmax 的分数,内部会以数值稳定的方式完成对数概率计算。先做 Softmax 再交给它,会改变预期的计算。

2. 开始之前,权重应该多大

若同一层的隐藏单元具有完全相同的初值,它们可能得到相同输出和梯度,一直重复彼此的工作。尤其是本例全零权重、全零偏置与 ReLU 的组合,隐藏层很难从零状态启动。

随机初始化打破这种对称性。但随机数也不能随便取很大:逐层相乘可能让输出与梯度迅速增大;太小又可能让信号逐层缩弱。

对于后接 ReLU 的层,常见的 He 初始化让权重方差约为:

Var⁡(w)=2din \operatorname{Var}(w)=\frac2{d_{\mathrm{in}}}

dind_{\mathrm{in}} 是一个单元收到的输入数量。这个尺度来自对信号传播的近似分析,不是对任意网络都严格成立的保证。本例隐藏层使用这种初始化,线性输出层使用兼顾输入、输出宽度的 Xavier 初始化,偏置设为零。

偏置为零通常没有问题;需要打破的是不同隐藏单元在整套连接上的对称性。不能把这条经验误读成“所有参数都必须随机”。

3. 一次看多少样本

前面的梯度下降每次查看全部训练数据。神经网络经常每次只处理一个小批次(mini-batch),例如 32 条:

LB=1∣B∣∑i∈Bℓi L_{\mathcal B} =\frac1{|\mathcal B|} \sum_{i\in\mathcal B}\ell_i

B\mathcal B 是当前批次的样本集合。它的梯度是整体梯度的一种带波动估计。批次较小,更新更频繁,波动通常也更大;批次较大,能更充分利用并行计算,但会占用更多内存。

遍历一遍训练集叫一个 epoch,这里称为一轮。每一批更新一次叫一个 step。360 条记录、批量 32,一轮有 12 次更新,最后一批只有 8 条。

代码每轮重新打乱训练样本,却不会把样本与标签分别打乱。验证与测试不参与参数更新,也不必为了计算平均分而随机排序。

统计整轮损失时,要注意最后一批大小。直接平均各批次的平均损失,会让较小批次被过度加权。本例在轮末用当前模型重新计算全部训练数据的平均损失,图中的训练曲线因此对应同一时刻的模型。

4. 优化器和学习率各负责什么

最简单的 SGD 更新仍是:

θt+1=θt−ηgt \theta_{t+1}=\theta_t-\eta g_t

θ\theta 代表参数,gtg_t 是当前批次梯度。小批次使更新方向出现波动,因此常加入动量:

vt=βvt−1+gtθt+1=θt−ηvt \begin{aligned} v_t&=\beta v_{t-1}+g_t\\ \theta_{t+1}&=\theta_t-\eta v_t \end{aligned}

vtv_t 累积近期方向,β\beta 控制旧信息保留多少。本例使用 0.9。动量可以缓和一些来回摆动,但过大的学习率仍会使训练不稳。

Adam 则同时跟踪梯度与梯度平方的移动平均,使各参数使用不同的有效步长。常见写法为:

θt+1=θt−ηm^tv^t+ϵ \theta_{t+1} =\theta_t-\eta \frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}

m^t,v^t\hat m_t,\hat v_t 是经过初始偏差修正的统计量,ϵ\epsilon 是防止分母过小的常数。这里的 vv 是平方梯度统计量,与前面 SGD 动量里的记号含义不同,阅读代码时不要混用。

AdamW 将权重衰减与自适应梯度更新分开。它与“在 Adam 损失中加入 L2 项”一般不是完全相同的运算。无需马上背完每种优化器公式,先记住:优化器决定怎样利用梯度,学习率仍控制整体步幅。

学习率也可以随训练变化。本例每 30 轮把它乘以 0.3,让后期更新更细一些。其他常见做法包括余弦下降和开始时逐步增大学习率的预热。调度不是越复杂越好,应先和简单设置比较。

5. 归一化与正则化,分别在帮什么忙

输入标准化处理的是原始特征。网络内部也可以对激活值归一化,一般形式是:

h~=γh−μs2+ϵ+β \tilde h=\gamma \frac{h-\mu}{\sqrt{s^2+\epsilon}}+\beta

μ,s2\mu,s^2 来自指定的一组数,γ,β\gamma,\beta 通常可以学习。关键区别在于“对哪一组数计算统计量”。

BatchNorm 在训练时利用批次及相应空间位置的统计量;常用设置下,评估时使用训练中累计的统计量。批次很小时,这些估计可能不稳。

LayerNorm 对单个样本的指定特征维做归一化;在序列模型里,常对每个位置的隐藏特征分别处理。它通常在训练和评估时使用相同的归一化方式。后面的 Transformer 会频繁用到它。

归一化主要改变信号尺度与优化条件,并不保证泛化。正则化则给拟合增加约束。例如:

  • 权重衰减抑制参数无节制增大。
  • Dropout 在训练时随机将部分激活置零,并调整保留值的尺度;评估时关闭这种随机丢弃。
  • 早停根据验证表现选择训练到哪一轮,而不是把训练误差压到最低才停。

本章小网络没有加入 BatchNorm、LayerNorm 或 Dropout,便于单独观察初始化与学习率;这些是后续可以逐项检验的选择,不是每次都要装上的配件。

6. 把训练过程跑起来

使用第八章创建的 Python 3.12 环境,更新依赖后运行完整脚本。本次版本为 PyTorch 2.14.1,实验使用 CPU、单计算线程与双精度,不需要 GPU:

python -m pip install -r requirements.txt
python src/examples/training_network.py

PyTorch 的 Tensor 是可参与自动微分的多维数组。这里输入显式使用浮点类型,标签使用整数类型;nn.Linear 保存权重和偏置,nn.Sequential 按顺序连接各层。

"""第 14 章:控制变量比较初始化与学习率;CPU 上运行。"""

from copy import deepcopy
import numpy as np
import torch
from torch import nn
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler


def make_data():
    x, y = make_moons(n_samples=600, noise=0.18, random_state=2026)
    x_dev, x_test, y_dev, y_test = train_test_split(
        x, y, test_size=0.2, stratify=y, random_state=2026)
    x_train, x_val, y_train, y_val = train_test_split(
        x_dev, y_dev, test_size=0.25, stratify=y_dev, random_state=2027)
    scaler = StandardScaler().fit(x_train)
    datasets = []
    for features, labels in [(x_train, y_train), (x_val, y_val), (x_test, y_test)]:
        datasets.append((torch.tensor(scaler.transform(features), dtype=torch.float64),
                         torch.tensor(labels, dtype=torch.long)))
    return datasets, scaler


def make_model(zero=False):
    model = nn.Sequential(nn.Linear(2, 32), nn.ReLU(),
                          nn.Linear(32, 32), nn.ReLU(), nn.Linear(32, 2)).double()
    layers = [module for module in model if isinstance(module, nn.Linear)]
    for index, layer in enumerate(layers):
        if zero:
            nn.init.zeros_(layer.weight)
        elif index == len(layers)-1:
            nn.init.xavier_normal_(layer.weight)
        else:
            nn.init.kaiming_normal_(layer.weight, nonlinearity="relu")
        nn.init.zeros_(layer.bias)
    return model


def metrics(model, dataset):
    model.eval()
    with torch.no_grad():
        x, y = dataset
        logits = model(x)
        loss = nn.functional.cross_entropy(logits, y).item()
        accuracy = (logits.argmax(1) == y).double().mean().item()
    return loss, accuracy


def train(train_data, validation, rate, zero=False, epochs=60):
    torch.manual_seed(2026)
    model = make_model(zero)
    optimizer = torch.optim.SGD(model.parameters(), lr=rate, momentum=0.9,
                                weight_decay=1e-4)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.3)
    generator = torch.Generator().manual_seed(2027)
    best_loss, best_epoch, best_state = float("inf"), 0, None
    history = []
    x, y = train_data
    for epoch in range(1, epochs+1):
        model.train()
        for batch in torch.randperm(len(x), generator=generator).split(32):
            optimizer.zero_grad(set_to_none=True)
            loss = nn.functional.cross_entropy(model(x[batch]), y[batch])
            if not torch.isfinite(loss):
                raise RuntimeError("训练损失不是有限数,请检查输入、尺度和学习率")
            loss.backward()
            optimizer.step()
        train_loss, _ = metrics(model, train_data)
        val_loss, val_accuracy = metrics(model, validation)
        history.append((train_loss, val_loss, val_accuracy))
        if val_loss < best_loss:
            best_loss, best_epoch = val_loss, epoch
            best_state = deepcopy(model.state_dict())
        scheduler.step()
    model.load_state_dict(best_state)
    return model, best_epoch, history


def run():
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    datasets, scaler = make_data()
    configurations = [("步长很小", 0.0001, False),
                      ("常用设置", 0.05, False),
                      ("全零初始化", 0.05, True)]
    results = []
    for name, rate, zero in configurations:
        model, epoch, history = train(datasets[0], datasets[1], rate, zero)
        val_loss, val_accuracy = metrics(model, datasets[1])
        results.append((name, model, epoch, history, val_loss, val_accuracy))
    return datasets, scaler, results


def main():
    datasets, _, results = run()
    print("训练/验证/测试:360 / 120 / 120")
    for name, _, epoch, _, loss, accuracy in results:
        print(f"{name}:最佳轮次={epoch},验证交叉熵={loss:.3f},准确率={accuracy:.3f}")
    name, model, _, _, _, _ = min(results, key=lambda row: row[4])
    loss, accuracy = metrics(model, datasets[2])
    print(f"固定方案:{name};测试交叉熵={loss:.3f},准确率={accuracy:.3f}")


if __name__ == "__main__":
    main()

输出为:

训练/验证/测试:360 / 120 / 120
步长很小:最佳轮次=60,验证交叉熵=0.328,准确率=0.867
常用设置:最佳轮次=31,验证交叉熵=0.113,准确率=0.950
全零初始化:最佳轮次=39,验证交叉熵=0.693,准确率=0.500
固定方案:常用设置;测试交叉熵=0.120,准确率=0.958

三组实验使用相同数据与批次顺序。“步长很小”与“常用设置”只改变学习率;“全零初始化”与“常用设置”只改变初始化。我们根据验证交叉熵选方案与轮次,最后才使用测试集。

图 1:虚线是验证损失。较小步长在最后一轮仍取得最佳值,可能只是还没训练够;全零初始化则表现为另一种失败。

二分类各猜一半时,交叉熵为 −log⁡0.5≈0.693-\log0.5\approx0.693。全零初始化的结果接近这个值,提供了有意义的诊断线索。

图 2:模型学出弯曲的边界。背景展示选定模型的预测,散点仅为训练数据,不把测试样本混进训练示意图。

两个很容易混淆的开关

model.eval() 改变 Dropout、BatchNorm 等层的运行行为,不会自动关闭梯度记录。torch.no_grad() 则关闭这段计算的梯度记录,不会替你把模型切到评估模式。验证时通常两个都需要。

保存最佳参数时,我们复制了 state_dict。如果只保留对当前状态的引用,后续训练可能继续修改其中张量,所谓“最佳轮次”就被覆盖了。

这里保存的是用于选择预测模型的参数快照。若要精确恢复训练,还要保存优化器、学习率调度器、随机状态和数据进度,第二十三章会继续处理。

7. 损失不正常时,从哪里查起

观察到什么优先检查什么
损失出现 NaN 或无穷输入是否合法、学习率、指数与对数写法、精度和梯度大小
训练与验证都很差标签是否对齐、输出形状与损失是否匹配、初始化、优化是否充分
训练继续改善,验证恶化过拟合、数据量、正则化与训练轮数
训练分数好得不合理数据泄漏、重复样本、错误的评估模式
换个种子结果差很多样本量、初始化敏感性、训练稳定性与报告方式

一个很实用的排查办法,是先取十几条样本,看网络能否把它们拟合好。若连小集合都学不会,先查实现和优化,不必马上增加更多数据。这个“小集合拟合”只用于诊断,不能当作泛化成绩。

必要时可以裁剪梯度,限制一次更新中的梯度范数;它能缓和梯度爆炸,却不能修正错误损失或泄漏。一次只改一项、记录曲线,比同时换模型、换学习率、换数据更容易找到原因。

8. 自测与小实验

  1. 360 条训练记录、批量 32,一轮更新几次?
  2. 为什么不能把网络所有权重都设成零,期待它们自然学出不同功能?
  3. CrossEntropyLoss 通常接收概率还是原始分数?
  4. eval() 与 no_grad() 有什么区别?
  5. 验证损失最优在第 31 轮,是否应该一律保留第 60 轮?
  6. 本例小步长的结果较差,是否证明小步长始终不好?
  7. 为什么单次固定种子的实验不足以证明一种设置普遍更可靠?
展开参考答案
  1. 12 次,最后一批为 8 条。
  2. 隐藏单元可能始终对称;本例全零 ReLU 网络还会切断隐藏层的梯度通路。
  3. 原始分数。接口内部完成稳定的对数概率计算。
  4. 前者改变部分层的训练/评估行为,后者控制是否记录梯度。
  5. 不应该。若按验证交叉熵选择,就应恢复第 31 轮的参数。
  6. 不证明。它可能只是在相同轮数下尚未优化充分,还需要考察训练预算。
  7. 初始化、批次与样本划分都有波动,需要重复实验,并明确比较条件。

选做:只把小步长实验延长到 300 轮,观察它能追上多少。再固定方案、换三个初始化种子,只比较验证结果。不要因为某次测试分数更高,就回过头把那次种子称为“最佳设置”。

下一章卷积网络与视觉表示把网络结构与图像的空间关系联系起来。初始化的原始分析可读 He 等人的论文;优化器与归一化可参阅 Adam 和 Layer Normalization。先核对本章涉及的机制,不必一开始重做论文中的大规模实验。