多隐藏层全连接神经网络:参考答案

目录

\[ \begin{align}\begin{aligned}\newcommand{\ba}{\boldsymbol{a}} \newcommand{\bb}{\boldsymbol{b}} \newcommand{\be}{\boldsymbol{e}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bw}{\boldsymbol{w}} \newcommand{\bx}{\boldsymbol{x}} \newcommand{\by}{\boldsymbol{y}} \newcommand{\bz}{\boldsymbol{z}} \newcommand{\bd}{\boldsymbol{d}} \newcommand{\bv}{\boldsymbol{v}} \newcommand{\bs}{\boldsymbol{s}}\\\newcommand{\btheta}{\boldsymbol{\theta}} \newcommand{\bbeta}{\boldsymbol{\beta}} \newcommand{\bgamma}{\boldsymbol{\gamma}} \newcommand{\bsigma}{\boldsymbol{\sigma}} \newcommand{\md}{\mbox{d}} \newcommand{\bmu}{\boldsymbol{\mu}} \newcommand{\bone}{\boldsymbol{1}} \newcommand{\bzero}{\boldsymbol{0}} \newcommand{\bepsilon}{\boldsymbol{\epsilon}} \newcommand{\bphi}{\boldsymbol{\phi}} \newcommand{\bh}{\boldsymbol{h}} \newcommand{\bc}{\boldsymbol{c}} \newcommand{\br}{\boldsymbol{r}} \newcommand{\bQ}{\boldsymbol{Q}} \newcommand{\bK}{\boldsymbol{K}} \newcommand{\bV}{\boldsymbol{V}} \newcommand{\bSigma}{\boldsymbol{\Sigma}} \newcommand{\bg}{\boldsymbol{g}} \newcommand{\bxi}{\boldsymbol{\xi}} \newcommand{\bvarepsilon}{\boldsymbol{\varepsilon}} \newcommand{\bdelta}{\boldsymbol{\delta}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bJ}{\boldsymbol{J}} \newcommand{\bp}{\boldsymbol{p}} \newcommand{\bi}{\boldsymbol{i}} \newcommand{\bo}{\boldsymbol{o}} \newcommand{\bE}{\boldsymbol{E}} \newcommand{\bH}{\boldsymbol{H}} \newcommand{\bL}{\boldsymbol{L}} \newcommand{\bu}{\boldsymbol{u}} \newcommand{\bLambda}{\boldsymbol{\Lambda}} \newcommand{\trans}{^{\rm\scriptsize T}} \newcommand{\var}{\mathrm{var}}\\\newcommand{\bA}{\boldsymbol{A}} \newcommand{\bB}{\boldsymbol{B}} \newcommand{\bC}{\boldsymbol{C}} \newcommand{\bD}{\boldsymbol{D}} \newcommand{\bG}{\boldsymbol{G}} \newcommand{\bI}{\boldsymbol{I}} \newcommand{\bM}{\boldsymbol{M}} \newcommand{\bP}{\boldsymbol{P}} \newcommand{\bS}{\boldsymbol{S}} \newcommand{\bU}{\boldsymbol{U}} \newcommand{\bW}{\boldsymbol{W}} \newcommand{\bX}{\boldsymbol{X}} \newcommand{\bY}{\boldsymbol{Y}} \newcommand{\bZ}{\boldsymbol{Z}} \newcommand{\cotp}{\textcolor[RGB]{48,209,88}{TP}} \newcommand{\cotn}{\textcolor[RGB]{100,210,255}{TN}} \newcommand{\cofp}{\textcolor[RGB]{94,92,230}{FP}} \newcommand{\cofn}{\textcolor[RGB]{191,90,242}{FN}}\\\newcommand{\numcotp}{\textcolor[RGB]{48,209,88}{50}} \newcommand{\numcotn}{\textcolor[RGB]{100,210,255}{30}} \newcommand{\numcofp}{\textcolor[RGB]{94,92,230}{10}} \newcommand{\numcofn}{\textcolor[RGB]{191,90,242}{10}} \DeclareMathOperator*{\argmin}{arg\,min}\end{aligned}\end{align} \]

多隐藏层全连接神经网络:参考答案#

返回正文练习 · 返回答案索引

说明#

以下答案与正文 6 道题逐题对应。推导与程序统一采用“样本按行”约定;比较网络结构时,应使用相近的参数量、相同的训练量和相同的随机种子,每次只改变一个需要研究的结构设置。

  1. \(\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\)⁠。

  2. 两个仿射层的复合满足

    \[\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]},\]

    可以得到与原来完全相同的总权重和总偏置。因此,即使预测函数可以化为一个仿射映射,也可能有许多组深层参数表示同一个函数。

  3. 沿用正文中式 (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.\]

    每增加一层,梯度都要再经过一个权重矩阵,并与一个逐元素激活函数导数相乘。权重矩阵改变梯度的方向与尺度,激活函数导数按对应位置缩放梯度;两者反复结合可能使梯度变小或变大,但不能只依据深度判断必然结果。

  4. 按照正文“样本按行存放”的写法,将题目给出的列向量转置为 \(\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)
    

    逐层复核比只比较最终损失更容易定位缓存、转置和循环边界错误。

  5. 下面的程序实现任意多个 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_prevZ 分别用于计算权重梯度和激活函数导数;额外保存的 W 用于继续向前一层传播梯度。所有输入都先复制为 float64 数组,因此这些函数不会原地修改调用者传入的样本或参数。

  6. 参数量为

    \[P=\sum_{l=1}^L\left(d^{[l]}d^{[l-1]}+d^{[l]}\right).\]

    以二维输入和一个二分类输出为例,可以构造下面两种参数量完全相同的网络:

    • 深而窄网络 2-8-8-8-8-1 的参数量为 \(24+3\times72+9=249\)⁠;

    • 浅而宽网络 2-62-1 的参数量为 \(186+63=249\)⁠。

    更一般地,若深而窄网络的参数量为 \(P_{\mathrm{deep}}\)⁠、输入维度为 \(d^{[0]}\)⁠,则单隐藏层网络的隐藏神经元个数可以先取

    \[H=\operatorname{round}\!\left( \frac{P_{\mathrm{deep}}-1}{d^{[0]}+2} \right),\]

    再比较相邻整数对应的参数量,选择最接近 \(P_{\mathrm{deep}}\) 的结构。

    实验时,两种网络应使用完全相同的数据划分和预处理方法、隐藏层激活函数、输出激活函数、损失函数、初始化规则、优化器及学习率设置,还应使用相同的随机种子集合、配对的批量抽取顺序、批量大小、迭代次数和提前停止规则。若确实需要选择学习率,则两种网络必须使用相同的候选值和验证集选择规则,并报告最终取值。对每个随机种子分别训练后,报告训练集与验证集准确率、对数损失的均值和波动。

    训练时间应从同一训练阶段开始和结束;峰值内存也应在相同范围内测量。测量固定批量预测时间时,应统一硬件、数值类型和批量大小,把模型设为预测状态,先运行若干次以完成必要的准备,再重复计时并采用相同的汇总方式。

    深而窄网络具有更长的计算路径,浅而宽网络则在单层中进行规模更大的矩阵运算。因此,实验结果应同时列出参数量、预测性能、训练时间、固定批量预测时间和峰值内存;哪一种网络更好取决于数据与计算环境,不能仅根据深度、宽度或参数量预先判断。