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

用 NumPy 实现全连接神经网络#

学习目标与记号#

  1. 准确解释参数初始化、模块化前向传播、缓存与后向传播;

  2. 根据公式和张量维度分析训练循环,并识别常见实现错误;

  3. Python 实现或验证决策边界;在其他条件相同的情况下,只改变一个需要研究的因素进行比较,并根据实验结果分析该因素可能带来的影响。

  本节沿用统一记号:普通小写字母表示标量,粗体小写字母表示向量,粗体大写字母表示矩阵或高阶张量;层编号写作上标 \([l]\)⁠,样本或时间编号写作下标;转置写作 \(\trans\)⁠。除非另有说明,批量样本按行存放。正文与练习中的程序都应同时检查数值结果和数组维度。本节练习的参考答案见 用 NumPy 从零实现 FNN 答案⁠。

  代码分别对应 批量前向传播⁠、批量后向传播逻辑回归⁠;遇到数组维度变化时,可回看 广播机制⁠。本节用 NumPy 从零实现一个单隐藏层二分类网络。示例沿用“样本按行存放”的约定:\(\bX=[\bx_1\trans;\ldots;\bx_n\trans]\in\mathbb{R}^{n\times d}\)⁠,第 \(i\) 行是 \(\bx_i\trans\)⁠。手动实现的目的,是把前向传播、后向传播以及参数维度逐一对应起来;实际项目通常应使用自动微分框架。

加载程序包#

  我们首先加载两个本节需要的程序包。该代码没有数据输入和可见输出;执行成功后,np 用于随机数和矩阵运算,plt 用于绘制训练样本与决策边界。

import numpy as np
import matplotlib.pyplot as plt # for plots # 重要的画图程序包。

  导入只建立模块别名,不会自动创建或训练模型。运行环境必须已经安装 NumPy 和 Matplotlib;绘图能否显示还取决于当前使用的图形后端。

生成数据集#

  我们考虑如下模型:

(54)#\[\begin{split}\begin{eqnarray} y_i\mid\bx_i &\sim \operatorname{Bernoulli}\!\left(\pi(\bx_i)\right),\\ \pi(\bx_i)&=\frac{\lVert\bx_i\rVert_2}{2}, \end{eqnarray}\end{split}\]

其中,\(r_i\sim\operatorname{Uniform}(0,2)\)⁠、\(\theta_i\sim\operatorname{Uniform}(0,2\pi)\)⁠,并令 \(\bx_i=(r_i\cos\theta_i,r_i\sin\theta_i)\trans\)⁠。因此 \(\lVert\bx_i\rVert_2=r_i\)⁠,条件概率只取决于样本到原点的欧氏范数,而不是输入特征的线性组合。逻辑回归的线性决策边界无法准确表达这种径向结构。

  接下来根据式 (54) 编写数据生成函数。输入 n 为样本量、rn 为随机种子;输出 x 的维度为 \(n\times2\)⁠,两列是平面坐标,y 的维度为 \(n\times1\)⁠,元素为 0 或 1。

def train_data_generation_nn(n, rn):
    # n: 样本量
    # rn: 随机种子

    np.random.seed(rn)  # 设置随机种子为 rn
    r = np.random.uniform(0, 2, (n, 1))  # 生成半径 r
    theta2 = np.random.uniform(0, 2 * np.pi, (n, 1))  # 生成极角 theta2
    x = np.concatenate((r * np.cos(theta2), r * np.sin(theta2)), axis=1)
    y = np.random.binomial(1, r / 2, (n, 1))  # 生成标签 y

    return x, y

  函数先独立生成半径和角度,再转为笛卡尔坐标,并以 r/2 为成功概率抽取伯努利标签。固定 rn 有助于复现实例,但函数会重置 NumPy 的全局随机状态;生成的是带标签噪声的随机样本,单个标签不一定等于概率较大的类别。

  接下来调用该函数生成 \(1\,000\) 个样本,并以 x 的两列为横纵坐标、y 为颜色绘制散点图;预期由颜色观察到类别概率随半径变化的径向结构。

x, y = train_data_generation_nn(1000, 100)

fig, ax = plt.subplots()
scatter = ax.scatter(x[:,0], x[:,1],  c=y[:,0])
legend1 = ax.legend(*scatter.legend_elements(),
                    loc="lower right", title="类别")
ax.set_title('模拟生成的训练样本')
plt.rcParams["font.sans-serif"] = ['SimSong']  # 设置中文字体
plt.show()
../_images/Code_for_FNN_2_1.png

  x[:,0]x[:,1] 分别提供两个坐标,y[:,0] 决定点的颜色;图例说明颜色对应的类别。根据图像难以找到能良好划分样本的直线,这与径向生成机制一致;但散点图只是探索性证据,不能单独量化模型的样本外性能,中文字体是否可用也因系统而异。

分步编写模型训练细节#

  我们将根据 单一隐藏层神经网络-一般形式 中所介绍的过程,编写一个只有一个隐藏层的全连接神经网络。具体地讲,我们将分步完成如下过程:

  1. 初始化模型参数⁠。权重采用随机初始化,偏置初始化为 0。

  2. 前向传播⁠。基于当前模型参数进行前向传播,并缓存损失函数以及每层激活后的结果,即 \(\bA^{[1]}\) 以及 \(\bA^{[2]}\)⁠。

  3. 后向传播⁠。计算并缓存损失函数对模型参数的梯度。

  4. 参数更新⁠。利用梯度下降方法对模型参数进行更新。

  权重矩阵的每一行对应一个神经元的权重向量。若同一层的权重全部初始化为相同值,神经元会得到相同梯度,无法打破对称性;因此权重必须随机初始化。这里使用适合 sigmoid 函数与 tanh 函数的 Xavier 型尺度,使初始激活不易进入饱和区。

  下面的初始化函数输入特征数 d隐藏单元数 na 和随机种子 rn输出参数字典。其形状分别为 W1: (na,d)b1: (na,1)W2: (1,na)b2: (1,1)

def Initialize_pars_nn(d,na,rn):
    # d: 输入特征向量的维度
    # na: 隐藏层神经元个数
    # rn: 随机种子

    np.random.seed(rn)  # 设置随机种子为 rn
    W1 = np.random.normal(0, np.sqrt(1/d), (na,d))  # Xavier 型初始化
    b1 = np.zeros((na, 1))  # 初始化 b1 为 0
    W2 = np.random.normal(0, np.sqrt(1/na), (1,na))
    b2 = np.zeros((1, 1))  # 初始化 b2 为 0

    par = {
        'W1': W1,
        'b1': b1,
        'W2': W2,
        'b2': b2,
          }

    return par

  两层权重按输入宽度缩放随机初始化,偏置从零开始;字典让后续函数按名称读取参数。固定种子会让初始化可复现,但函数同样修改全局随机状态;这里只适合单隐藏层、单输出的二分类网络,且没有检查 dna 是否为正整数。

  第二步,基于当前模型参数进行前向传播。函数输入 \(n\times2\) 特征 x\(n\times1\) 标签 y 和参数字典,输出缓存字典:标量平均损失 J大小为 \(n\times na\) 的隐藏激活 A1以及大小为 \(n\times1\) 的概率 A2 和线性输出 Z2隐藏层使用 sigmoid;二元交叉熵通过 np.logaddexp 根据 Z2 稳定计算。

def forward_nn(x, y, par):
    # x: 特征矩阵,维度为 (n, 2)
    # y: 标签列向量,维度为 (n, 1)
    # par: 由当前参数值组成的字典.

    W1 = par["W1"]  #从字典 par 中获得 W1
    b1 = par["b1"]  #从字典 par 中获得 b1
    W2 = par["W2"]  #从字典 par 中获得 W2
    b2 = par["b2"]  #从字典 par 中获得 b2

    Z1 = np.dot(x, W1.transpose()) + b1.transpose()  # 计算 Z1
    A1 = sigmoid(Z1)  # 计算 A1
    Z2 = np.dot(A1, W2.transpose()) + b2  # 计算 Z2
    A2 = sigmoid(Z2)  # 计算 A2
    J = np.mean(np.logaddexp(0.0, Z2) - y * Z2)

    cache = {  # 缓存损失和后向传播所需中间量
        'J': J,
        'A1': A1,
        'A2': A2,
        'Z2': Z2,
    }
    return cache

  np.dot(x, W1.T) 同时计算所有隐藏单元,偏置通过广播加到每个样本;输出层再把隐藏表示映射为一个概率。缓存保留后向传播所需的激活值。该实现依赖稍后定义的 sigmoid按整批数据取平均,并假定标签只含 0、1 且参数维度完全匹配。

  接下来编写后向传播函数。它接收与前向传播相同的 xypar 及前一步的 cache输出梯度字典;dW1db1dW2db2 的形状分别与对应参数完全相同。

def backprop_nn(x, y, par, cache):
    # x: 特征矩阵,维度为 (n, 2)
    # y: 标签列向量,维度为 (n, 1)
    # par: 由当前参数值组成的字典.
    # cache: 缓存了 J, A1 以及 A2 的字典

    n = x.shape[0]  # 获取样本量 n
    A1 = cache['A1']  #从字典 cache 中获得 A1
    A2 = cache['A2']  #从字典 cache 中获得 A2

    dZ2 = (A2 - y) / n  # 计算 dZ2
    dW2 = np.dot(dZ2.transpose(), A1)  # 计算 dW2
    db2 = np.sum(dZ2, axis=0, keepdims=True)
    dZ1 = np.dot(dZ2, par['W2']) * (A1 * (1 - A1))  # 计算 dZ1
    dW1 = np.dot(dZ1.transpose(), x)  # 计算 dW1
    db1 = np.sum(dZ1, axis=0, keepdims=True).transpose()  # 计算 db1

    grad = {  #缓存 dW1, db1, dW2, db2
        'dW1': dW1,
        'db1': db1,
        'dW2': dW2,
        'db2': db2,
    }
    return grad

  dZ2=(A2-y)/n 把平均交叉熵对输出线性值的导数一次算出,随后按链式法则反向经过第二层、sigmoid 导数和第一层。这里通过矩阵乘法汇总所有样本的梯度,偏置梯度则沿样本轴求和。函数只适用于当前 sigmoid 隐藏层与二元交叉熵组合;手写梯度还应使用数值梯度或自动微分逐项核验。

  接下来利用梯度下降更新参数。输入当前参数字典、同形梯度字典和标量学习率 alpha输出更新后的参数字典;每个参数都减去“学习率乘对应梯度”。

def update_par_nn(par, grad, alpha):
    # par: 由当前参数值组成的字典
    # grad: 包含导数结果的字典
    # alpha: 学习率

    par['W1'] -= alpha * grad['dW1']  # 更新 W1
    par['b1'] -= alpha * grad['db1']  # 更新 b1
    par['W2'] -= alpha * grad['dW2']  # 更新 W2
    par['b2'] -= alpha * grad['db2']  # 更新 b2

    return par

  -= 会原地修改传入的数组,因此调用前后的 par 指向同一组已更新参数,而不是保留旧参数副本。该函数没有检查梯度是否有限,也没有动量、自适应步长或正则化;学习率过大时损失可能震荡或发散。

整合#

  下面把初始化、前向传播、后向传播和更新整合成完整训练函数。输入训练数据、隐藏宽度 na学习率 alpha迭代数 M 和初始化种子 rn训练期间每 \(2\,000\) 次打印一次当前损失,最终返回参数字典。

def est_par_nn(x, y, na, alpha, M, rn):
    # x: 特征矩阵,维度为 (n, 2)
    # y: 标签列向量,维度为 (n, 1)
    # na: 隐藏层神经元个数
    # alpha: 学习率
    # M: 梯度下降算法中最多迭代次数
    # rn: 初始化所用到的随机种子

    d = x.shape[1]  # 获取输入特征维度
    par = Initialize_pars_nn(d, na, rn)  # 初始化模型参数

    for i in range(M):
        cache = forward_nn(x, y, par)  # 前向传播
        grad = backprop_nn(x, y, par, cache)  # 后向传播
        par = update_par_nn(par, grad, alpha)  #参数更新
        if i % 2000 == 1999:
            print("After %4d iterations, the cost is %10.8f" % (i+1, cache['J']))  #监控损失函数

    return par

  每轮都用同一整批训练数据计算梯度并更新一次参数,因此这是全批量梯度下降。打印的 cache['J'] 是本轮更新前参数对应的训练损失;函数没有早停、验证集选择、学习率调度或最终损失返回值,M 很大时计算成本会线性增加。

模型拟合及分类估计#

  首先定义数值稳定的 sigmoid:输入可以是标量或任意形状数组,输出保持相同形状且元素位于 0 与 1 之间。随后生成 \(1\,000\) 个样本,以 4 个隐藏单元、学习率 0.01 训练 \(10\,000\) 次,预期得到参数字典并定期打印训练损失。

def sigmoid(x):
    # x: input

    x = np.asarray(x)
    return np.exp(-np.logaddexp(0.0, -x))

x, y = train_data_generation_nn(1000, 100)
par = est_par_nn(x, y, 4, 0.01, 10000, 1234)
After 2000 iterations, the cost is 0.69367263
After 4000 iterations, the cost is 0.69134580
After 6000 iterations, the cost is 0.68963953
After 8000 iterations, the cost is 0.68778689
After 10000 iterations, the cost is 0.68535907

  logaddexp 避免直接计算大正数的指数,因而比朴素的 1/(1+exp(-x)) 更稳定;训练结果 par 覆盖初始化参数并供后续预测使用。即便损失下降,这里也只观察训练集,不能据此判断泛化,而且 \(10\,000\) 次固定步长更新不保证对所有数据和宽度都合适。

  根据训练后的参数,下面的预测函数输入 \(n_{\mathrm{test}}\times2\)x_test 和参数字典,返回 \(n_{\mathrm{test}}\times1\) 的类别 1 概率,即输出层激活 \(\bA^{[2]}\)⁠。

def prediction_nn(x_test, par):
    # x_test: 维度为 (n_test, 2) 的测试集
    # par: 训练好的模型参数字典

    W1 = par["W1"]  #从字典 par 中获得 W1
    b1 = par["b1"]  #从字典 par 中获得 b1
    W2 = par["W2"]  #从字典 par 中获得 W2
    b2 = par["b2"]  #从字典 par 中获得 b2

    Z1 = np.dot(x_test, W1.transpose()) + b1.transpose()  # 计算 Z1
    A1 = sigmoid(Z1)  # 计算 A1
    Z2 = np.dot(A1, W2.transpose()) + b2  # 计算 Z2
    A2 = sigmoid(Z2)  # 计算 A2

    return A2

  预测过程复用了训练时的两层线性映射和 sigmoid,但不需要标签,也不计算损失或梯度。返回的是概率而非 0/1 类别;函数不验证输入维度、缺失值或分布是否与训练数据一致。

  为可视化决策边界,下面在两个坐标各取 200 个值,组成 \(200\times200\) 网格并展平为 \(40\,000\times2\)x_test预测概率按 0.5 转为类别后再恢复网格形状,预期背景色显示模型的二维分类区域,散点覆盖原训练样本。

x1_margin = np.linspace(-2.5,2.5,200)
x2_margin = np.linspace(-2.5,2.5,200)
x1_grid, x2_grid = np.meshgrid(x1_margin,x2_margin)
x_test = np.c_[x1_grid.ravel(), x2_grid.ravel()]
y_pred = prediction_nn(x_test, par)
y_pred[y_pred>=0.5]=1
y_pred[y_pred<0.5]=0

y_cont = y_pred.reshape(x1_grid.shape)


plt.contourf(x1_grid, x2_grid, y_cont, cmap=plt.cm.Spectral)
scatter = plt.scatter(x[:,0], x[:,1], c = y[:,0], cmap=plt.cm.Spectral,s=0.5)
plt.legend(*scatter.legend_elements()) # add legend
plt.show()
../_images/Code_for_FNN_10_0.png

  meshgrid 构造评估坐标,np.c_ 将两列坐标拼成模型输入,reshape 则把 \(40\,000\) 个类别还原为绘图网格。代码直接在 y_pred 上覆盖概率,因此之后无法用它分析概率校准;边界图也只覆盖 \([-2.5,2.5]^2\)⁠,且训练散点上的视觉效果不能代替独立测试指标。

  四个隐藏单元时,模型对径向边界的逼近能力仍然有限。下面重新生成同一训练集,把隐藏单元增加到 10 个,并复用前面建立的 x_test 网格;预期输出新的训练损失记录和决策边界图,以便在其余设置相同时观察容量变化。

x, y = train_data_generation_nn(1000, 100)
par = est_par_nn(x, y, 10, 0.01, 10000, 1234)

y_pred = prediction_nn(x_test, par)
y_pred[y_pred>=0.5]=1
y_pred[y_pred<0.5]=0

y_cont = y_pred.reshape(x1_grid.shape)


plt.contourf(x1_grid, x2_grid, y_cont, cmap=plt.cm.Spectral)
scatter = plt.scatter(x[:,0], x[:,1], c = y[:,0], cmap=plt.cm.Spectral,s=0.5)
plt.legend(*scatter.legend_elements()) # add legend
plt.show()
After 2000 iterations, the cost is 0.69285347
After 4000 iterations, the cost is 0.69176909
After 6000 iterations, the cost is 0.69118817
After 8000 iterations, the cost is 0.69053537
After 10000 iterations, the cost is 0.68965801
../_images/Code_for_FNN_11_5.png

  代码只把隐藏宽度从 4 改为 10,随后按相同阈值把网格概率转成类别并重画边界。增加隐藏单元后,边界通常更贴近数据的径向结构,但参数量与计算量也随之增加;单次训练的损失还会受随机初始化和学习率影响,不能仅凭训练损失判断泛化性能,严谨比较应在独立验证集上进行并重复不同随机种子。

与逻辑回归模型的比较#

  接下来把同样的训练结构改写为逻辑回归。这个代码块定义初始化、前向传播、后向传播、参数更新和完整训练五个函数;输入仍为 \(n\times2\)x\(n\times1\)y最终输出只含 w\(2\times1\)⁠)和 b\(1\times1\)⁠)的参数字典,并每 \(2\,000\) 次打印一次稳定计算的交叉熵损失。

def Initialize_pars(d,rn):
    # d: 特征向量的维度
    # rn: 随机种子

    np.random.seed(rn)  # 设置随机种子为 rn
    w = np.zeros((d,1))  # 凸问题可从零向量开始
    b = np.zeros((1, 1))  # 初始化维度为 (1, 1) 的 b

    par = {
        'w': w,
        'b': b
          }

    return par

def forward(x, y, par):
    # x: 特征向量构成的矩阵。在本例中,其规模为 n X 2
    # y: 由标签组成的列向量。在本例中,其规模为 nX1
    # par: 由当前参数值组成的字典.

    n = x.shape[0]  # 获取样本量 n
    Z = np.dot(x, par['w']) + par['b']  # 计算 Z
    A = sigmoid(Z)  # 计算 A
    J = np.mean(np.logaddexp(0.0, Z) - y * Z)

    cache = {  #缓存 J 和 A
        'J': J,
        'A': A
    }
    return cache

def backprop(x, y, cache):
    # x: 特征向量构成的矩阵。在本例中,其规模为 n X 2
    # y: 由标签组成的列向量。在本例中,其规模为 nX1
    # cache: 缓存了 J 以及 A 的字典

    n = x.shape[0]  # 获取样本量 n
    err = (cache['A'] - y)/n  # 这是损失对输出层线性运算结果的梯度 dZ
    dw = np.dot(x.transpose(), err)  # 计算 dw
    db = np.sum(err, axis=0, keepdims=True)  # 计算 db

    grad = {  #缓存导数 dw 和 db
        'dw': dw,
        'db': db
    }
    return grad

def update_par(par, grad, alpha):
    # par: 由当前参数值组成的字典
    # grad: 包含导数结果的字典
    # alpha: 学习率

    par['w'] -= alpha * grad['dw']  # 更新 w
    par['b'] -= alpha * grad['db']  # 更新 b

    return par

def est_par_logistic(x, y, alpha, M, rn):
    # x: 特征向量构成的矩阵。在本例中,其规模为 n X 2
    # y: 由标签组成的列向量。在本例中,其规模为 nX1
    # alpha: 学习率
    # M: 梯度下降算法中最多迭代次数
    # rn: 初始化所用到的随机种子

    d = x.shape[1]  #得到特征向量的维度
    par = Initialize_pars(d, rn)  # 初始化模型参数

    for i in range(M):
        cache = forward(x, y, par)  # 前向传播
        grad = backprop(x, y, cache)  # 后向传播
        par = update_par(par, grad, alpha)  #参数更新

        if i % 2000 == 1999:
            print("After %4d iterations, the cost is %10.8f" % (i+1, cache['J']))

    return par

  初始化从零开始;forward 计算线性输出、概率和平均损失,backprop 汇总 dwdbupdate_par 原地更新参数,est_par_logistic 将这些步骤循环 M 次。逻辑回归的目标在本例中是凸的,但固定学习率仍可能过大或收敛缓慢;这些函数没有输入验证、早停、正则化和独立验证集。

  基于当前变量 xy 中的 \(1\,000\) 个训练样本,下面以学习率 0.005 运行 \(10\,000\) 次更新;预期定期打印损失,并把最终 wb 保存到 par覆盖之前的神经网络参数字典。

par = est_par_logistic(x, y, 0.005, 10000, 1234)
After 2000 iterations, the cost is 0.69251359
After 4000 iterations, the cost is 0.69250745
After 6000 iterations, the cost is 0.69250733
After 8000 iterations, the cost is 0.69250732
After 10000 iterations, the cost is 0.69250732

  返回的 par 只能交给逻辑回归预测公式使用,因为它不再包含 W1 等神经网络键。训练日志可用于观察优化趋势,却不能说明直线边界是否适合径向数据,也不能替代验证集和测试集评价。

  逻辑回归只能产生直线决策边界,因而即使优化已经收敛,也无法表达真实的同心径向结构。下面输入训练后的 par\(200\times200\) 网格上直接计算概率并以 0.5 阈值分类;预期背景呈直线分割,再叠加训练样本用于对照。

x1_margin = np.linspace(-2.5,2.5,200)
x2_margin = np.linspace(-2.5,2.5,200)
x1_grid, x2_grid = np.meshgrid(x1_margin,x2_margin)
y_grid = sigmoid(par['b'] + par['w'][0] * x1_grid + par['w'][1]*x2_grid)
y_grid[y_grid>=0.5] = 1
y_grid[y_grid<0.5] = 0

plt.contourf(x1_grid, x2_grid, y_grid, cmap=plt.cm.Spectral)
scatter = plt.scatter(x[:,0], x[:,1], c = y[:,0], cmap=plt.cm.Spectral,s=1)
plt.legend(*scatter.legend_elements()) # add legend
plt.show()
../_images/Code_for_FNN_14_0.png

  b+w_1x_1+w_2x_2=0 对应概率 0.5,因此颜色变化的边界必为直线。图中可见该边界无法有效表达径向结构;不过这仍是训练数据上的定性展示,公平比较还应让两个模型使用相同数据划分、评价指标和重复实验,并报告样本外结果与计算成本。

核心推导与实现核验#

核心关系

\[\btheta^{(t+1)}=\btheta^{(t)}-\alpha\cdot\nabla\mathcal{J}(\btheta^{(t)}).\]

  推导路径。 一次迭代必须先用同一组旧参数完成前向传播,并通过后向传播计算出全部梯度,再同步更新参数;若边计算梯度边更新参数,后续梯度对应的是混合参数点,不再是同一目标函数在同一参数取值处的梯度。

关键条件

  把每层线性映射和激活写成纯函数后,可用数学归纳法证明按层组合的程序与递归前向公式一致;后向模块逐层返回同形梯度。

数据规模

  若有 \(n\) 个样本、每个样本有 \(d\) 个特征,隐藏层有 \(h\) 个神经元,则第一层权重大小为 \(h\times d\)⁠,隐藏层批量输出大小为 \(n\times h\)⁠,二分类输出大小为 \(n\times1\)⁠。程序应在每个模块的输入和输出位置检查这些大小。

常见误区

  用测试集反复选择隐藏宽度会造成泄漏;二元交叉熵应与代码一致地根据线性运算结果计算,不能把裁剪概率当作稳定公式的替代品。

数值稳定性与规模

  二分类损失根据输出层的线性运算结果计算,初始化按 fan-in 缩放;每层记录激活和梯度范数。出现梯度爆炸时先检查公式与学习率,再考虑全局范数裁剪。

本节小结#

  1. 从零实现的价值在于一一对应公式与数组。

  2. 参数更新必须在全部梯度计算完成后同步执行。

  3. 断言、数值梯度检验 和独立验证集共同保证实现可信。

综合练习#

  程序题应固定随机种子、写出维度断言并报告运行环境;比较题还应固定数据划分、随机种子集合和训练预算。全部参考答案见 用 NumPy 从零实现 FNN 答案⁠。

  1. 维度与参数量。 单隐藏层二分类网络的输入维度为 \(d=2\)⁠,隐藏层含 \(h=3\) 个神经元,批量大小为 \(n=5\)⁠,输出层含一个神经元。按正文“样本按行存放”的约定,写出 \(\bX,\bW^{[1]},\bb^{[1]},\bZ^{[1]},\bA^{[1]},\bW^{[2]},\bb^{[2]},\bZ^{[2]}\) 的维度,并计算总参数量。

  2. 前向具体计算。 对单个样本 \(\bx=(1,1)\trans\)⁠,取 \(\bW^{[1]}=\bI_2\)⁠、\(\bb^{[1]}=\boldsymbol0\)⁠、\(\bW^{[2]}=(1,-1)\)⁠、\(b^{[2]}=0\)⁠,两层均按正文使用 sigmoid 函数。计算隐藏激活、输出层线性运算结果、预测概率,以及标签 \(y=1\) 时的二元交叉熵。

  3. 批量后向传播。 从二元交叉熵和 sigmoid 函数的组合出发,推导 \(\mathrm d\bZ^{[2]}=(\bA^{[2]}-\by)/n\)⁠,再推导 \(\mathrm d\bW^{[2]},\mathrm d\bb^{[2]},\mathrm d\bZ^{[1]},\mathrm d\bW^{[1]},\mathrm d\bb^{[1]}\) 的向量化公式并核对维度。

  4. 线性边界的限制。 逻辑回归以 \(\bw\trans\cdot\bx+b=0\) 为决策边界。证明该边界是直线,不能精确表示正文中只依赖 \(\lVert\bx\rVert_2\) 的圆形等概率集合;说明含非线性隐藏层的 FNN 为什么可以逼近这种径向关系。

  5. 逻辑回归与 FNN 比较。 分别生成线性可分二分类数据和正文的径向二分类数据,在两套数据上比较逻辑回归与单隐藏层 FNN。固定训练/验证/测试划分、随机种子集合、训练预算和阈值选择规则;报告测试精度或交叉熵、训练时间、固定批量与单样本预测时间、参数量及峰值内存。

  6. 隐藏层宽度比较。 在径向数据上比较隐藏神经元数 \(2,4,8,32\)⁠。固定数据划分、随机种子集合、初始化规则、学习率选择预算和以参数更新次数表示的训练预算;报告任务性能、训练时间、固定批量与单样本预测时间、参数量及峰值内存,并讨论欠拟合与过拟合。

  7. 学习率比较。 对相同隐藏宽度比较至少四个按数量级变化的学习率。固定数据划分、随机种子集合、初始参数、批量顺序和训练预算;报告任务性能、收敛或发散情况、训练时间、固定批量与单样本预测时间、参数量及峰值内存。