神经网络为什么能从数据中学到规律?我们可以从一次训练开始理解。网络首先根据当前参数计算预测,损失函数随后衡量预测与答案的差距,训练程序再根据梯度调整参数。这个过程反复进行,模型对训练样本的拟合通常就会逐步改善。

本课用 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 自动微分。