激活函数:参考答案#
说明#
以下答案与正文 7 道题逐题对应。涉及程序运行时间和模型性能的题目没有与硬件、软件版本和数据集无关的唯一数值答案,因此参考答案重点给出计算方法、实验步骤、需要报告的结果以及解释结果时应注意的问题。
对 sigmoid 函数求导可得
\[\begin{split}\begin{aligned} \sigma'(z) &=\frac{\exp(-z)}{[1+\exp(-z)]^2}\\ &=\frac{1}{1+\exp(-z)} \left[1-\frac{1}{1+\exp(-z)}\right]\\ &=\sigma(z)[1-\sigma(z)]. \end{aligned}\end{split}\]将双曲正切函数写成 \(\tanh(z)=[\exp(z)-\exp(-z)]/[\exp(z)+\exp(-z)]\),利用商法则可得
\[\begin{split}\begin{aligned} \frac{\mathrm d}{\mathrm dz}\tanh(z) &=\frac{[\exp(z)+\exp(-z)]^2-[\exp(z)-\exp(-z)]^2} {[\exp(z)+\exp(-z)]^2}\\ &=1-\tanh^2(z). \end{aligned}\end{split}\]ReLU 和 Leaky ReLU 在各自分段的内部都是线性函数,因此
\[\begin{split}\begin{aligned} \operatorname{ReLU}'(z) &= \begin{cases} 0,&z<0,\\ 1,&z>0, \end{cases}\\ \operatorname{LeakyReLU}_{\alpha}'(z) &= \begin{cases} \alpha,&z<0,\\ 1,&z>0. \end{cases} \end{aligned}\end{split}\]在通常使用的 \(0<\alpha<1\) 条件下,四类函数的值域及其在可导点上的导数取值为
\[\begin{split}\begin{array}{c|c|c} \text{激活函数}&\text{值域}&\text{可导点上的导数取值}\\ \hline \sigma&(0,1)&(0,1/4]\\ \tanh&(-1,1)&(0,1]\\ \operatorname{ReLU}&[0,\infty)&\{0,1\}\\ \operatorname{LeakyReLU}_{\alpha}&\mathbb R&\{\alpha,1\} \end{array}.\end{split}\]ReLU 在 \(z=0\) 处的左、右导数分别为 0 和 1,因此不可导;当 \(\alpha\ne1\) 时,Leaky ReLU 在该点的左、右导数分别为 \(\alpha\) 和 1,也不可导。程序必须明确选择一个后向传播值。例如,可以约定 ReLU 在 0 处取 0,Leaky ReLU 在 0 处取 \(\alpha\)。该数值是程序约定,不表示函数在这一点存在通常意义下的导数。
令 \(u=\sigma(z)\in(0,1)\),则
\[\sigma'(z)=u(1-u) =\frac14-\left(u-\frac12\right)^2 \leq\frac14.\]因为 \(0<u<1\),所以 \(\sigma'(z)>0\);仅当 \(u=1/2\),也就是 \(z=0\) 时,上式等号成立。令 \(v=\tanh(z)\in(-1,1)\),则
\[0<\tanh'(z)=1-v^2\leq1,\]且仅当 \(v=0\),也就是 \(z=0\) 时取到上界 1。当 \(|z|\to\infty\) 时,\(\sigma(z)\) 趋近于 0 或 1,\(\tanh(z)\) 趋近于 \(-1\) 或 1,所以
\[\lim_{|z|\to\infty}\sigma'(z)=0, \qquad \lim_{|z|\to\infty}\tanh'(z)=0.\]在多层网络中,后向传播需要依次经过各层的局部导数和权重矩阵。当许多 sigmoid 函数或 tanh 函数进入饱和区域时,多个接近 0 的局部导数可能使梯度逐层减小。这说明两类函数具有引起梯度衰减的局部机制,但不能据此断定整个网络一定发生梯度消失。网络深度、权重尺度、损失函数、归一化方法和残差连接等因素也会影响最终的梯度大小。
对第 \(j\) 个元素,有 \(a_j=g(z_j)\)。根据链式法则,
\[\delta_{z,j} =\frac{\partial\mathcal J}{\partial z_j} =\frac{\partial\mathcal J}{\partial a_j}g'(z_j) =\delta_{a,j}g'(z_j).\]将所有元素放在一起,便得到
\[\boldsymbol\delta_z =\boldsymbol\delta_a\odot g'(\bz).\]四类激活函数对局部梯度的影响可以分区域说明:
当 \(z\ll0\) 时,sigmoid 函数和 tanh 函数的导数接近 0;ReLU 的导数为 0;Leaky ReLU 的导数为 \(\alpha\)。因此,前三者会明显削弱或完全截断这一位置的上游梯度,Leaky ReLU 则保留 \(\alpha\) 倍的上游梯度。
当 \(z\approx0\) 时,sigmoid 函数的导数接近 \(1/4\),tanh 函数的导数接近 1。ReLU 和 Leaky ReLU 在 0 的两侧采用不同斜率,并且在 0 点本身需要使用事先说明的程序约定。
当 \(z\gg0\) 时,sigmoid 函数和 tanh 函数的导数再次接近 0;ReLU 和 Leaky ReLU 的导数均为 1,因而这两个激活函数不会在该位置缩小上游梯度。
ReLU 在正半轴上的导数为 1,只能说明梯度经过当前激活函数时没有被缩小。梯度还必须经过其他层的权重矩阵、激活函数和损失函数,因此整个网络仍可能出现梯度过小或过大的现象。
下面的实现使用正负两个分支计算 sigmoid 函数,避免对很大的正数机械计算指数;它还统一检查有限值,并保持标量、向量和矩阵输入的维度。
import numpy as np def activation(z, kind, alpha=0.01, derivative=False): x = np.asarray(z, dtype=np.float64) if not np.all(np.isfinite(x)): raise ValueError("输入必须全部为有限值") if kind == "sigmoid": flat = x.reshape(-1) value_flat = np.empty_like(flat) positive = flat >= 0.0 value_flat[positive] = 1.0 / ( 1.0 + np.exp(-flat[positive]) ) exp_z = np.exp(flat[~positive]) value_flat[~positive] = exp_z / (1.0 + exp_z) value = value_flat.reshape(x.shape) grad = value * (1.0 - value) elif kind == "tanh": value = np.tanh(x) grad = 1.0 - value * value elif kind == "relu": value = np.maximum(x, 0.0) # 约定 ReLU 在 0 处的后向传播值为 0 grad = (x > 0.0).astype(x.dtype) elif kind == "leaky_relu": if alpha <= 0.0: raise ValueError("alpha 必须为正数") value = np.where(x > 0.0, x, alpha * x) # 约定 Leaky ReLU 在 0 处的后向传播值为 alpha grad = np.where(x > 0.0, 1.0, alpha) else: raise ValueError(f"未知的激活函数:{kind}") result = grad if derivative else value if result.shape != x.shape: raise RuntimeError("输出维度与输入维度不一致") if not np.all(np.isfinite(result)): raise FloatingPointError("输出中出现了无穷大或无效数值") return result
可以使用以下代码检查不同输入维度以及 0 点约定:
inputs = [ np.array(2.0), np.array([-2.0, 0.0, 2.0]), np.array([[-2.0, 0.0], [1.0, 2.0]]), ] for x in inputs: for name in ("sigmoid", "tanh", "relu", "leaky_relu"): y = activation(x, name) dy = activation(x, name, derivative=True) assert y.shape == x.shape assert dy.shape == x.shape
由于这里使用 \(\exp(z)\) 处理负输入、使用 \(\exp(-z)\) 处理非负输入,指数函数的自变量始终不大于 0,从而避免了直接计算过大指数所造成的溢出。
对一个固定数组 \(\bX\),四类激活函数的前向计算量都与元素个数 \(N\) 成正比,前向加逐元素后向计算也具有相同的数量级。计时程序可以采用下面的结构:
from time import perf_counter import numpy as np def forward_backward(x, upstream, kind, alpha=0.01): y = activation(x, kind, alpha=alpha) if kind == "sigmoid": local_grad = y * (1.0 - y) elif kind == "tanh": local_grad = 1.0 - y * y elif kind == "relu": local_grad = (x > 0.0).astype(x.dtype) else: local_grad = np.where(x > 0.0, 1.0, alpha) return y, upstream * local_grad def median_time(function, warmup=10, repeats=50): for _ in range(warmup): function() times = [] for _ in range(repeats): start = perf_counter() function() times.append(perf_counter() - start) return float(np.median(times)) rng = np.random.default_rng(2026) x = rng.standard_normal((2048, 2048)).astype(np.float64) upstream = np.ones_like(x) for kind in ("sigmoid", "tanh", "relu", "leaky_relu"): forward_time = median_time( lambda: activation(x, kind) ) total_time = median_time( lambda: forward_backward(x, upstream, kind) ) print( kind, forward_time, total_time, )
应分别对标量、长向量、方阵和批量张量等多种维度运行测试,并对每种设置报告重复计时的中位数和波动范围。峰值内存应使用同一个内存测量工具,在每次正式测试前清理上一轮临时数组,并说明是否把输入、输出和上游梯度计入内存。四类函数必须使用数值完全相同的输入数组、上游梯度和数据类型。
ReLU 和 Leaky ReLU 主要使用比较与乘法,sigmoid 函数和 tanh 函数还需要指数函数或双曲函数,因此逐元素测试的运行时间可能不同。不过,完整神经网络还包含计算量更大的矩阵乘法,并受到内存传输、编译优化、计算设备和算子融合等因素影响。因此,单独测试激活函数得到的快慢顺序不能直接当作整个网络训练或预测速度的结论。
题目明确规定权重元素的 方差 为 \(c/\sqrt d\),因此应写成
\[W_{jk}^{[l]} \sim\mathcal N\left(0,\frac{c}{\sqrt{d_{l-1}}}\right),\]并在
NumPy中把相应的 标准差 \(\sqrt{c/\sqrt{d_{l-1}}}\) 传给随机数生成函数:import numpy as np def initialize_weight(base_normal, c, fan_in): variance = c / np.sqrt(fan_in) standard_deviation = np.sqrt(variance) return standard_deviation * base_normal rng = np.random.default_rng(2026) fan_in, fan_out = 100, 64 base_normal = rng.standard_normal((fan_out, fan_in)) weights = { c: initialize_weight(base_normal, c, fan_in) for c in (0.1, 1.0, 3.0, 10.0) }
对不同的 \(c\) 使用同一个
base_normal,可以使随机方向保持一致,只改变初始化尺度。需要特别注意,不能把 \(c/\sqrt d\) 直接作为NumPy随机正态分布函数的scale参数,因为该参数表示标准差而不是方差。为理解这种初始化的影响,暂时忽略偏置,并令 \(z_j=\sum_{k=1}^{d}W_{jk}a_k\)。在权重相互独立、均值为 0,并把输入 \(\ba\) 看作给定值时,
\[\operatorname{Var}(z_j\mid\ba) =\frac{c}{\sqrt d}\sum_{k=1}^{d}a_k^2.\]如果各输入分量的平均平方约为 1,则该方差约为
\[\operatorname{Var}(z_j)\approx c\sqrt d.\]因而,按照题目当前给定的方差,增大 \(c\) 通常会扩大线性运算结果的分布;在输入维度增大时,其方差也不会保持不变。这一点与“标准差为 \(c/\sqrt d\)”或“方差为 \(c/d\)”的常见初始化方式不同,实验和答案不能把三种写法混为一谈。
对每个激活函数、每个 \(c\) 和每个随机种子,可以按照以下步骤完成实验:
使用相同的数据划分、网络结构、批量顺序、优化器、学习率和更新次数;不同 \(c\) 之间只改变权重尺度。
在每次前向传播中记录各层 \(\bZ^{[l]}\) 的均值、标准差和分位数。饱和比例可预先定义为 \(|g'(Z_{ij}^{[l]})|<10^{-3}\) 的元素比例,并在全部实验中保持该标准不变。
在后向传播中记录各层权重梯度的范数,并检查是否出现无穷大或无效数值。
对各个固定种子报告验证损失、训练时间和峰值内存的均值及标准差。
一般来说,较大的 \(c\) 更容易使 sigmoid 函数和 tanh 函数进入两端的饱和区域,使局部导数和部分梯度变小;较小的 \(c\) 虽可减少初始饱和,却也可能使层间信号过弱。验证损失、训练时间和最终梯度大小仍会受到数据及优化过程影响,所以应依据实际结果比较,不能预先规定 \(c\) 的固定排名。网络结构与理论参数量在各组间相同,峰值内存通常接近,但仍应实际测量。
首先生成一次数据划分和一组初始权重,并让四个模型使用相同的训练集、验证集、测试集、网络深度与宽度、批量顺序、初始化方法、随机种子和最大更新次数。训练阶段只允许根据验证集选择各激活函数的学习率以及 Leaky ReLU 的 \(\alpha\);确定全部方案后,测试集只用于最终评价。
如果二分类模型使用平均二元交叉熵作为代价函数,则测试集代价函数值可以写成
\[\mathcal J_{\mathrm{test}} =-\frac{1}{m_{\mathrm{test}}} \sum_{i=1}^{m_{\mathrm{test}}} \left[ y_i\log\widehat p_i +(1-y_i)\log(1-\widehat p_i) \right].\]程序中应使用能够直接根据输出层线性运算结果计算二元交叉熵的稳定实现。如果训练目标还包含正则化项,报告结果时应明确 \(\mathcal J_{\mathrm{test}}\) 是否包含该项;比较预测表现时,通常应另外报告不含参数惩罚项的测试集平均二元交叉熵,避免把参数惩罚和预测误差混在一起。
每个模型至少应记录以下结果:
总训练时间,以及第一次达到预先指定验证损失所需的时间;未达到目标的模型标记为“未达到”,不能任意更换目标值。
对同一个固定测试批量预先运行并重复预测后得到的运行时间中位数和波动范围。
使用同一工具和同一统计范围测得的峰值内存。
训练结束后,根据预先确定的模型计算一次测试集代价函数值。
为降低偶然性,应使用多个预先确定的随机种子,并报告各项结果的均值、标准差或中位数及波动范围。ReLU 的逐元素计算通常较简单,Leaky ReLU 能在负半轴保留非零梯度,sigmoid 函数和 tanh 函数则可能在输入绝对值较大时饱和;这些性质只能用于解释观察到的结果,不能提前保证某种激活函数一定具有最高预测精度或最快训练速度。最终结论只适用于本题使用的数据、模型结构、初始化、训练预算、软件版本和硬件环境。