01 / 本节要解决什么?
能运行之后,要知道哪里出了问题
梯度能否传回来?
激活导数与权重决定反向信号的大小。多层相乘可能使它缩小或放大。
参数怎样更新?
初始化确定起点,优化器确定更新规则,调度器安排学习率的变化。
验证效果如何?
Dropout 与归一化有具体作用,也有适用条件;看验证结果再判断。
先读公式,再动图解,最后运行代码。你已经会偏导和矩阵乘法,本节不要求先学复杂网络。真实训练使用当前项目的 uv 管理 Python 和 CPU 版 PyTorch。
uv run python lessons/lesson-07-training.py02 / 为什么在线性层之间加函数?
激活函数引入非线性,也影响梯度
如果两层之间什么都不加,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 | 很正或很负时趋近饱和,导数小 |
| tanh | tanh(z),范围 (−1,1) | 1−tanh²(z),最大 1 | 绝对值大时也会饱和 |
| ReLU | max(0,z) | z>0 为 1;z<0 为 0 | 负区间没有梯度;PyTorch 在 0 处取 0 |
实验 A:移动输入,看看输出和斜率
导数可以理解成:输入略微改变时,输出会改变多少。饱和区中输出几乎不动,导数也小;这会影响传到前一层的梯度。
输出层的激活与损失要怎么配?
二分类网络可输出一个原始分数,叫 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ₖ 才能传给前一层。
假设这条路径上每个权重为 1,每个 sigmoid 输入都为 0,则每层局部导数为 0.25。末端梯度为 1 时,过一层剩 0.25,过三层剩 0.015625,过十层只剩约 0.000000954。
实验 B:跟随同一条路径回传
真实多维网络会涉及矩阵乘法与多条路径求和,上面的乘积是单路径示例。它解释了缩小和放大的机制,不能直接推出每个复杂网络的总梯度。配套 Python 用 h=σ(h)−0.5 连续计算,使每层输入恰好为 0,并验证输入梯度等于 0.25**层数。
04 / 相同起点会发生什么?
隐藏神经元需要打破对称性
同一层两个隐藏神经元,如果权重和偏置完全相同,并且它们到后面的连接也相同,它们会输出同样的数、收到同样的梯度、继续保持相同。你放了两个神经元,却没让它们学习不同的特征。
实验 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。它们提供合理起点,仍要检查真实网络。
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=v=0,会使早期平均偏向 0;带帽统计量做偏差修正。这里演示 β₁=0.9、β₂=0.999、ε=10⁻⁸、η=0.1,无权重衰减。不要把 m、v 当成梯度本身。
实验 D:给一个参数依次输入梯度 2、−1、3
这是预设梯度序列,专门隔离更新规则;它不是来自某个损失函数的收敛实验。SGD 和 Adam 都从 θ=1 出发,学习率均为 0.1。
| t / g | m̂ | 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 的逐元素规则。
实验 E:看每个元素的输入、掩码和输出
p 是每个元素的概率,不保证本次恰好丢弃 p 的比例;每次前向重新抽样。遮挡只针对当前计算中的激活,参数仍然存在,下次可能再次参与计算。Dropout 可以抑制对特定激活的过度依赖,但过大可能导致欠拟合。
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⁻⁵,先看统计量如何计算。
固定形状 (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 条使用干净规则。划分固定,输入均值与标准差仅用训练集拟合。
实验 G:基线与一个改动对照
每条线是 3 个种子的逐轮平均;不是置信区间。图中 BCE 越低越好。改动是否值得保留,还要看各次运行与误差分析。
| 配置 | 最佳验证 BCE:均值 ± 样本标准差 | 对应准确率均值 | 各次最佳轮数 |
|---|
“最佳”是在每次运行中按验证 BCE 选择轮数,再汇总 3 次;对应准确率不是另挑最高准确率。验证集同时用于选择轮数与比较配置,因此这不是独立测试成绩。这里只比较当前学习率与预算,尤其 SGD 未单独调学习率,不能据此宣称 Adam 在所有任务上更好。
Sigmoid 配置保持同样的初始权重,目的是隔离激活改动;它没有重新选择与 Sigmoid 匹配的初始化。训练标签含噪声、验证标签干净,因此验证损失低于训练损失也可以正常,不能仅凭这点断定数据泄漏。
本例基线平均最佳验证 BCE 最低。Dropout、LayerNorm 和调度没有改善这一指标;它们在其他数据、网络或预算中可能有帮助。全零网络只能学习近似类别比例,不能学出 XOR 边界。这些判断限于本次实验。
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 时放在隐藏激活之后。- 手算 3 层局部乘积 0.25 的回传梯度,再用代码检查。
- 解释 p=0.5 时保留值为什么乘 2;说明 no_grad 是否关闭 Dropout。
- 对本节的 2×3 矩阵,手算一个 BatchNorm 和一个 LayerNorm 输出。
- 在新的实验副本中只改一个因素,例如 Dropout 从 0.3 改成 0.1;保留原结果再运行。页面嵌入的是本次结果,重新跑 Python 不会自动更新页面。
- 写一小段报告:改了什么、固定了什么、3 个种子的验证结果如何、保留改动的依据是什么。不要只挑最好的单次运行。
11 / 检查理解