以下答案与正文 6 道题逐题对应。推导与程序统一采用“样本按行”约定;比较网络结构时,应使用相近的参数量、相同的训练量和相同的随机种子,每次只改变一个需要研究的结构设置。
记 \(\bA^{[0]}=\bX\in\mathbb R^{n\times d^{[0]}}\)。对 \(l=1,\ldots,L\),
\[\bZ^{[l]}=\bA^{[l-1]}\cdot(\bW^{[l]})\trans
+\bone\cdot(\bb^{[l]})\trans,
\qquad
\bA^{[l]}=\sigma^{[l]}(\bZ^{[l]}),\]
其中,\(\bone\in\mathbb R^{n\times 1}\) 是元素均为 1 的列向量,\(\bW^{[l]}\in\mathbb R^{d^{[l]}\times d^{[l-1]}}\),\(\bb^{[l]}\in\mathbb R^{d^{[l]}\times 1}\),而 \(\bZ^{[l]}\)、\(\bA^{[l]}\in\mathbb R^{n\times d^{[l]}}\)。后向传播为
\[\begin{split}\begin{aligned}
\mathrm d\bZ^{[l]}&=\mathrm d\bA^{[l]}\odot
{\sigma^{[l]}}'(\bZ^{[l]}),\\
\mathrm d\bW^{[l]}&=(\mathrm d\bZ^{[l]})\trans\cdot\bA^{[l-1]},
&\mathrm d\bb^{[l]}&=(\mathrm d\bZ^{[l]})\trans\cdot\bone,\\
\mathrm d\bA^{[l-1]}&=\mathrm d\bZ^{[l]}\cdot\bW^{[l]}.
\end{aligned},\end{split}\]
相应梯度的维度为
\[\begin{split}\begin{aligned}
\mathrm d\bA^{[l]},\mathrm d\bZ^{[l]}
&\in\mathbb R^{n\times d^{[l]}},\\
\mathrm d\bW^{[l]}
&\in\mathbb R^{d^{[l]}\times d^{[l-1]}},\\
\mathrm d\bb^{[l]}
&\in\mathbb R^{d^{[l]}\times 1},\\
\mathrm d\bA^{[l-1]}
&\in\mathbb R^{n\times d^{[l-1]}}.
\end{aligned}\end{split}\]
\(\mathrm d\bA^{[l-1]}\) 是继续计算第 \(l-1\) 层梯度所需的量,所以必须先得到第 \(l\) 层的结果;这决定了后向循环顺序为 \(L,L-1,\ldots,1\)。
两个仿射层的复合满足
\[\bW^{[2]}\cdot(\bW^{[1]}\cdot\bx+\bb^{[1]})+\bb^{[2]}
=(\bW^{[2]}\cdot\bW^{[1]})\cdot\bx
+(\bW^{[2]}\cdot\bb^{[1]}+\bb^{[2]}).\]
因而总权重为 \(\bW^{[2]}\cdot\bW^{[1]}\),总偏置为 \(\bW^{[2]}\cdot\bb^{[1]}+\bb^{[2]}\)。假设前 \(k\) 层已写成 \(\widetilde{\bW}_k\cdot\bx+\widetilde{\bb}_k\),再复合第 \(k+1\) 层仍具有同一形式,因此由数学归纳法可推广到任意 \(L\)。
参数化不是唯一的。例如,若 \(\bS\) 是维度合适的可逆矩阵,则令
\[\bW^{[1]\prime}=\bS\cdot\bW^{[1]},\qquad
\bb^{[1]\prime}=\bS\cdot\bb^{[1]},\qquad
\bW^{[2]\prime}=\bW^{[2]}\cdot\bS^{-1},\qquad
\bb^{[2]\prime}=\bb^{[2]},\]
可以得到与原来完全相同的总权重和总偏置。因此,即使预测函数可以化为一个仿射映射,也可能有许多组深层参数表示同一个函数。
沿用正文中式 (36) 的记号,\(\mathrm d\bZ^{[l]}\) 表示损失函数对第 \(l\) 层线性运算结果的梯度。按照样本按行存放的写法,链式法则给出
\[\mathrm d\bZ^{[l]}
=\left(\mathrm d\bZ^{[l+1]}\cdot\bW^{[l+1]}\right)
\odot{\sigma^{[l]}}'(\bZ^{[l]}).\]
上式适用于隐藏层 \(l=L-1,\ldots,1\)。输出层的 \(\mathrm d\bZ^{[L]}\) 应根据损失函数与输出激活函数的具体组合确定。例如,对 sigmoid 输出与样本平均二元交叉熵的组合,有 \(\mathrm d\bZ^{[L]}=(\bA^{[L]}-\by)/n\)。对应的参数梯度为
\[\mathrm d\bW^{[l]}
=(\mathrm d\bZ^{[l]})\trans\cdot\bA^{[l-1]},
\qquad
\mathrm d\bb^{[l]}
=(\mathrm d\bZ^{[l]})\trans\cdot\bone.\]
每增加一层,梯度都要再经过一个权重矩阵,并与一个逐元素激活函数导数相乘。权重矩阵改变梯度的方向与尺度,激活函数导数按对应位置缩放梯度;两者反复结合可能使梯度变小或变大,但不能只依据深度判断必然结果。
按照正文“样本按行存放”的写法,将题目给出的列向量转置为 \(\bX=\bx\trans=(1,-1)\)。前向传播结果为
\[\bZ^{[1]}=(1,-1),\quad \bA^{[1]}=(1,0),
\quad \bZ^{[2]}=(1,-1),\quad \bA^{[2]}=(1,0),\]
且 \(Z^{[3]}=2\)、\(A^{[3]}=0.880797\)、\(\mathcal J=0.126928\)。从输出层开始逐层计算,有
\[\begin{split}\begin{aligned}
\mathrm d Z^{[3]}&=-0.119203,\\
\mathrm d\bA^{[2]}&=(-0.238406,0.119203),\\
\mathrm d\bZ^{[2]}&=(-0.238406,0),\\
\mathrm d\bA^{[1]}&=(-0.238406,-0.476812),\\
\mathrm d\bZ^{[1]}&=(-0.238406,0).
\end{aligned}\end{split}\]
三层梯度分别为
\[\begin{split}\begin{aligned}
\mathrm d\bW^{[3]}&=(-0.119203,0),
&\mathrm d b^{[3]}&=-0.119203,\\
\mathrm d\bW^{[2]}&=
\begin{pmatrix}-0.238406&0\\0&0\end{pmatrix},
&\mathrm d\bb^{[2]}&=(-0.238406,0)\trans,\\
\mathrm d\bW^{[1]}&=
\begin{pmatrix}-0.238406&0.238406\\0&0\end{pmatrix},
&\mathrm d\bb^{[1]}&=(-0.238406,0)\trans.
\end{aligned}\end{split}\]
定义第 5 题中的函数后,可以用下面的程序逐层复核前向结果、后向传播中的中间梯度和参数梯度:
X = np.array([[1.0, -1.0]])
y = np.array([1.0])
params = [
(np.eye(2), np.zeros(2)),
(np.array([[1.0, 2.0], [-1.0, 1.0]]), np.zeros(2)),
(np.array([[2.0, -1.0]]), np.zeros(1)),
]
loss, p, cache, grads = loss_and_backward(X, y, params)
np.testing.assert_allclose(cache[0]["Z"], [[1.0, -1.0]])
np.testing.assert_allclose(cache[0]["A"], [[1.0, 0.0]])
np.testing.assert_allclose(cache[1]["Z"], [[1.0, -1.0]])
np.testing.assert_allclose(cache[1]["A"], [[1.0, 0.0]])
np.testing.assert_allclose(cache[2]["Z"], [[2.0]])
np.testing.assert_allclose(loss, 0.1269280110, rtol=1e-8)
dZ3 = p - y.reshape(-1, 1)
dA2 = dZ3 @ params[2][0]
dZ2 = dA2 * (cache[1]["Z"] > 0.0)
dA1 = dZ2 @ params[1][0]
dZ1 = dA1 * (cache[0]["Z"] > 0.0)
np.testing.assert_allclose(dZ3, [[-0.1192029220]])
np.testing.assert_allclose(dA2, [[-0.2384058440, 0.1192029220]])
np.testing.assert_allclose(dZ2, [[-0.2384058440, 0.0]])
np.testing.assert_allclose(dA1, [[-0.2384058440, -0.4768116881]])
np.testing.assert_allclose(dZ1, [[-0.2384058440, 0.0]])
expected_grads = [
(np.array([[-0.2384058440, 0.2384058440], [0.0, 0.0]]),
np.array([-0.2384058440, 0.0])),
(np.array([[-0.2384058440, 0.0], [0.0, 0.0]]),
np.array([-0.2384058440, 0.0])),
(np.array([[-0.1192029220, 0.0]]),
np.array([-0.1192029220])),
]
for (dW, db), (expected_dW, expected_db) in zip(grads, expected_grads):
np.testing.assert_allclose(dW, expected_dW)
np.testing.assert_allclose(db, expected_db)
逐层复核比只比较最终损失更容易定位缓存、转置和循环边界错误。
下面的程序实现任意多个 ReLU 隐藏层和一个 sigmoid 二分类输出层。程序把偏置保存为一维数组,并在每一层明确检查输入、参数、缓存和梯度的维度:
import numpy as np
def as_finite_float_array(value, name, ndim):
"""返回独立的 float64 数组,并检查维度与有限性。"""
try:
raw_array = np.asarray(value)
except (TypeError, ValueError) as error:
raise TypeError(f"{name} 必须能够转换为数值数组") from error
if np.iscomplexobj(raw_array):
raise TypeError(f"{name} 不能包含复数")
try:
array = np.asarray(raw_array, dtype=np.float64)
except (TypeError, ValueError) as error:
raise TypeError(f"{name} 必须能够转换为数值数组") from error
if array.ndim != ndim:
raise ValueError(f"{name} 必须是 {ndim} 维数组")
if any(size == 0 for size in array.shape):
raise ValueError(f"{name} 不能为空")
if not np.all(np.isfinite(array)):
raise ValueError(f"{name} 只能包含有限数值")
return array.copy()
def sigmoid(z):
return np.exp(-np.logaddexp(0.0, -z))
def forward(X, params):
A = as_finite_float_array(X, "X", ndim=2)
if not isinstance(params, (list, tuple)) or len(params) == 0:
raise ValueError("params 必须是非空的层参数列表")
n = A.shape[0]
cache = []
for l, layer_params in enumerate(params):
if not isinstance(layer_params, (list, tuple)) or len(layer_params) != 2:
raise ValueError(f"第 {l + 1} 层参数必须写成 (W, b)")
W = as_finite_float_array(layer_params[0], f"W[{l + 1}]", ndim=2)
b = as_finite_float_array(layer_params[1], f"b[{l + 1}]", ndim=1)
if W.shape[0] != b.shape[0] or W.shape[1] != A.shape[1]:
raise ValueError(
f"第 {l + 1} 层应满足 "
"W.shape == (b.shape[0], A_prev.shape[1])"
)
Z = A @ W.T + b.reshape(1, -1)
expected_shape = (n, W.shape[0])
if Z.shape != expected_shape or not np.all(np.isfinite(Z)):
raise ValueError(f"第 {l + 1} 层线性运算结果无效")
next_A = sigmoid(Z) if l == len(params) - 1 else np.maximum(Z, 0.0)
if next_A.shape != expected_shape or not np.all(np.isfinite(next_A)):
raise ValueError(f"第 {l + 1} 层激活结果无效")
cache.append({
"A_prev": A,
"Z": Z,
"A": next_A,
"W": W,
"b": b,
})
A = next_A
if A.shape != (n, 1):
raise ValueError("二分类输出层必须只包含一个神经元")
return A, cache
def prepare_binary_labels(y, n):
try:
raw_y = np.asarray(y)
except (TypeError, ValueError) as error:
raise TypeError("y 必须能够转换为数值数组") from error
if np.iscomplexobj(raw_y):
raise TypeError("y 不能包含复数")
try:
y = np.asarray(raw_y, dtype=np.float64)
except (TypeError, ValueError) as error:
raise TypeError("y 必须能够转换为数值数组") from error
if y.size == 0 or not np.all(np.isfinite(y)):
raise ValueError("y 必须是非空的有限数值数组")
y = y.copy()
if y.ndim == 1:
if y.shape[0] != n:
raise ValueError("y 的样本数必须与 X 相同")
y = y.reshape(-1, 1)
elif y.ndim == 2 and y.shape == (n, 1):
pass
else:
raise ValueError("y 的维度必须是 (n,) 或 (n, 1)")
if not np.all((y == 0.0) | (y == 1.0)):
raise ValueError("二分类标签只能取 0 或 1")
return y
def backward(y, cache):
if not isinstance(cache, list) or len(cache) == 0:
raise ValueError("cache 必须由 forward 返回且不能为空")
n = cache[0]["A_prev"].shape[0]
y = prepare_binary_labels(y, n)
p = cache[-1]["A"]
z_last = cache[-1]["Z"]
if p.shape != (n, 1) or z_last.shape != (n, 1):
raise ValueError("输出层缓存的维度不正确")
loss_terms = np.logaddexp(0.0, z_last) - y * z_last
loss = float(np.mean(loss_terms))
if not np.isfinite(loss):
raise ValueError("损失函数值不是有限数")
dZ = (p - y) / n
grads = [None] * len(cache)
for l in range(len(cache) - 1, -1, -1):
W = cache[l]["W"]
b = cache[l]["b"]
A_prev = cache[l]["A_prev"]
expected_z_shape = (n, W.shape[0])
if (
cache[l]["Z"].shape != expected_z_shape
or cache[l]["A"].shape != expected_z_shape
or A_prev.shape != (n, W.shape[1])
or dZ.shape != expected_z_shape
):
raise ValueError(f"第 {l + 1} 层缓存或梯度的维度不正确")
dW = dZ.T @ A_prev
db = dZ.sum(axis=0)
if dW.shape != W.shape or db.shape != b.shape:
raise ValueError(f"第 {l + 1} 层参数梯度的维度不正确")
if not np.all(np.isfinite(dW)) or not np.all(np.isfinite(db)):
raise ValueError(f"第 {l + 1} 层参数梯度不是有限数")
grads[l] = (dW, db)
if l > 0:
dA_prev = dZ @ W
if dA_prev.shape != A_prev.shape or not np.all(np.isfinite(dA_prev)):
raise ValueError(f"第 {l} 个隐藏层的激活梯度无效")
dZ = dA_prev * (cache[l - 1]["Z"] > 0)
return loss, grads
def loss_and_backward(X, y, params):
p, cache = forward(X, params)
loss, grads = backward(y, cache)
return loss, p, cache, grads
prepare_binary_labels 只接受维度为 (n,) 或 (n, 1) 的二分类标签,并检查标签数是否等于样本数,从而避免 NumPy 在标签维度错误时自动广播。缓存中的 A_prev 与 Z 分别用于计算权重梯度和激活函数导数;额外保存的 W 用于继续向前一层传播梯度。所有输入都先复制为 float64 数组,因此这些函数不会原地修改调用者传入的样本或参数。
参数量为
\[P=\sum_{l=1}^L\left(d^{[l]}d^{[l-1]}+d^{[l]}\right).\]
以二维输入和一个二分类输出为例,可以构造下面两种参数量完全相同的网络:
更一般地,若深而窄网络的参数量为 \(P_{\mathrm{deep}}\)、输入维度为 \(d^{[0]}\),则单隐藏层网络的隐藏神经元个数可以先取
\[H=\operatorname{round}\!\left(
\frac{P_{\mathrm{deep}}-1}{d^{[0]}+2}
\right),\]
再比较相邻整数对应的参数量,选择最接近 \(P_{\mathrm{deep}}\) 的结构。
实验时,两种网络应使用完全相同的数据划分和预处理方法、隐藏层激活函数、输出激活函数、损失函数、初始化规则、优化器及学习率设置,还应使用相同的随机种子集合、配对的批量抽取顺序、批量大小、迭代次数和提前停止规则。若确实需要选择学习率,则两种网络必须使用相同的候选值和验证集选择规则,并报告最终取值。对每个随机种子分别训练后,报告训练集与验证集准确率、对数损失的均值和波动。
训练时间应从同一训练阶段开始和结束;峰值内存也应在相同范围内测量。测量固定批量预测时间时,应统一硬件、数值类型和批量大小,把模型设为预测状态,先运行若干次以完成必要的准备,再重复计时并采用相同的汇总方式。
深而窄网络具有更长的计算路径,浅而宽网络则在单层中进行规模更大的矩阵运算。因此,实验结果应同时列出参数量、预测性能、训练时间、固定批量预测时间和峰值内存;哪一种网络更好取决于数据与计算环境,不能仅根据深度、宽度或参数量预先判断。