训练一个可靠的神经网络
知道怎样求梯度以后,训练循环看起来只剩几行:预测、算损失、反向传播、更新参数。
但同一份数据,为什么有时很快学会,有时损失几乎不动,有时又突然出现 NaN?这一章把训练看成一项可以观察和诊断的实验。先让小网络稳定工作,再考虑增加规模。
1. 从一个能看见形状的任务开始
我们生成 600 个二维点,分成两条带噪声的弯月。这个例子没有实际业务单位,适合观察非线性分类边界。
划分仍然先做:360 条训练、120 条验证、120 条测试。标准化器只拟合训练集。网络为 2→32→32→2,两个隐藏层使用 ReLU,输出两个类别分数。
对一批 个样本,各步形状为:
| 步骤 | 形状 |
|---|---|
| 输入 | |
| 第一隐藏层 | |
| 第二隐藏层 | |
| 输出分数 | |
| 类别标签 | ,每项为 0 或 1 |
我们用 PyTorch 的 CrossEntropyLoss 对应接口计算交叉熵。它需要未经 Softmax 的分数,内部会以数值稳定的方式完成对数概率计算。先做 Softmax 再交给它,会改变预期的计算。
2. 开始之前,权重应该多大
若同一层的隐藏单元具有完全相同的初值,它们可能得到相同输出和梯度,一直重复彼此的工作。尤其是本例全零权重、全零偏置与 ReLU 的组合,隐藏层很难从零状态启动。
随机初始化打破这种对称性。但随机数也不能随便取很大:逐层相乘可能让输出与梯度迅速增大;太小又可能让信号逐层缩弱。
对于后接 ReLU 的层,常见的 He 初始化让权重方差约为:
是一个单元收到的输入数量。这个尺度来自对信号传播的近似分析,不是对任意网络都严格成立的保证。本例隐藏层使用这种初始化,线性输出层使用兼顾输入、输出宽度的 Xavier 初始化,偏置设为零。
偏置为零通常没有问题;需要打破的是不同隐藏单元在整套连接上的对称性。不能把这条经验误读成“所有参数都必须随机”。
3. 一次看多少样本
前面的梯度下降每次查看全部训练数据。神经网络经常每次只处理一个小批次(mini-batch),例如 32 条:
是当前批次的样本集合。它的梯度是整体梯度的一种带波动估计。批次较小,更新更频繁,波动通常也更大;批次较大,能更充分利用并行计算,但会占用更多内存。
遍历一遍训练集叫一个 epoch,这里称为一轮。每一批更新一次叫一个 step。360 条记录、批量 32,一轮有 12 次更新,最后一批只有 8 条。
代码每轮重新打乱训练样本,却不会把样本与标签分别打乱。验证与测试不参与参数更新,也不必为了计算平均分而随机排序。
统计整轮损失时,要注意最后一批大小。直接平均各批次的平均损失,会让较小批次被过度加权。本例在轮末用当前模型重新计算全部训练数据的平均损失,图中的训练曲线因此对应同一时刻的模型。
4. 优化器和学习率各负责什么
最简单的 SGD 更新仍是:
代表参数, 是当前批次梯度。小批次使更新方向出现波动,因此常加入动量:
累积近期方向, 控制旧信息保留多少。本例使用 0.9。动量可以缓和一些来回摆动,但过大的学习率仍会使训练不稳。
Adam 则同时跟踪梯度与梯度平方的移动平均,使各参数使用不同的有效步长。常见写法为:
是经过初始偏差修正的统计量, 是防止分母过小的常数。这里的 是平方梯度统计量,与前面 SGD 动量里的记号含义不同,阅读代码时不要混用。
AdamW 将权重衰减与自适应梯度更新分开。它与“在 Adam 损失中加入 L2 项”一般不是完全相同的运算。无需马上背完每种优化器公式,先记住:优化器决定怎样利用梯度,学习率仍控制整体步幅。
学习率也可以随训练变化。本例每 30 轮把它乘以 0.3,让后期更新更细一些。其他常见做法包括余弦下降和开始时逐步增大学习率的预热。调度不是越复杂越好,应先和简单设置比较。
5. 归一化与正则化,分别在帮什么忙
输入标准化处理的是原始特征。网络内部也可以对激活值归一化,一般形式是:
来自指定的一组数, 通常可以学习。关键区别在于“对哪一组数计算统计量”。
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:虚线是验证损失。较小步长在最后一轮仍取得最佳值,可能只是还没训练够;全零初始化则表现为另一种失败。
二分类各猜一半时,交叉熵为 。全零初始化的结果接近这个值,提供了有意义的诊断线索。
图 2:模型学出弯曲的边界。背景展示选定模型的预测,散点仅为训练数据,不把测试样本混进训练示意图。
两个很容易混淆的开关
model.eval() 改变 Dropout、BatchNorm 等层的运行行为,不会自动关闭梯度记录。torch.no_grad() 则关闭这段计算的梯度记录,不会替你把模型切到评估模式。验证时通常两个都需要。
保存最佳参数时,我们复制了 state_dict。如果只保留对当前状态的引用,后续训练可能继续修改其中张量,所谓“最佳轮次”就被覆盖了。
这里保存的是用于选择预测模型的参数快照。若要精确恢复训练,还要保存优化器、学习率调度器、随机状态和数据进度,第二十三章会继续处理。
7. 损失不正常时,从哪里查起
| 观察到什么 | 优先检查什么 |
|---|---|
| 损失出现 NaN 或无穷 | 输入是否合法、学习率、指数与对数写法、精度和梯度大小 |
| 训练与验证都很差 | 标签是否对齐、输出形状与损失是否匹配、初始化、优化是否充分 |
| 训练继续改善,验证恶化 | 过拟合、数据量、正则化与训练轮数 |
| 训练分数好得不合理 | 数据泄漏、重复样本、错误的评估模式 |
| 换个种子结果差很多 | 样本量、初始化敏感性、训练稳定性与报告方式 |
一个很实用的排查办法,是先取十几条样本,看网络能否把它们拟合好。若连小集合都学不会,先查实现和优化,不必马上增加更多数据。这个“小集合拟合”只用于诊断,不能当作泛化成绩。
必要时可以裁剪梯度,限制一次更新中的梯度范数;它能缓和梯度爆炸,却不能修正错误损失或泄漏。一次只改一项、记录曲线,比同时换模型、换学习率、换数据更容易找到原因。
8. 自测与小实验
- 360 条训练记录、批量 32,一轮更新几次?
- 为什么不能把网络所有权重都设成零,期待它们自然学出不同功能?
CrossEntropyLoss通常接收概率还是原始分数?eval()与no_grad()有什么区别?- 验证损失最优在第 31 轮,是否应该一律保留第 60 轮?
- 本例小步长的结果较差,是否证明小步长始终不好?
- 为什么单次固定种子的实验不足以证明一种设置普遍更可靠?
展开参考答案
- 12 次,最后一批为 8 条。
- 隐藏单元可能始终对称;本例全零 ReLU 网络还会切断隐藏层的梯度通路。
- 原始分数。接口内部完成稳定的对数概率计算。
- 前者改变部分层的训练/评估行为,后者控制是否记录梯度。
- 不应该。若按验证交叉熵选择,就应恢复第 31 轮的参数。
- 不证明。它可能只是在相同轮数下尚未优化充分,还需要考察训练预算。
- 初始化、批次与样本划分都有波动,需要重复实验,并明确比较条件。
选做:只把小步长实验延长到 300 轮,观察它能追上多少。再固定方案、换三个初始化种子,只比较验证结果。不要因为某次测试分数更高,就回过头把那次种子称为“最佳设置”。
下一章卷积网络与视觉表示把网络结构与图像的空间关系联系起来。初始化的原始分析可读 He 等人的论文;优化器与归一化可参阅 Adam 和 Layer Normalization。先核对本章涉及的机制,不必一开始重做论文中的大规模实验。