AI 学习课程 / LESSON 07

第七节:训练更可靠的神经网络

第六节让训练循环跑起来。这一节进一步理解:梯度为何变小、参数为何不动、随机遮挡怎样工作,以及怎样用实验判断改动是否有效。

公式 → 图解 → 真正的 PyTorch每次改变一个主要因素CPU · 不下载数据

01 / 本节要解决什么?

能运行之后,要知道哪里出了问题

梯度能否传回来?

激活导数与权重决定反向信号的大小。多层相乘可能使它缩小或放大。

参数怎样更新?

初始化确定起点,优化器确定更新规则,调度器安排学习率的变化。

验证效果如何?

Dropout 与归一化有具体作用,也有适用条件;看验证结果再判断。

先读公式,再动图解,最后运行代码。你已经会偏导和矩阵乘法,本节不要求先学复杂网络。真实训练使用当前项目的 uv 管理 Python 和 CPU 版 PyTorch。

uv run python lessons/lesson-07-training.py
读完后,至少能解释三个现象:backward 后梯度很小、相同神经元一直相同、训练和评估的输出不同。然后能设计一个只改变一个主要因素的对照实验。

02 / 为什么在线性层之间加函数?

激活函数引入非线性,也影响梯度

如果两层之间什么都不加,h=W₁x+b₁、ŷ=W₂h+b₂ 合起来仍是 ŷ=(W₂W₁)x+(W₂b₁+b₂)。增加层数仍只能表达一个仿射变换。加入 tanh、ReLU 等非线性函数后,才能表示更丰富的关系。

函数输出对输入 z 的导数需要注意
Sigmoidσ(z)=1/(1+e⁻ᶻ)σ(z)(1−σ(z)),最大 0.25很正或很负时趋近饱和,导数小
tanhtanh(z),范围 (−1,1)1−tanh²(z),最大 1绝对值大时也会饱和
ReLUmax(0,z)z>0 为 1;z<0 为 0负区间没有梯度;PyTorch 在 0 处取 0

实验 A:移动输入,看看输出和斜率

实线:输出 f(z)虚线:导数 f′(z)

导数可以理解成:输入略微改变时,输出会改变多少。饱和区中输出几乎不动,导数也小;这会影响传到前一层的梯度。

ReLU 的正区间导数为 1,不代表整个网络的梯度一定不消失:还要乘权重,而且负区间梯度为 0。LeakyReLU 会给负区间保留小斜率,可作为后续实验,但不是万能修复。
输出层的激活与损失要怎么配?

二分类网络可输出一个原始分数,叫 logit。训练用 BCEWithLogitsLoss,它已经包含 sigmoid 与二元交叉熵的稳定计算;不要先 sigmoid 再送进去。推理需要概率时才 torch.sigmoid(logits)。本节固定以 0 为 logit 阈值,相当于概率 0.5。

logits = model(X)             # (B,1),最后一层 Linear,不加 sigmoid
loss = torch.nn.BCEWithLogitsLoss()(logits, y)  # y 为浮点 (B,1)
probability = torch.sigmoid(logits)            # 推理时

接口依据:BCEWithLogitsLoss。

03 / 从一层导数到多层乘积

反向传播一直在乘局部导数

先看每层只有一个数的网络:zₖ=wₖhₖ₋₁+bₖ、hₖ=f(zₖ)。从下一层传回来的梯度,还要乘 f′(zₖ) 和 wₖ 才能传给前一层。

∂L/∂hₖ₋₁ = (∂L/∂hₖ) × f′(zₖ) × wₖ 逐层递推:∂L/∂h₀ = (∂L/∂hₙ) × Πₖ[wₖ f′(zₖ)]

假设这条路径上每个权重为 1,每个 sigmoid 输入都为 0,则每层局部导数为 0.25。末端梯度为 1 时,过一层剩 0.25,过三层剩 0.015625,过十层只剩约 0.000000954。

实验 B:跟随同一条路径回传

真实多维网络会涉及矩阵乘法与多条路径求和,上面的乘积是单路径示例。它解释了缩小和放大的机制,不能直接推出每个复杂网络的总梯度。配套 Python 用 h=σ(h)−0.5 连续计算,使每层输入恰好为 0,并验证输入梯度等于 0.25**层数。

“梯度消失”表示前面层收到的梯度非常小,学习可能很慢;“梯度爆炸”表示梯度很大,更新可能不稳定。学习率决定如何使用梯度,但把学习率调大不能可靠地修复已经消失的信号。

04 / 相同起点会发生什么?

隐藏神经元需要打破对称性

同一层两个隐藏神经元,如果权重和偏置完全相同,并且它们到后面的连接也相同,它们会输出同样的数、收到同样的梯度、继续保持相同。你放了两个神经元,却没让它们学习不同的特征。

h₁=f(a₁x+c₁),h₂=f(a₂x+c₂),ŷ=v₁h₁+v₂h₂ a₁=a₂、c₁=c₂、v₁=v₂ → 两个神经元的前向与对应梯度相同

实验 C:两个 tanh 神经元做一次更新

固定 x=2、y=1,L=(ŷ−y)²,η=0.1,c₁=c₂=0、v₁=v₂=1。选择起点后查看每个神经元的梯度。

把多层网络所有参数都设为 0 更糟:本节 ReLU 网络中隐藏输出为 0、隐藏权重梯度也为 0,只有输出偏置能学到类别比例。偏置设为 0 与全部权重设为 0 是两件事;随机初始化权重时,偏置为 0 很常见。

随机也要控制尺度

权重太大可能让 tanh/sigmoid 饱和,太小可能让信号逐层缩小。初始化方法根据输入连接数和激活安排随机尺度。ReLU 隐藏层可从 Kaiming 初始化开始;Xavier 也是常用方案,需要结合激活与 gain。它们提供合理起点,仍要检查真实网络。

ReLU 的 Kaiming 正态初始化(fan_in): 权重均值 0;方差约 2 / 输入特征数;标准差约 √(2 / fan_in) 本例 Linear(2,16) 的 fan_in=2,而不是 16。
hidden = torch.nn.Linear(2, 16)
torch.nn.init.kaiming_normal_(hidden.weight, nonlinearity="relu")
torch.nn.init.zeros_(hidden.bias)
# 本例输出层是线性 logit 层,另用 Xavier 初始化。
output = torch.nn.Linear(16, 1)
torch.nn.init.xavier_normal_(output.weight)
torch.nn.init.zeros_(output.bias)

尺度和 fan_in 的接口说明:PyTorch 初始化文档。

05 / 更新方向与更新尺度

Adam 使用梯度的历史信息

普通 SGD 使用当前梯度:θ←θ−ηg。Adam 为每个参数维护两组统计量:梯度的滑动平均 m,以及梯度平方的滑动平均 v。m 平滑方向,v 参与调整更新尺度。

mₜ=β₁mₜ₋₁+(1−β₁)gₜ;vₜ=β₂vₜ₋₁+(1−β₂)gₜ² m̂ₜ=mₜ/(1−β₁ᵗ);v̂ₜ=vₜ/(1−β₂ᵗ) θₜ=θₜ₋₁−η m̂ₜ/(√v̂ₜ+ε)

初始 m=v=0,会使早期平均偏向 0;带帽统计量做偏差修正。这里演示 β₁=0.9、β₂=0.999、ε=10⁻⁸、η=0.1,无权重衰减。不要把 m、v 当成梯度本身。

实验 D:给一个参数依次输入梯度 2、−1、3

这是预设梯度序列,专门隔离更新规则;它不是来自某个损失函数的收敛实验。SGD 和 Adam 都从 θ=1 出发,学习率均为 0.1。

t / gm̂v̂Adam 参数SGD 参数

第 1 步 g=2,m̂=2、v̂=4,因此 Adam 更新量约为 0.1,而 SGD 更新量为 0.2。第 2 步当前梯度虽然为负,历史平均 m̂ 仍可能为正,Adam 参数仍可能往原来的方向走。不同优化器使用相同学习率,并不代表相同更新幅度,也不构成各自调参后的公平能力排名。

optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
# 每批仍然是 zero_grad → forward → backward → step。

更新规则依据:Adam 官方接口。

学习率调度:明确什么时候改

调度器不求梯度、不替代优化器,只修改学习率。以 StepLR 为例:初始 η=0.01,每完成 40 轮减半;第 1–40 轮用 0.01,第 41–80 轮用 0.005,第 81–120 轮用 0.0025。

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=40, gamma=0.5)
for epoch in range(120):
    train_one_epoch(...)      # 内部有 optimizer.step()
    validate(...)             # eval + no_grad
    scheduler.step()          # 轮末:修改下一轮的学习率

不同调度器触发方式可能不同,例如 ReduceLROnPlateau 接收验证指标。本节先掌握一种;调度不保证验证效果更好。

调用顺序与行为:StepLR。

06 / 一次前向中的随机遮挡

Dropout 改变激活值,不删除参数

对隐藏激活 h=[1,2,3,4,5,6],训练时以概率 p 把每个元素设为 0。保留的元素除以 1−p,以维持每个元素在随机遮挡下的期望。本节只演示普通 nn.Dropout 的逐元素规则。

mᵢ ∈ {0,1},P(mᵢ=1)=1−p 训练:h′ᵢ=mᵢhᵢ/(1−p);评估:h′ᵢ=hᵢ p=0.5 时,一个值 3 有一半机会变 0,一半机会变 6;期望仍是 3。

实验 E:看每个元素的输入、掩码和输出

p 是每个元素的概率,不保证本次恰好丢弃 p 的比例;每次前向重新抽样。遮挡只针对当前计算中的激活,参数仍然存在,下次可能再次参与计算。Dropout 可以抑制对特定激活的过度依赖,但过大可能导致欠拟合。

no_grad() 只控制梯度追踪。如果层还处于 train 模式,即使在 no_grad 中,Dropout 仍会随机遮挡。验证与推理通常同时使用 model.eval() 和 torch.no_grad()。
h = torch.tensor([1., 2., 3., 4., 5., 6.])
dropout = torch.nn.Dropout(p=0.5)
dropout.train()
with torch.no_grad():
    out = dropout(h)      # 仍随机遮挡;保留的值乘 2
dropout.eval()
out = dropout(h)          # 直接返回相同数值

遮挡与缩放规则:Dropout 文档。

07 / 先问“哪些数一起算统计量?”

BatchNorm 和 LayerNorm 的分组不同

归一化的一种常见形式是:一组数减去组内均值,再除以组内标准差。网络归一化层可以再用可学习的 γ、β 缩放和平移。本区固定 γ=1、β=0、ε=10⁻⁵,先看统计量如何计算。

μ=(1/n)Σxᵢ;方差 σ²=(1/n)Σ(xᵢ−μ)² 归一化 x̂ᵢ=(xᵢ−μ)/√(σ²+ε);输出 γx̂ᵢ+β 方差是“离均值有多远”的平方平均;ε 防止分母为零。

固定形状 (B,F)=(2,3):每行一个样本,每列一个特征。BatchNorm1d(3) 在训练时,分别用每列的两个样本计算统计量;LayerNorm(3) 用每行的三个特征计算统计量。

实验 F:点击一个格子,看它和谁一起归一化

原始 X:行是样本,列是特征。绿色为同组成员;蓝框为你选中的元素。

归一化结果(形状仍为 2×3)

例如 BatchNorm 对第 0 列 [1,3]:均值 2,方差 1,输出约 [−1,1]。LayerNorm 对第 0 行 [1,10,100]:均值 37,方差 1998,输出约 [−0.8054,−0.6040,1.4094]。组不同,结果自然不同;这不是交换轴,也不是广播加偏置。

本例统计量分组训练与评估可学习参数默认形状
BatchNorm1d(3)每列跨样本默认 train 用当前批统计量;eval 用 running_mean / running_varγ、β 各 (3,)
LayerNorm(3)每行最后 3 个特征train 和 eval 都使用当前输入的组内统计量γ、β 各 (3,)
BatchNorm 的 running 统计量是什么?

默认 momentum=0.1 时,每次训练前向会更新统计量。例如 running_mean 初始为 0,这次某列均值为 2,则新 running_mean=0.9×0+0.1×2=0.2。它是跨批次累积的估计,不是可学习的梯度参数。

PyTorch 训练前向归一化的方差使用除以 n 的估计;更新 running_var 使用除以 n−1 的估计。因此本例第 0 列训练方差为 1,而 running_var 从 1 更新为 0.9×1+0.1×2=1.1。配套代码分别核对两种计算。

默认 eval 使用累计统计量;若设置 track_running_stats=False,评估也会用当前批。对本节二维输入,BatchNorm 训练时每列只有一个数会报错;小批次统计也可能不稳定。LayerNorm 不跨样本统计,但它与 BatchNorm 的作用并不完全等价。

bn = torch.nn.BatchNorm1d(3)  # 输入 (B,3)
ln = torch.nn.LayerNorm(3)    # 对每个样本最后 3 个数归一化
# 层内归一化与输入预处理分开:
# 输入标准化的 mean/std 仍只在训练集拟合,并保存供推理复用。

层内归一化有助于调整激活尺度与训练过程,但不会保证泛化改善;不能代替正确的数据划分或损失设计。

分组、估计与模式依据:BatchNorm1d、LayerNorm。

08 / 看真实结果,避免凭印象调参

同一数据、同一训练预算,改变一个主要因素

任务是二维 XOR 分类:两个坐标一正一负时标签为 1,同号时为 0。训练集 128 条,其中随机翻转 13 条标签模拟噪声;验证集 256 条使用干净规则。划分固定,输入均值与标准差仅用训练集拟合。

基线:2 → 16 → 16 → 1;隐藏 ReLU;Kaiming 隐藏初始化 Adam η=0.01;全批训练 120 轮(一轮一次更新);无 Dropout、无归一化 种子:7001、7002、7003;同一种子共同层的初始权重相同
下面的数据来自已运行的 PyTorch 实验,嵌入页面,无需联网。训练损失和验证损失都在 eval + no_grad 下计算,因此 Dropout 不会给两条曲线引入不同的评估模式。

实验 G:基线与一个改动对照

绿色:基线蓝色:当前改动实线:训练 BCE虚线:验证 BCE

每条线是 3 个种子的逐轮平均;不是置信区间。图中 BCE 越低越好。改动是否值得保留,还要看各次运行与误差分析。

配置最佳验证 BCE:均值 ± 样本标准差对应准确率均值各次最佳轮数

“最佳”是在每次运行中按验证 BCE 选择轮数,再汇总 3 次;对应准确率不是另挑最高准确率。验证集同时用于选择轮数与比较配置,因此这不是独立测试成绩。这里只比较当前学习率与预算,尤其 SGD 未单独调学习率,不能据此宣称 Adam 在所有任务上更好。

Sigmoid 配置保持同样的初始权重,目的是隔离激活改动;它没有重新选择与 Sigmoid 匹配的初始化。训练标签含噪声、验证标签干净,因此验证损失低于训练损失也可以正常,不能仅凭这点断定数据泄漏。

本例基线平均最佳验证 BCE 最低。Dropout、LayerNorm 和调度没有改善这一指标;它们在其他数据、网络或预算中可能有帮助。全零网络只能学习近似类别比例,不能学出 XOR 边界。这些判断限于本次实验。

汇总 CSV · 21 次运行记录 · 逐轮曲线和梯度范数 · 完整结果 JSON

09 / 损失不下降时按证据排查

先确定训练真的在发生

① 检查数据与目标。

检查输入/标签是否配对、形状是否一致、标签是否合理。二分类 BCEWithLogitsLoss 接收原始 logits 与同形状的浮点标签。本例都是 (B,1)。BCEWithLogitsLoss 会拒绝不一致的目标尺寸;前节的 MSE 则可能在警告后广播,因此每种损失都要核对输入要求。

② 检查图和参数。

loss 是否仍连着参数?有没有把训练前向放在 no_grad 中,或误用 detach?参数是否在 optimizer 中?backward 后 grad 是 None、零、很小,还是很大?检查到 None 与零的原因不同。

③ 检查更新。

是否每次清空、反向、step?更新前后参数有没有变化?学习率太小可能变化很慢,太大可能振荡或发散。初始化、输入尺度和激活饱和也要一起观察。

④ 先尝试拟合很小一批。

暂时移除 Dropout 等干扰,固定少量干净样本反复训练。如果连它们都无法拟合,优先检查实现、表达能力和优化。能拟合小批次只说明基础流程可行,不证明泛化良好。

⑤ 再看训练与验证的差距。

训练改善而验证变差,可检查过拟合、分布差异与评估模式。必要时试早停、正则化或更多数据,每次只改变一个主要因素,保留对照记录。

loss.backward()
for name, parameter in model.named_parameters():
    if parameter.grad is None:
        print(name, "grad=None:检查是否参与本次计算")
    else:
        print(name, "grad norm=", parameter.grad.norm().item(),
              "finite=", torch.isfinite(parameter.grad).all().item())
# 可在 step 前后 clone 某个参数,比较其变化。
梯度很大时可以直接裁剪吗?

可以在 backward 后、step 前尝试 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。它限制梯度总范数,有时帮助稳定,但先检查学习率、异常输入与损失。它不是修复 NaN 或错误代码的替代方案;本节对照实验没有使用裁剪。

10 / 真正运行一次,并做自己的改动

从机制核对到实验报告

配套 PyTorch 代码直接使用当前项目环境,不下载数据,不添加依赖,不修改 uv 缓存设置。

uv run python lessons/lesson-07-training.py

运行后输出到 lessons/lesson-07-output/。机制断言会核对链式梯度、Adam 手算、Dropout 缩放以及 BatchNorm/LayerNorm 分组;21 次训练生成 CSV 与 JSON,并保存一个最佳基线 checkpoint,验证加载后的 logits 完全一致。

网络的 PyTorch 结构如何对应本节?
class Network(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = torch.nn.Sequential(
            torch.nn.Linear(2, 16),
            torch.nn.ReLU(),
            torch.nn.Linear(16, 16),
            torch.nn.ReLU(),
            torch.nn.Linear(16, 1),  # 原始 logit
        )
    def forward(self, x):
        return self.layers(x)
# 具体初始化、配置切换和训练循环见配套文件。
# 加 LayerNorm 时本例放在 Linear 之后、激活之前;
# 加 Dropout 时放在隐藏激活之后。
  1. 手算 3 层局部乘积 0.25 的回传梯度,再用代码检查。
  2. 解释 p=0.5 时保留值为什么乘 2;说明 no_grad 是否关闭 Dropout。
  3. 对本节的 2×3 矩阵,手算一个 BatchNorm 和一个 LayerNorm 输出。
  4. 在新的实验副本中只改一个因素,例如 Dropout 从 0.3 改成 0.1;保留原结果再运行。页面嵌入的是本次结果,重新跑 Python 不会自动更新页面。
  5. 写一小段报告:改了什么、固定了什么、3 个种子的验证结果如何、保留改动的依据是什么。不要只挑最好的单次运行。

11 / 检查理解

五道自测

1. 一条路径经过 3 层,每层 w×f′=0.25,末端梯度 1,输入梯度是多少?

2. ReLU 网络全部参数设为 0,与随机权重、零偏置一样吗?

3. Dropout 处于 train 模式,在 no_grad 中是否还遮挡?

4. 对输入 (B,3),本例 LayerNorm(3) 把哪些数一起计算统计量?

5. 本例 Adam 比同学习率 SGD 更好,是否说明 Adam 永远更好?

下一节:卷积神经网络。把输入从表格变成图像,理解卷积、池化、感受野,以及如何逐层跟踪张量形状。