Pdch's log

PRML 第五章:神经网络 — 从固定基函数到可学习表示

13 分钟
来源: Pixiv 129053281

PRML 第五章:神经网络

第四章的最后一节问了一个问题:谁来选择基函数? 手工加上二次基函数 $\phi(\mathbf{x})=[1,x_1,x_2,x_1^2,x_1x_2,x_2^2]^\top$ 能解决 moons 问题,但换一个任务就得重新设计,而且固定基函数的数量会随着特征维度爆炸式增长。

神经网络的答案很直接:让模型自己学特征表示。把基函数也变成参数:

$$ y(\mathbf{x},\mathbf{w})=f\left(\sum_{j}w_{kj}^{(2)}\,h\!\left(\sum_{i}w_{ji}^{(1)}x_i+w_{j0}^{(1)}\right)+w_{k0}^{(2)}\right). $$

外层 $f$ 与隐藏层非线性 $h$ 都带参数,特征表示和分类器一起从数据中学习。本章沿着 PRML Chapter 5 的主线:

  1. 前馈网络结构(§5.1):多层非线性变换,以及它为什么比固定基函数更灵活;
  2. 网络训练(§5.2):目标函数的选择与梯度下降;
  3. 误差反向传播(§5.3):全书最重要的算法之一——梯度如何高效计算;
  4. 正则化(§5.5):权重衰减与早停;
  5. 贝叶斯神经网络(§5.7):对权重的不确定性建模。

理解本章的关键:第四章的所有结论——损失函数来自分布假设、L2 正则化等价于高斯先验——在神经网络里一条都不少,只是 $y(\mathbf{x},\mathbf{w})$ 从线性函数变成了多层非线性函数。

环境准备与数据

本章用两个数据集:XOR 四点(经典的线性不可分问题)和 make_moons(两类弯曲交错)。网络全部用 NumPy 手写,不依赖任何深度学习框架——反向传播核心只有十几行,亲手实现一次就再也不会忘。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

plt.style.use("seaborn-v0_8-whitegrid")
rng = np.random.default_rng(42)


def plot_boundary(ax, X_data, labels, predict_fn, title, resolution=220, alpha=0.25):
    """predict_fn 接收 (N, 2) 特征矩阵,返回 (N,) 的概率。"""
    x_min, x_max = X_data[:, 0].min() - 0.5, X_data[:, 0].max() + 0.5
    y_min, y_max = X_data[:, 1].min() - 0.5, X_data[:, 1].max() + 0.5
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, resolution), np.linspace(y_min, y_max, resolution))
    grid = np.c_[xx.ravel(), yy.ravel()]
    probability = predict_fn(grid).reshape(xx.shape)
    ax.contourf(xx, yy, probability, levels=np.linspace(0, 1, 21), cmap="RdBu_r", alpha=alpha)
    ax.contour(xx, yy, probability, levels=[0.5], colors="black", linewidths=2)
    ax.scatter(X_data[:, 0], X_data[:, 1], c=labels, cmap="RdBu_r", edgecolor="white", s=30)
    ax.set(title=title, xlabel="$x_1$", ylabel="$x_2$")


# XOR:经典的线性不可分问题
X_xor = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
t_xor = np.array([0, 1, 1, 0], dtype=float)

# moons:弯曲交错的两类数据
X_moons, t_moons = make_moons(n_samples=300, noise=0.15, random_state=42)
X_moons_train, X_moons_test, t_moons_train, t_moons_test = train_test_split(
    X_moons, t_moons, test_size=0.3, random_state=42, stratify=t_moons
)

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
axes[0].scatter(X_xor[:, 0], X_xor[:, 1], c=t_xor, cmap="RdBu_r", edgecolor="white", s=120)
axes[0].set(title="XOR: linearly inseparable", xlabel="$x_1$", ylabel="$x_2$")
axes[1].scatter(X_moons_train[:, 0], X_moons_train[:, 1], c=t_moons_train, cmap="RdBu_r", edgecolor="white", s=30)
axes[1].set(title="Moons: curved boundary", xlabel="$x_1$", ylabel="$x_2$")
plt.tight_layout()
plt.show()

print("XOR shape:", X_xor.shape, " moons train/test:", X_moons_train.shape, X_moons_test.shape)
notebook output
XOR shape: (4, 2)  moons train/test: (210, 2) (90, 2)

1. 前馈网络结构(PRML §5.1)

考虑一个隐藏层的网络。第 $j$ 个隐藏单元的激活值是输入的加权和再经过非线性 $h$:

$$ a_j=\sum_{i}w_{ji}^{(1)}x_i+w_{j0}^{(1)},\qquad z_j=h(a_j). $$

输出单元(以二分类 sigmoid 为例)再对隐藏表示做一次加权组合:

$$ a_k=\sum_{j}w_{kj}^{(2)}z_j+w_{k0}^{(2)},\qquad y_k=\sigma(a_k). $$

把偏置 $w_{j0}$ 吸收进权重(在输入中固定一个恒为 1 的 $x_0$),两层网络可以写成简洁的矩阵形式:

$$ \mathbf{z}=h(W^{(1)}\mathbf{x}),\qquad \mathbf{y}=\sigma(W^{(2)}\mathbf{z}). $$
  • 第一层权重 $W^{(1)}$ 定义了一组可学习的基函数,每个隐藏单元对应一个方向;
  • 第二层权重 $W^{(2)}$ 在学到的特征上做线性分类/回归;
  • 所以 MLP 可以看作"自适应基函数 + 线性模型",第四章的线性模型是隐藏单元数 $M=0$ 的特例。

上标 $(1)$ 表示从输入到隐藏层、$(2)$ 表示从隐藏层到输出层;隐藏单元数 $M$ 控制模型的复杂度。

# 前向传播:两层网络 z = h(X @ W1 + b1),  y = sigmoid(z @ W2 + b2)
def forward_demo(X_data, W1, b1, W2, b2, hidden_activation=np.tanh):
    z = hidden_activation(X_data @ W1 + b1)
    a = z @ W2 + b2
    return z, a

rng_demo = np.random.default_rng(0)
W1 = rng_demo.normal(size=(2, 5))
b1 = rng_demo.normal(size=(5,))
W2 = rng_demo.normal(size=(5, 1))
b2 = rng_demo.normal(size=(1,))
X_probe = rng_demo.normal(size=(1000, 2))

# 如果隐藏层是恒等映射(没有非线性),两层会退化为单层:
#   a = (X @ W1 + b1) @ W2 + b2 = X @ (W1 @ W2) + (b1 @ W2 + b2)
z_identity, a_identity = forward_demo(X_probe, W1, b1, W2, b2, hidden_activation=lambda a: a)
W_effective = W1 @ W2          # 合并后的权重
b_effective = b1 @ W2 + b2     # 合并后的偏置
max_diff = np.abs(a_identity - (X_probe @ W_effective + b_effective)).max()
print(f"max |a - (X @ W_eff + b_eff)| = {max_diff:.2e}   (两层线性 = 一层线性)")

# 换成 tanh 非线性,同样的权重就"掰弯"了输出
z_tanh, a_tanh = forward_demo(X_probe, W1, b1, W2, b2, hidden_activation=np.tanh)
print(f"with tanh: output range = [{a_tanh.min():+.3f}, {a_tanh.max():+.3f}],  "
      f"cannot be written as X @ W_eff + b_eff")
max |a - (X @ W_eff + b_eff)| = 1.78e-15   (两层线性 = 一层线性)
with tanh: output range = [-2.649, +2.889],  cannot be written as X @ W_eff + b_eff

1.1 权重空间对称性(§5.1.1)与万能近似(§5.1.2)

对称性:交换两个隐藏单元 $j_1,j_2$ 以及它们对应的第一、二层权重,网络的输入-输出映射完全不变。因此每个含 $M$ 个隐藏单元的网络对应 $M!$ 个等价的参数设置;对 $\tanh$ 这类奇对称激活函数,同时翻转某个单元的第一层权重符号与第二层权重符号也保持映射不变,又带来 $2^M$ 倍。这些对称解对函数没有任何影响,却让梯度下降在不同副本之间游走,形成大量等价的局部极小。

万能近似:只要隐藏单元数足够多、激活函数是光滑的非线性函数,两层网络就能以任意精度逼近紧集上的任意连续函数。这正面回答了第四章"谁来选基函数"——网络本身就可以生成任意形状的边界。但定理只说"存在":梯度下降能否找到这样的权重、需要多少数据,是另一回事。

# 对称性验证:交换两个隐藏单元(及其对应权重),网络输出逐点相同
rng_sym = np.random.default_rng(1)
W1_sym = rng_sym.normal(size=(2, 4))
b1_sym = rng_sym.normal(size=(4,))
W2_sym = rng_sym.normal(size=(4, 1))
b2_sym = rng_sym.normal(size=(1,))
X_probe2 = rng_sym.normal(size=(500, 2))

_, a_before = forward_demo(X_probe2, W1_sym, b1_sym, W2_sym, b2_sym, hidden_activation=np.tanh)

perm = np.array([2, 0, 3, 1])  # 任意交换隐藏单元的顺序
_, a_after = forward_demo(
    X_probe2, W1_sym[:, perm], b1_sym[perm], W2_sym[perm], b2_sym,
    hidden_activation=np.tanh,
)
print(f"max |a_before - a_after| after permuting hidden units: {np.abs(a_before - a_after).max():.2e}")
max |a_before - a_after| after permuting hidden units: 2.22e-16

2. 网络训练(PRML §5.2)

把所有权重收集成一个向量 $\mathbf{w}$,训练就是最小化误差函数 $E(\mathbf{w})$。误差函数的选择与前面章节完全一致,取决于输出单元和数据分布的假设:

任务输出单元误差函数概率假设
回归线性 $y=a$平方和 $\frac12\sum_n\|y_n-t_n\|^2$高斯噪声
二分类sigmoid $y=\sigma(a)$交叉熵 $-\sum_n[t_n\log y_n+(1-t_n)\log(1-y_n)]$伯努利
多分类softmax交叉熵 $-\sum_n\sum_k t_{nk}\log y_{nk}$类别分布

对分类问题,如果错误地使用平方和误差,sigmoid 在输出接近 0 或 1 时导数为零,梯度会消失,学习变得极慢;而交叉熵与 sigmoid/softmax 组合恰好给出"预测减标签"的干净梯度。这正是第四章的结论在神经网络里的延续。

2.1 为什么梯度信息如此重要(§5.2.3)

如果只把 $E(\mathbf{w})$ 当作黑盒,用试探性方法(比如沿每个坐标方向做一维搜索)找极小值,每个方向都需要一次函数求值——对 $W$ 个权重,一次迭代要 $O(W)$ 次前向计算。反向传播给出的答案是:一次前向 + 一次反向传播,同时得到全部 $W$ 个偏导数 $\partial E/\partial w$,代价只是前向的常数倍。当网络有几千到几百万个权重时,这是从"不可行"到"可行"的差别——现代深度学习的可行性正建立在这条性质上。

2.2 梯度下降(§5.2.4)

$$ \mathbf{w}^{(\tau+1)}=\mathbf{w}^{(\tau)}-\eta\nabla E(\mathbf{w}^{(\tau)}). $$

学习率 $\eta$ 是第一个要调的超参数:太大发散,太小收敛缓慢。每次用全部数据计算梯度的版本叫批量梯度下降;每次用一个(或一小批)样本更新则叫随机梯度下降(SGD)——SGD 的随机性有时还能帮助逃离不好的局部极小。

def stable_sigmoid(values):
    values = np.asarray(values, dtype=float)
    result = np.empty_like(values)
    positive = values >= 0
    result[positive] = 1.0 / (1.0 + np.exp(-values[positive]))
    exp_values = np.exp(values[~positive])
    result[~positive] = exp_values / (1.0 + exp_values)
    return result


def binary_cross_entropy(logits, labels):
    # log(1 + exp(a)) - t*a,数值稳定
    return np.mean(np.logaddexp(0.0, logits) - labels * logits)


# 分类任务上,交叉熵 vs 平方误差的梯度行为
logits = np.linspace(-10, 10, 400)
labels = np.ones_like(logits)
prob = stable_sigmoid(logits)
ce_gradient = prob - labels                 # ∂BCE/∂a = σ(a) - t
se_gradient = (prob - labels) * prob * (1 - prob)  # ∂(½(p-t)²)/∂a = (p-t)p(1-p)

fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))
for ax, xlim, ylim, title in [(axes[0], (-10, 10), (-1.1, 0.2), "Full range"),
                              (axes[1], (-4, 4), (-0.5, 0.1), "Zoom: saturated region")]:
    ax.plot(logits, ce_gradient, linewidth=2, label="cross-entropy gradient")
    ax.plot(logits, se_gradient, linewidth=2, label="squared-error gradient")
    ax.axhline(0, color="black", linestyle="--", linewidth=0.8)
    ax.set(xlim=xlim, ylim=ylim, xlabel="logit a (true label = 1)", ylabel="∂E/∂a", title=title)
    ax.legend()
plt.tight_layout()
plt.show()

idx_m6 = np.argmin(np.abs(logits - (-6)))
print(f"squared-error gradient at a=-6: {se_gradient[idx_m6]:.2e}  (梯度消失,学习几乎停滞)")
notebook output
squared-error gradient at a=-6: -2.49e-03  (梯度消失,学习几乎停滞)

3. 误差反向传播(PRML §5.3)

记第 $j$ 个单元的加权和为 $a_j$(PRML 中称为 activation),激活输出为 $z_j=h(a_j)$。

前向传播逐层计算:

$$ a_j=\sum_i w_{ji}z_i\quad(\text{对输入层 } z_i=x_i),\qquad z_j=h(a_j). $$

目标:对任意权重 $w_{ji}$ 计算 $\partial E/\partial w_{ji}$。链式法则把它拆成两项:

$$ \frac{\partial E}{\partial w_{ji}}= \underbrace{\frac{\partial E}{\partial a_j}}_{\delta_j}\, \underbrace{\frac{\partial a_j}{\partial w_{ji}}}_{z_i} =\delta_j z_i. $$

第二项只是上游单元的激活 $z_i$,前向时已经算好。关键在第一项——误差 $\delta_j$。

输出层。以平方误差 $E=\frac12\sum_k(y_k-t_k)^2$ 和线性输出 $y_k=a_k$ 为例:

$$ \delta_k=\frac{\partial E}{\partial a_k}=y_k-t_k. $$

隐藏层。$a_j$ 通过所有下游单元 $k$ 影响 $E$,全导数公式给出递归关系:

$$ \delta_j=\frac{\partial E}{\partial a_j} =\sum_k\frac{\partial E}{\partial a_k}\frac{\partial a_k}{\partial a_j} =h'(a_j)\sum_k w_{kj}\delta_k. $$

于是误差从输出层向后逐层传播:

$$ \delta_k\;\xrightarrow{\;w_{kj}\;}\;\delta_j=h'(a_j)\sum_k w_{kj}\delta_k \;\xrightarrow{\;w_{ji}\;}\;\frac{\partial E}{\partial w_{ji}}=\delta_j z_i. $$

这正是"反向传播"名字的由来:误差 $\delta$ 像信号一样从输出端流回输入端。计算量与前向同量级——这就是 §2.1 所说的"一次反向得到全部梯度"。

class MLP:
    """NumPy 手写两层网络:输入层 → 隐藏层(tanh/relu)→ sigmoid 输出层。

    结构: x (D,) -> [W1, b1, h] -> z (M,) -> [W2, b2, sigmoid] -> y (1,)
    损失: 二元交叉熵(按样本数平均)。
    """

    def __init__(self, input_dim, hidden_dim, activation="tanh", seed=0, scale=1.0):
        rng_init = np.random.default_rng(seed)
        self.activation_name = activation
        # 1/sqrt(fan_in) 量级初始化:让 tanh 单元初始落在线性区附近,避免一开始就饱和
        self.W1 = rng_init.normal(0.0, scale / np.sqrt(input_dim), (input_dim, hidden_dim))
        self.b1 = np.zeros(hidden_dim)
        self.W2 = rng_init.normal(0.0, scale / np.sqrt(hidden_dim), (hidden_dim, 1))
        self.b2 = np.zeros(1)

    def _activate(self, a):
        if self.activation_name == "tanh":
            return np.tanh(a)
        return np.maximum(0.0, a)  # relu

    def _activate_derivative(self, z):
        if self.activation_name == "tanh":
            return 1.0 - z**2
        return (z > 0).astype(float)

    def forward(self, X_data):
        """前向传播,缓存中间量供 backward 使用。返回 (z, y)。"""
        self.z = self._activate(X_data @ self.W1 + self.b1)
        self.a2 = self.z @ self.W2 + self.b2
        self.y = stable_sigmoid(self.a2)
        return self.z, self.y

    def backward(self, X_data, labels):
        """反向传播。delta2 = y - t;  delta1 = (delta2 @ W2.T) * h'(z)。"""
        delta2 = self.y - labels.reshape(-1, 1)                       # 输出层误差
        grad_W2 = self.z.T @ delta2 / len(X_data)
        grad_b2 = delta2.mean(axis=0)
        delta1 = (delta2 @ self.W2.T) * self._activate_derivative(self.z)  # 隐藏层误差
        grad_W1 = X_data.T @ delta1 / len(X_data)
        grad_b1 = delta1.mean(axis=0)
        return {"W1": grad_W1, "b1": grad_b1, "W2": grad_W2, "b2": grad_b2}

    def loss(self, X_data, labels):
        self.forward(X_data)
        return binary_cross_entropy(self.a2.ravel(), labels)

    def train(self, X_data, labels, learning_rate=1.0, iterations=2000):
        losses = []
        for _ in range(iterations):
            self.forward(X_data)
            grads = self.backward(X_data, labels)
            for name, gradient in grads.items():
                setattr(self, name, getattr(self, name) - learning_rate * gradient)
            losses.append(self.loss(X_data, labels))
        return np.array(losses)
# 数值梯度验证:∂E/∂w ≈ (E(w+ε) - E(w-ε)) / 2ε
def numerical_gradient(net, X_data, labels, epsilon=1e-6):
    grads = {}
    for name in ["W1", "b1", "W2", "b2"]:
        parameter = getattr(net, name)
        flat = parameter.ravel()
        grad_flat = np.zeros_like(flat)
        for index in range(len(flat)):
            plus = flat.copy(); plus[index] += epsilon
            minus = flat.copy(); minus[index] -= epsilon
            setattr(net, name, plus.reshape(parameter.shape))
            loss_plus = net.loss(X_data, labels)
            setattr(net, name, minus.reshape(parameter.shape))
            loss_minus = net.loss(X_data, labels)
            grad_flat[index] = (loss_plus - loss_minus) / (2 * epsilon)
        setattr(net, name, parameter)  # 恢复原参数
        grads[name] = grad_flat.reshape(parameter.shape)
    return grads


net_check = MLP(input_dim=2, hidden_dim=3, seed=1)
numeric = numerical_gradient(net_check, X_xor, t_xor)
net_check.forward(X_xor)  # backward 需要前向缓存的中间量
analytic = net_check.backward(X_xor, t_xor)

print("参数        max|解析-数值|     相对误差")
for name in ["W1", "b1", "W2", "b2"]:
    difference = np.abs(analytic[name] - numeric[name]).max()
    scale = max(np.abs(numeric[name]).max(), 1e-12)
    print(f"{name:6s}  {difference:12.3e}  {difference / scale:10.3e}")
参数        max|解析-数值|     相对误差
W1         5.984e-11   3.270e-09
b1         4.251e-11   1.828e-09
W2         4.945e-11   9.438e-10
b2         1.198e-11   1.633e-10

4. 从感知机到 MLP:XOR 问题

第四章的感知机只能画一条直线,而 XOR 的四个点需要两条。第四章末尾靠手工设计的二次基函数做到了,现在换成 MLP:第一层从数据中学出两条"分界线"(隐藏单元),第二层把它们组合起来。

下面训练一个 2-2-1 网络(2 个输入、2 个隐藏单元、1 个输出)。训练曲线会展示一个常见现象:损失先快速下降,随后变慢——这是 sigmoid 型输出在接近 0/1 时梯度变小的表现。

# 训练 2-2-1 网络解 XOR
net_xor = MLP(input_dim=2, hidden_dim=2, seed=7)
xor_losses = net_xor.train(X_xor, t_xor, learning_rate=1.0, iterations=2500)
_, y_xor = net_xor.forward(X_xor)
xor_pred = (y_xor.ravel() >= 0.5).astype(int)
print("predictions:", xor_pred, "  labels:   ", t_xor.astype(int))
print(f"XOR accuracy: {np.mean(xor_pred == t_xor):.3f}")

# 关键可视化:隐藏层把 XOR 四点映射到 (z1, z2) 空间,在那里变成线性可分的
z_xor, _ = net_xor.forward(X_xor)
fig, axes = plt.subplots(1, 3, figsize=(15, 4.3))
axes[0].plot(xor_losses)
axes[0].set(xlabel="iteration", ylabel="BCE", title="Training loss")
axes[1].scatter(X_xor[:, 0], X_xor[:, 1], c=t_xor, cmap="RdBu_r", edgecolor="white", s=150)
axes[1].set(title="Original input space", xlabel="$x_1$", ylabel="$x_2$")
axes[2].scatter(z_xor[:, 0], z_xor[:, 1], c=t_xor, cmap="RdBu_r", edgecolor="white", s=150)
axes[2].set(title="Hidden space (z1, z2): now separable", xlabel="$z_1$", ylabel="$z_2$")
plt.tight_layout()
plt.show()
predictions: [0 1 1 0]   labels:    [0 1 1 0]
XOR accuracy: 1.000
notebook output

5. MLP 在弯曲数据上:moons 对比

逻辑回归在 moons 上只能画一条直线;MLP 用隐藏层学出弯曲的边界。我们把隐藏单元的"响应模式"也画出来——每个隐藏单元就像第四章手工构造的一个基函数,只不过方向、位置和形状都是从数据中学出来的。

# 训练 6 个隐藏单元的 MLP
net_moons = MLP(input_dim=2, hidden_dim=6, seed=3)
moons_losses = net_moons.train(X_moons_train, t_moons_train, learning_rate=0.8, iterations=4000)
_, y_moons = net_moons.forward(X_moons_test)
moons_accuracy = np.mean((y_moons.ravel() >= 0.5) == t_moons_test)
print(f"MLP moons test accuracy: {moons_accuracy:.3f}")

fig, axes = plt.subplots(1, 2, figsize=(12, 4.3))
plot_boundary(axes[0], X_moons_train, t_moons_train,
              lambda grid: net_moons.forward(grid)[1].ravel(), "MLP decision boundary")
axes[1].plot(moons_losses)
axes[1].set(xlabel="iteration", ylabel="BCE", title="Training loss")
plt.tight_layout()
plt.show()

# 每个隐藏单元学到的"基函数"
grid_axis = np.linspace(-2.5, 2.5, 200)
xx, yy = np.meshgrid(grid_axis, grid_axis)
grid_points = np.c_[xx.ravel(), yy.ravel()]
z_grid, _ = net_moons.forward(grid_points)
hidden_count = net_moons.W1.shape[1]
fig_hidden, axes_hidden = plt.subplots(2, 3, figsize=(12, 7))
for index, ax in enumerate(axes_hidden.ravel()):
    if index >= hidden_count:
        ax.axis("off")
        continue
    response = z_grid[:, index].reshape(xx.shape)
    ax.contourf(xx, yy, response, levels=21, cmap="RdBu_r")
    ax.scatter(X_moons_train[:, 0], X_moons_train[:, 1], c=t_moons_train,
               cmap="RdBu_r", edgecolor="white", s=20, alpha=0.6)
    ax.set(title=f"hidden unit {index}", xlabel="$x_1$", ylabel="$x_2$")
plt.suptitle("Learned hidden units = learned basis functions", y=1.02)
plt.tight_layout()
plt.show()
MLP moons test accuracy: 0.978
notebook output
notebook output

6. 训练细节:激活函数、学习率与初始化

反向传播公式里的 $h'(a_j)$ 揭示了三个工程细节的来源:

激活函数。$\tanh$ 的导数 $1-z^2$ 在 $|z|\to 1$ 时趋近零,单元饱和后梯度消失;ReLU $h(a)=\max(0,a)$ 在正区间导数恒为 1,能缓解饱和,但负区间导数为 0,会"杀死"单元。PRML 写于 ReLU 流行之前,正文主要讨论 tanh,这里用实验对比两者。

初始化。如果权重初始值过大,tanh 单元一开始就饱和,梯度消失,训练几乎不动;用 $1/\sqrt{D_{\text{in}}}$ 量级初始化能让激活初始落在线性区附近。

学习率。太大导致损失发散或震荡,太小收敛极慢。对同一个网络,学习率是第一个要调的超参数。

# 实验一:tanh vs ReLU(相同种子、相同学习率)
fig, axes = plt.subplots(1, 3, figsize=(15, 4.2))

for activation in ["tanh", "relu"]:
    net_act = MLP(2, 8, activation=activation, seed=5)
    losses_act = net_act.train(X_moons_train, t_moons_train, learning_rate=0.3, iterations=4000)
    axes[0].plot(losses_act, label=activation)
axes[0].set(xlabel="iteration", ylabel="BCE", title="Activation comparison (moons)")
axes[0].legend()

# 实验二:学习率敏感性(XOR)
for learning_rate in [0.05, 0.5, 5.0]:
    net_lr = MLP(2, 4, seed=0)
    losses_lr = net_lr.train(X_xor, t_xor, learning_rate=learning_rate, iterations=3000)
    axes[1].plot(losses_lr, label=f"lr={learning_rate}")
axes[1].set(xlabel="iteration", ylabel="BCE", title="Learning rate sensitivity (XOR)")
axes[1].legend()

# 实验三:初始化尺度(moons):scale=5 时 tanh 一开始就饱和
for scale in [1.0, 5.0]:
    net_init = MLP(2, 4, seed=0, scale=scale)
    losses_init = net_init.train(X_moons_train, t_moons_train, learning_rate=0.5, iterations=3000)
    axes[2].plot(losses_init, label=f"init scale={scale}")
axes[2].set(xlabel="iteration", ylabel="BCE", title="Initialization sensitivity (moons)")
axes[2].legend()

plt.tight_layout()
plt.show()
notebook output

7. 正则化(PRML §5.5)

7.1 权重衰减 = 高斯先验(§5.5.1)

第三章的结论在这里原样成立:对权重加零均值高斯先验,等价于在误差函数上加

$$ \tilde E(\mathbf{w})=E(\mathbf{w})+\frac{\lambda}{2}\lVert\mathbf{w}\rVert^2. $$

正则化系数 $\lambda$ 对应先验精度。梯度下降的更新变成

$$ \mathbf{w}\leftarrow\mathbf{w}-\eta\left(\nabla E+\lambda\mathbf{w}\right) =(1-\eta\lambda)\mathbf{w}-\eta\nabla E, $$

所以它叫权重衰减:每一步先把权重按 $(1-\eta\lambda)$ 向零收缩,再沿负梯度移动。偏置通常不参与惩罚(它们不改变特征的缩放行为,见 PRML 关于一致高斯先验的讨论)。

下面构造一个过拟合场景来验证效果:随机翻转 40% 的训练标签制造噪声,用 64 个隐藏单元的大网络去拟合。没有正则化时,网络会记住这些噪声标签;权重衰减应该阻止这种记忆。

class RegularizedMLP(MLP):
    """在 MLP 基础上加权重衰减(只惩罚 W1、W2,不惩罚偏置)。"""

    def __init__(self, *args, weight_decay=0.0, **kwargs):
        super().__init__(*args, **kwargs)
        self.weight_decay = weight_decay

    def weight_norm_sq(self):
        return float(np.sum(self.W1**2) + np.sum(self.W2**2))

    def train(self, X_data, labels, learning_rate=1.0, iterations=2000):
        losses = []
        for _ in range(iterations):
            self.forward(X_data)
            grads = self.backward(X_data, labels)
            for name, gradient in grads.items():
                update = gradient
                if name in ("W1", "W2"):
                    update = gradient + self.weight_decay * getattr(self, name)
                setattr(self, name, getattr(self, name) - learning_rate * update)
            losses.append(self.loss(X_data, labels) + 0.5 * self.weight_decay * self.weight_norm_sq())
        return np.array(losses)


# 构造过拟合场景:随机翻转 40% 训练标签(标签噪声),大网络会倾向于"背下"噪声
X_tr_wd, X_te_wd, t_tr_wd, t_te_wd = train_test_split(
    X_moons, t_moons, test_size=0.3, random_state=42, stratify=t_moons
)
rng_noise = np.random.default_rng(7)
t_noisy = t_tr_wd.copy()
flip_mask = rng_noise.random(len(t_tr_wd)) < 0.4
t_noisy[flip_mask] = 1 - t_noisy[flip_mask]
print(f"flipped {flip_mask.sum()}/{len(t_tr_wd)} training labels, hidden units: 64")
print("λ        测试准确率    ||w||")
for weight_decay in [0.0, 0.01, 0.03]:
    net_wd = RegularizedMLP(2, 64, activation="tanh", seed=0, weight_decay=weight_decay)
    net_wd.train(X_tr_wd, t_noisy, learning_rate=0.3, iterations=8000)
    test_prob = net_wd.forward(X_te_wd)[1].ravel()
    accuracy = np.mean((test_prob >= 0.5) == t_te_wd)
    print(f"λ={weight_decay:<5}  {accuracy:.3f}        {np.sqrt(net_wd.weight_norm_sq()):.3f}")
flipped 80/210 training labels, hidden units: 64
λ        测试准确率    ||w||
λ=0.0    0.722        15.126
λ=0.01   0.822        0.925
λ=0.03   0.778        0.692
# 早停:验证损失不再改善时停止,并回滚到最佳权重
def train_with_early_stopping(net, X_train, t_train, X_val, t_val,
                              learning_rate=0.5, iterations=6000, patience=300):
    train_losses, val_losses = [], []
    best_val, best_weights, wait = float("inf"), None, 0
    for _ in range(iterations):
        net.forward(X_train)
        grads = net.backward(X_train, t_train)
        for name, gradient in grads.items():
            setattr(net, name, getattr(net, name) - learning_rate * gradient)
        train_losses.append(net.loss(X_train, t_train))
        val_loss = net.loss(X_val, t_val)
        val_losses.append(val_loss)
        if val_loss < best_val - 1e-5:
            best_val, wait = val_loss, 0
            best_weights = {name: getattr(net, name).copy() for name in ("W1", "b1", "W2", "b2")}
        else:
            wait += 1
            if wait >= patience:
                break
    stop_iteration = len(val_losses)
    for name, values in best_weights.items():
        setattr(net, name, values)
    return np.array(train_losses), np.array(val_losses), stop_iteration


# 从训练集再切出验证集,用大网络制造过拟合
X_train2, X_val, t_train2, t_val = train_test_split(
    X_moons_train, t_moons_train, test_size=0.4, random_state=0, stratify=t_moons_train
)
net_es = MLP(2, 32, activation="tanh", seed=11)
train_losses, val_losses, stop_iteration = train_with_early_stopping(
    net_es, X_train2, t_train2, X_val, t_val, learning_rate=0.5
)

_, y_es = net_es.forward(X_moons_test)
es_accuracy = np.mean((y_es.ravel() >= 0.5) == t_moons_test)
print(f"early stopping at iteration {stop_iteration}, test accuracy: {es_accuracy:.3f}")

fig, ax = plt.subplots(figsize=(7, 4.2))
ax.plot(train_losses, label="train loss")
ax.plot(val_losses, label="validation loss")
ax.axvline(stop_iteration, color="black", linestyle="--", label="early stop")
ax.set(xlabel="iteration", ylabel="BCE", title="Early stopping: validation loss bottoms out")
ax.legend()
plt.show()
early stopping at iteration 5170, test accuracy: 0.989
notebook output

8. 混合密度网络与贝叶斯神经网络(§5.6-5.7 简述)

混合密度网络(§5.6)。普通回归网络输出一个条件均值 $y(\mathbf{x},\mathbf{w})$,隐含假设 $p(t\mid\mathbf{x})$ 是单峰高斯。对逆问题(如机器人运动学、超分辨率),同一个输入可能对应多个合理输出,条件分布是多峰的。MDN 让网络输出混合高斯的参数——混合系数、均值、方差——并用负对数似然训练:

$$ p(t\mid\mathbf{x})=\sum_{k=1}^{K}\pi_k(\mathbf{x})\,\mathcal N\bigl(t\mid\mu_k(\mathbf{x}),\sigma_k^2(\mathbf{x})\bigr). $$

贝叶斯神经网络(§5.7)。点估计 $\mathbf{w}_{\text{MAP}}$ 忽略了参数不确定性。贝叶斯方法对后验 $p(\mathbf{w}\mid\mathcal D)$ 积分:

$$ p(t\mid\mathbf{x},\mathcal D)=\int p(t\mid\mathbf{x},\mathbf{w})\,p(\mathbf{w}\mid\mathcal D)\,\mathrm d\mathbf{w}. $$

第四章的拉普拉斯近似在神经网络上变得困难:权重极多、后验高度多峰、Hessian 维度过高。第五章提出的做法包括 MAP 附近的拉普拉斯近似,以及利用与高斯过程的联系(§5.7.4,呼应第六章)。现代的做法——变分推断、MC dropout、deep ensembles——都围绕同一个目标:让模型知道自己不知道什么。

9. 本章总结

PRML 小节核心结论动手实现
§5.1前馈网络 = 可学习的基函数 + 线性模型前向传播、隐藏单元可视化
§5.1.1-5.1.2权重空间对称性 $M!2^M$;万能近似置换隐藏单元验证映射不变
§5.2目标函数来自分布假设;梯度下降训练交叉熵 vs 平方误差的梯度对比
§5.3反向传播:$\partial E/\partial w_{ji}=\delta_j z_i$,误差递归回传手写 forward/backward、数值梯度验证
§5.3.3一次前向 + 一次反向得到全部梯度与 $O(W)$ 次前向的数值梯度对比
§5.5权重衰减 = 高斯先验;早停控制有效容量λ 扫描、验证损失早停
§5.6-5.7多峰条件分布用 MDN;参数不确定性用贝叶斯积分概念对比

贯穿第五章的三个核心认知:

  1. 反向传播就是链式法则。 $\delta_j z_i$ 一句话就能概括;数值梯度验证让你确信十几行代码就是对的。
  2. 第四章的结论全部保留。 损失来自分布假设、L2 正则等于高斯先验——只是 $y(\mathbf{x},\mathbf{w})$ 变成了多层非线性函数。
  3. 表示是学出来的。 隐藏单元可视化让你看到网络自己学会了"基函数",这正是固定基函数方法做不到的。

下一章进入第六章核方法:核技巧让你在不显式构造高维特征的情况下做非线性分类。你会发现核方法(显式构造特征空间)与神经网络(隐式学习特征空间)是同一个问题的两种答案。

说些什么吧!