神经网络为什么能从数据中学到规律?我们可以从一次训练开始理解。网络首先根据当前参数计算预测,损失函数随后衡量预测与答案的差距,训练程序再根据梯度调整参数。这个过程反复进行,模型对训练样本的拟合通常就会逐步改善。
本课用 NumPy 编写一个带隐藏层的小网络,让它学习 XOR,也就是两个输入不同才输出 1 的规则。我们选择这个问题,是因为四个样本就能把训练过程展示完整。读者可以逐项查看输出、损失、梯度和参数更新;真实任务的泛化能力还需要额外数据来评估。
一层网络怎样算
我们把输入矩阵记为 X,把权重记为 W,把偏置记为 b,那么一层的线性计算就是 Z = XW + b。这一层先混合输入特征,再通过 tanh 或 ReLU 等激活函数产生非线性表示,下一层继续处理这个结果。
激活函数为什么重要?假如网络的每一层都只做线性变换,多层计算仍能合并成一次线性变换。非线性激活使网络能够表达更复杂的关系,隐藏层因此才有了更丰富的作用。
本例一共有四个样本,每个样本包含两个输入。网络的八个隐藏单元把输入变成八维表示,输出层再把它变成一个数,所以矩阵形状依次是 4×2 → 4×8 → 4×1。偏置会沿样本维广播,同一层中的所有样本共享这组参数。
输出层给出的这个数称为 Logit。Sigmoid 可以把它转换成二分类概率,二元交叉熵则衡量该预测与 0/1 目标的差距。在 PyTorch 中,BCEWithLogitsLoss 把 Sigmoid 和损失计算合在一起;单标签多分类常用的 CrossEntropyLoss 同样接收各类别的 Logits。二元损失、多分类损失
梯度从哪里来
损失函数已经告诉我们预测差了多少,接下来的问题是:每个参数应该怎样调整?反向传播利用链式法则,计算损失对各层参数的导数。梯度描述了参数在当前位置发生小幅变化时,损失会怎样变化。
优化器拿到梯度后,才执行参数更新。最简单的梯度下降可以写成 参数 -= 学习率 × 梯度。学习率决定这一步走多远:步子太小,训练进展缓慢;步子太大,参数可能越过低损失区域,甚至来回振荡。
因此,训练损失没有下降时,我们首先要确认形状、标签和梯度是否正确。只有这些计算成立,比较不同优化器才有意义。
一个可以完整运行的网络
下面的代码分为两个部分。程序先用中心差分抽查手写导数,再运行训练循环。中心差分会略微改变一个参数,观察损失的变化,从而估计该位置的导数;这种方法很适合检查小例子,但计算成本较高,所以训练循环仍使用解析梯度。
import numpy as np
rng = np.random.default_rng(12)
x = np.array([[-1., -1.], [-1., 1.], [1., -1.], [1., 1.]])
y = np.array([[0.], [1.], [1.], [0.]])
params = {
"w1": rng.normal(0, 0.5, (2, 8)), "b1": np.zeros((1, 8)),
"w2": rng.normal(0, 0.5, (8, 1)), "b2": np.zeros((1, 1)),
}
def forward(p):
hidden = np.tanh(x @ p["w1"] + p["b1"])
logits = hidden @ p["w2"] + p["b2"]
loss = np.mean(np.logaddexp(0, logits) - y * logits)
return hidden, logits, float(loss)
def gradients(p):
hidden, logits, _ = forward(p)
probability = 1 / (1 + np.exp(-logits))
dz2 = (probability - y) / len(x)
dhidden = dz2 @ p["w2"].T
dz1 = dhidden * (1 - hidden ** 2)
return {
"w2": hidden.T @ dz2, "b2": dz2.sum(axis=0, keepdims=True),
"w1": x.T @ dz1, "b1": dz1.sum(axis=0, keepdims=True),
}
analytic = gradients(params)
epsilon = 1e-5
for name, index in [("w1", (0, 2)), ("w2", (3, 0)), ("b1", (0, 4))]:
old = params[name][index]
params[name][index] = old + epsilon
plus = forward(params)[2]
params[name][index] = old - epsilon
minus = forward(params)[2]
params[name][index] = old
np.testing.assert_allclose((plus - minus) / (2 * epsilon),
analytic[name][index], rtol=1e-5, atol=1e-7)
initial_loss = forward(params)[2]
for step in range(2500):
grads = gradients(params)
for name in params:
assert grads[name].shape == params[name].shape
assert np.isfinite(grads[name]).all()
params[name] -= 0.2 * grads[name]
_, logits, final_loss = forward(params)
prediction = (logits > 0).astype(int)
assert np.array_equal(prediction, y)
assert final_loss < 0.05 < initial_loss
print("initial loss:", round(initial_loss, 4))
print("final loss:", round(final_loss, 4))
print("prediction:", prediction.ravel().tolist())
我们可以沿着函数调用读这段代码。forward 依次产生隐藏表示、Logits 和平均损失,其中 logaddexp 帮助程序稳定计算二元交叉熵。gradients 从输出误差出发,计算第二层参数的梯度,再通过第二层权重把导数传回隐藏层。
训练循环在拿到全部梯度后才更新参数。这样各层导数都基于同一组旧参数,程序不会在计算前一层梯度时误用已经更新的权重。最后的断言确认了预测结果与四个目标一致,以及损失已经明显下降。
换到 PyTorch 时看哪些地方
PyTorch 会替我们记录计算图并完成自动求导,因此训练循环通常写成 zero_grad → forward → loss → backward → step。程序先清理旧梯度,再做前向与损失计算,接着反向求导,最后让优化器更新参数。我们仍要确认需要训练的参数已经加入优化器,标签格式也与损失函数匹配。
下表列出几种常见组合,读者可以用它核对自己的输出与目标:
| 任务 | 输出形状示意 | 目标 |
|---|---|---|
| 单标签多分类 | B×C Logits |
B 个类别索引 |
| 单输出二分类 | B×1 Logit |
同形状的 0/1 浮点目标 |
| 多标签分类 | B×C Logits |
同形状的多标签目标 |
形状检查尤其值得保留。例如,回归模型输出 [B,1],标签却是 [B],两者可能广播成一个误差矩阵。此时程序虽然能算出损失,却可能让每个预测和整批标签比较,偏离原本的逐样本训练目标。
验证阶段通常会调用 eval(),让 BatchNorm、Dropout 等层采用评估行为;程序也可以使用 no_grad() 停止记录梯度计算图。研究者根据验证表现保存相应检查点,等方案确定后,再使用测试集评估。
练习
读者可以把隐藏单元数改为 2、4、16,比较不同随机种子下的结果,再尝试较大和较小的学习率。请把失败的设置也记录下来,并结合损失、梯度和预测分析原因:网络可能遇到了数值问题,也可能存在优化困难或容量限制。
前置:机器学习建模流程与评估方法。后续可进入卷积网络、图神经网络或序列模型。
资料:疏锦行机器学习基础班《神经网络》;PyTorch 自动微分。
深度学习 01:亲手走完一次神经网络训练
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
评论交流
欢迎留下你的想法