P1:神经网络

此前已经使用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
import numpy as np
from numpy.random import randn

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))) # Sigmoid激活函数
y_pred = h.dot(w2)
loss = np.square(y_pred - y).sum() # L2损失函数
print(t, loss)

# 反向传播计算梯度
grad_y_pred = 2.0 * (y_pred - y) # 损失函数对y_pred的导数
grad_w2 = h.T.dot(grad_y_pred) # 链式法则计算w2的导数
grad_h = grad_y_pred.dot(w2.T) # 链式法则计算h的导数
grad_w1 = x.T.dot(grad_h * h * (1 - h)) # Sigmoid的导数

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


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

P2:反向传播

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

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

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},然后将这两个梯度传递给前一层结点。通过这种方式,梯度信息从输出层逐层传递回输入层,实现了高效的梯度计算。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def f(w0, x0, w1, x1, w2):
# 向前传播
s0 = w0 * x0
s1 = w1 * x1
s2 = s0 + s1
s3 = s2 + w2
L = sigmoid(s3)

# 反向传播
grad_L = 1.0 # 损失函数对L的导数
grad_s3 = grad_L * L * (1 - L) # Sigmoid的导数
grad_w2 = grad_s3
grad_s2 = grad_s3
grad_s0 = grad_s2
grad_s1 = grad_s2
grad_w1 = grad_s1 * x1
grad_x1 = grad_s1 * w1
grad_w0 = grad_s0 * x0
grad_x0 = grad_s0 * w0

一些其他示例:

推广到矩阵运算

线性代数学的不好,学这一部分的时候真的汗流浃背了(逃)。
将之前的简单运算变成矩阵运算:

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

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

尝试推一遍:

对矩阵乘 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}

长得很对称,记起来还是很方便的,不禁感慨于数学的美感。现在发现自己的线代基础真是稀烂,找个什么时候把线代捡起来再学一遍吧。