神经网络

此前已经使用 f=Wxf = Wx 构建了线性分类器,并通过正则化和梯度下降等技术提升了模型的泛化能力。接下来为了提高模型的表达能力,将组合之前的线性变换与非线性激活函数,构建更复杂的神经网络模型。

显然,如左图所示,一个线性分类器并不能很好的拟合分布复杂的数据,但是如果线性分类前将数据的分布转化成容易被线性分类器分类的形式(如右图所示),那么线性分类器也能取得不错的效果。神经网络的目标就是通过多层非线性变换,将数据映射到一个新的空间,使得在该空间中数据更容易被区分开来。

在原有基础上再加一层线性变换和非线性激活函数,得到:

f=W2max(0,W1x)f = W_2 \max(0, W_1 x)

其中 max(0,t)max(0, t) 作为激活函数,在两层网络中引入非线性(如果没有这个函数,那么两个矩阵运算可以合并成一个矩阵运算 f=(W2W1)x=Wxf = (W_2 W_1) x = W x,那么添加这一层就没有意义了)。

激活函数有很多常见的形式,如 Sigmoid、Tanh、ReLU 等。ReLU 函数在实践中表现良好,且计算简单,因此被广泛使用。ReLU 函数的导数在正区间为 1,在负区间为 0,这使得它在反向传播过程中计算效率较高。激活函数一般根据具体任务和数据分布选择,不同的激活函数可能会对模型的性能产生一定影响。

两层神经网络的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np
from numpy.random import randn

# N: batch大小;D_in: 输入维度;H: 隐藏层宽度;D_out: 输出维度
N, D_in, H, D_out = 64, 1000, 100, 10
x, y = randn(N, D_in), randn(N, D_out) # 这里用随机目标做回归式演示,不是分类标签
w1, w2 = randn(D_in, H), randn(H, D_out) # 两层权重矩阵

for t in range(2000):
# 前向传播计算损失
h = 1 / (1 + np.exp(-x.dot(w1))) # 隐藏层:x -> xW1 -> sigmoid
y_pred = h.dot(w2) # 输出层:h -> hW2
loss = np.square(y_pred - y).sum() # 平方误差损失;这里未除以N,只作演示
print(t, loss)

# 反向传播计算梯度
grad_y_pred = 2.0 * (y_pred - y) # d loss / d y_pred
grad_w2 = h.T.dot(grad_y_pred) # d loss / d w2,形状为 (H, D_out)
grad_h = grad_y_pred.dot(w2.T) # d loss / d h,形状为 (N, H)
grad_w1 = x.T.dot(grad_h * h * (1 - h)) # sigmoid'(h_pre)=h*(1-h)

# 更新权重
w1 -= 1e-4 * grad_w1 # 参数 -= 学习率 * 梯度
w2 -= 1e-4 * grad_w2

如图所示,随着神经网络的层数增加,模型的表达能力也随之增强,能够更好地拟合复杂的数据分布。然而,过深的网络也可能导致过拟合和梯度破碎等问题,因此在设计神经网络时需要合理选择层数和结构。

反向传播

反向传播是训练神经网络时最核心的计算机制:前向传播负责把输入变成 loss,反向传播负责把这个 loss 对每个参数的影响计算出来。它并不是一种新的求导规则,而是链式法则在计算图上的系统化应用。理解反向传播时可以先从标量计算图入手,再推广到矩阵运算和整层网络。

在无矩阵运算的情境下理解

梯度下降通过损失函数对参数的梯度来调整参数,从而最小化损失函数。反向传播则用来计算神经网络中损失函数对各层参数的梯度。反向传播的核心思想是利用链式法则,将复杂函数的导数分解为多个简单函数导数的乘积,从而高效地计算梯度。

用“计算图”来理解反向传播:前向传播时,每个节点只负责根据输入计算输出;反向传播时,每个节点接收来自后续节点的上游梯度,再乘上自己这个局部函数的导数,把梯度分发给自己的输入。这样整个网络不需要一次性写出一个巨大导数,只需要让每个小模块知道自己如何反传。

对于目前构建的神经网络,损失函数表达为:

L=1Ni=1NLi+λ(R(W1)+R(W2))L = \frac{1}{N} \sum_{i=1}^{N} L_i + \lambda (R(W_1) + R(W_2))

其中:

Li=jyimax(0,sjsyi+1)L_i = \sum_{j \neq y_i} \max(0, s_j - s_{y_i} + 1)

R(Wi)=m,n(Wi)m,n2R(W_i) = \sum_{m,n} (W_i)_{m,n}^2

如果直接计算损失函数对参数的梯度,计算量会非常大。反向传播通过将计算过程分解为多个步骤,逐层计算梯度,从而大大提高了计算效率。

以此图为例,f 从前一层结点接受 x 和 y 两个输入,给出输出 z。反向传播时,f 接收来自后一层的梯度 Lz\frac{\partial L}{\partial z},并根据链式法则计算出 Lx\frac{\partial L}{\partial x}Ly\frac{\partial L}{\partial y},然后将这两个梯度传递给前一层结点。通过这种方式,梯度信息从输出层逐层传递回输入层,实现了高效的梯度计算。

读这类图时可以把上游梯度理解为“最终损失对当前输出有多敏感”。当前节点要做的是回答:如果我的某个输入稍微变大一点,我的输出会怎样变?再把这两个敏感度相乘,就得到最终损失对该输入的敏感度。加法门会把梯度原样传给多个输入,乘法门会把另一个输入值作为局部导数,max 或 ReLU 这类门则会根据前向时的取值决定梯度是否通过。

这个图中的例子展示了一个向前和反向传播的过程。值得注意的是虽然其中的 Sigmoid 函数可以分步计算,但实际上可以将 Sigmoid 函数作为一个整体来计算其导数,来简化计算图的表示,计算图可以是不唯一的。整个计算图的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def sigmoid(x):
"""Sigmoid激活函数。"""
return 1.0 / (1.0 + np.exp(-x))

def f(w0, x0, w1, x1, w2):
# 向前传播
s0 = w0 * x0 # 乘法门:w0和x0都会影响s0
s1 = w1 * x1 # 乘法门:w1和x1都会影响s1
s2 = s0 + s1 # 加法门:梯度会原样分给两个输入
s3 = s2 + w2 # 再加上偏置/参数w2
L = sigmoid(s3) # 最终输出;这里把L视作标量损失/目标

# 反向传播
grad_L = 1.0 # dL/dL
grad_s3 = grad_L * L * (1 - L) # sigmoid'(s3)=L*(1-L)
grad_w2 = grad_s3 # s3 = s2 + w2,所以加法门梯度原样传递
grad_s2 = grad_s3
grad_s0 = grad_s2 # s2 = s0 + s1
grad_s1 = grad_s2
grad_w1 = grad_s1 * x1 # s1 = w1 * x1,对w1求导得到x1
grad_x1 = grad_s1 * w1 # 对x1求导得到w1
grad_w0 = grad_s0 * x0
grad_x0 = grad_s0 * w0

return L, (grad_w0, grad_x0, grad_w1, grad_x1, grad_w2)

一些其他示例:

推广到矩阵运算

将之前的简单运算变成矩阵运算:

在矩阵乘法中,对于输入的矩阵,损失函数对其梯度的矩阵大小与输入矩阵相同。矩阵中每个位置的值就代表损失函数对其对应输入位置标量的偏导数。下面是矩阵乘法的例子:

为什么不使用 Jacobian 矩阵来表示?因为 Jacobian 矩阵过于庞大,计算和存储都很不方便。反向传播通过逐层计算梯度,避免了显式构建 Jacobian 矩阵,从而提高了计算效率。

矩阵反传最重要的不是死记公式,而是检查形状和依赖关系。若前向是 y=xwy=xw,则 xx 影响 yy 的每一列,所以反向到 xx 时要把上游梯度与 wTw^T 相乘;ww 的每个元素连接输入维度和输出维度,所以反向到 ww 时要把 xTx^T 与上游梯度相乘。只要输出梯度的形状和原变量形状一致,公式通常就不会偏太远。

尝试推一遍:

对矩阵乘 y=xwy = xwyn,m=dxn,dwd,my_{n,m} = \sum_{d} x_{n,d} w_{d,m} (*);

其中 xx 的形状为 (N,D)(N, D)ww 的形状为 (D,M)(D, M)yy 的形状为 (N,M)(N, M)

现在我们要计算 LLxn,dx_{n,d} 的导数,需要用到两部分:第一部分是 LLyy 中某些元素的导数,这一部分已经在上一步的反向传播中得到;第二部分是 yy 中某些元素对 xn,dx_{n,d} 的导数,由于 yy 是通过矩阵乘法得到的,因此 yy 中每个元素对 xn,dx_{n,d} 的导数可以直接在 ww 中找出来。从上方(*)式来看,yn,my_{n,m}xn,dx_{n,d} 的导数就是 wd,mw_{d,m}

那么如何选取这些 yy 呢?在 xxyy 的运算中,xn,mx_{n,m} 会参与 yy 中第 nn 行所有元素的计算,而这些元素的计算则进一步影响了 LL,因此我们需要考虑 yy 中第 nn 行的所有元素。由于 yy 中每个元素对 xn,dx_{n,d} 的影响是独立的,因此我们可以将这些梯度直接相加,得到最终的梯度。因此就有:

Lxn,d=mLyn,myn,mxn,d=mLyn,mwd,m\frac{\partial L}{\partial x_{n,d}} = \sum_{m} \frac{\partial L}{\partial y_{n,m}} \cdot \frac{\partial y_{n,m}}{\partial x_{n,d}} = \sum_{m} \frac{\partial L}{\partial y_{n,m}} \cdot w_{d,m}

然后再推广到梯度矩阵。在上面得到的式子中,将 Ly\frac{\partial L}{\partial y} 的第 nn 行与 ww 的第 dd 行进行点积运算,就得到了 Lx\frac{\partial L}{\partial x} 的第 (n,d)(n,d) 个元素。前者的形状是 (N,M)(N, M),后者的形状是 (D,M)(D, M),而我们期望输出的矩阵是 (N,D)(N, D),为了形状对齐,我们将 ww 转置右乘到 Ly\frac{\partial L}{\partial y} 上,最终得到一个 (N,D)(N,D) 梯度矩阵:

Lx=LywT\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot w^T

同理可得:

Lw=xTLy\frac{\partial L}{\partial w} = x^T \cdot \frac{\partial L}{\partial y}

长得很对称,记起来还是很方便的,不禁感慨于数学的美感,也发现自己的线代基础真是稀烂。