之前有一段时间,我需要复习梯度下降和反向传播来应付面试。 虽然梯度下降和反向传播都属于基础,但要想顺畅地在有限时间内把它写出来,还是挺不容易,必须要提前准备才行。本文记录了笔者的复习成果。
在编写代码的过程中,尤其要注意数组尺寸的正确性。计算和编写代码的过程中,犯错常有的。最好能通过梯度检查来校验梯度计算的正确性,这样我们才有信心根据计算得到的梯度进行梯度下降。
本文代码以示范为目的,并不适用于工程实践,因此文中的程序不会追求扩展性、运行效率等,而是尽量简洁明了。
本文尝试通过numpy库实现梯度下降,不依赖任何深度学习工具。我们先import numpy
.
import numpy as np
0) np.random.seed(
1 多层感知机的前向计算
本节将实现一个多层感知机,用它来回归sin函数。
为简便起见,这个多层感知机只使用relu激活函数。
1.1 建立模型
def relu(x):
return np.clip(x, 0, None)
以下代码定义了MLPRegression类的初始化函数。我们通过硬编码的方式将这个多层感知机设置为3层,并规定每层的输入输出维度。
class MLPRegression:
def __init__(
self,
):self.w1 = np.random.randn(1, 16)
self.b1 = np.random.randn(16, 1)
self.w2 = np.random.randn(16, 32)
self.b2 = np.random.randn(32, 1)
self.w3 = np.random.randn(32, 1)
self.b3 = np.random.randn(1, 1)
在前向传播阶段,这个多层感知机在每一层线性层后应用了一个relu激活函数。最后一层是例外的,没有任何激活函数,这样就不会限制模型拟合函数的值域。
与一般的torch代码不同,我们在forward过程中需要手动保存cache变量,记录模型的中间执行结果。在反向传播过程中,我们需要使用它们来计算梯度。
def mlp_regression_forward(self, x, y=None):
= x.shape
d_in, bs = self.w1.transpose() @ x + self.b1 # d_1, bs
h1 = relu(h1)
a1
= self.w2.transpose() @ a1 + self.b2 # d_2, bs
h2 = relu(h2)
a2
= self.w3.transpose() @ a2 + self.b3 # 1, bs
h3
= {
cache 'x': x,
'h1': h1,
'a1': a1,
'h2': h2,
'a2': a2,
'h3': h3,
'y': y
}
if y is not None:
= np.mean(np.abs(y - h3))
loss else:
= None
loss
return h3, loss, cache
= mlp_regression_forward MLPRegression.forward
接下来我们试运行一下forward函数。函数返回pred,loss,cache三个变量,分别对应模型的预测值,损失以及用于计算梯度的中间变量。
= MLPRegression()
r = r.forward(np.random.random((1, 2)))
pred, loss, cache for k, v in cache.items():
print(k, v.shape if v is not None else 'None')
x (1, 2)
h1 (16, 2)
a1 (16, 2)
h2 (32, 2)
a2 (32, 2)
h3 (1, 2)
y None
2 多层感知机的反向传播
反向传播代码是本文的重点。在编写反向传播代码之前,我们先复习几个重要函数的梯度计算。
2.1 MAE函数的梯度
代码中,我们使用的损失函数为MAE,即 \[ l=\frac{1}{m} \sum_i^m |\hat y - y| \] 易知其导数为 \[ \frac{\mathrm d l}{\mathrm d \hat y} = \left\{ \begin{aligned} 1&, \hat y > y \\ -1&, \hat y < y \end{aligned} \right. \] 姑且不考虑0点処该函数没有导数的问题。
2.2 ReLU函数的梯度
ReLU函数的公式为: \[ a=\text{ReLU}(h)=\max(0, h) \] 易得其导数(同样不考虑\(h=0\) 时没有导数的问题)为 \[ \frac{\mathrm d a}{\mathrm dh} = \left\{ \begin{aligned} 1 & ,h > 0 \\ 0 & ,h < 0 \end{aligned} \right. \]
2.3 线性层的梯度
设MLP的一层线性变换为 \[ \vec y=\mathbf W^T \vec x + \vec b, \] 其中\(\vec b\in \mathbb R^{d_\text{out}}, \vec x\in \mathbb R^{d_\text{in}, 1}\) , 应用链式法则,可以求得以下梯度: \[\begin{aligned} \frac{\partial l}{\partial \mathbf W} &= \vec x \left(\frac{\partial l}{\partial \vec y}\right)^T\\ \frac{\partial l}{\partial \vec b} &= \frac{\partial l}{\partial \vec y}\\ \frac{\partial l}{\partial \vec x} &= \mathbf W \left(\frac{\partial l}{\partial \vec y}\right) \\ \end{aligned}, \] 其中\(l\)为模型的损失。
2.4 反向传播代码
根据以上推导,可以写出如下的反向传播代码。该函数返回一个dict
用于存储各个变量的梯度。
def mlp_regression_backward(self, cache):
= cache['y']
y = y.shape
_, bs = cache['h3']
pred
= 1. / bs * (
dh3 > y)
np.int64(pred - np.int64(pred < y)
# 1, m
)
# w3.T @ a2 + b3 = h3
= cache['a2'] @ dh3.T # d_2, 1
dw3 = dh3.sum(axis=-1, keepdims=True) # 1, 1
db3 = self.w3 @ dh3 # d_2, bs
da2
# a2 = relu(h2)
= da2 * (cache['h2'] > 0) # d_2, bs
dh2 # h2 = w2.T @ a1 + b2
= cache['a1'] @ dh2.T # d_1, d_2
dw2 = dh2.sum(-1, keepdims=True) # d_2, 1
db2 = self.w2 @ dh2 # d_1, bs
da1
# a1 = relu(h1)
= da1 * (cache['h1'] > 0) # d_1, bs
dh1 # h1 = w1.T @ x + b1
= cache['x'] @ dh1.T # d_in, d_1
dw1 = dh1.sum(-1, keepdims=True) # d_in, 1
db1
return {
'dw3': dw3, 'db3': db3,
'dw2': dw2, 'db2': db2,
'dw1': dw1, 'db1': db1
}
= mlp_regression_backward
MLPRegression.backward
= MLPRegression()
r = r.forward(
yh, loss, cache 1, 2]).reshape(1, 2),
np.array([=np.array([2, 3]).reshape(1, 2),
y
)= r.backward(cache)
grads for k, v in grads.items():
print(k, v.shape)
dw3 (32, 1)
db3 (1, 1)
dw2 (16, 32)
db2 (32, 1)
dw1 (1, 16)
db1 (16, 1)
2.5 梯度检查
一次性正确写完梯度反传并不容易。梯度检查是校验梯度计算正确性的重要方法。 根据梯度的定义,设损失函数为\(J\) ,那么梯度可以用如下方法估计: \[
\frac{\partial J(x;\vec \theta)}{\partial \theta_i} \approx \frac{J(x;[\theta_1, \theta_2, \cdots, \theta_i + \epsilon, \cdots, \theta_n]) - J(x;[\theta_1, \theta_2, \cdots, \theta_i - \epsilon, \cdots, \theta_n] )}{2\epsilon}
\] 下面提供的parameters_to_vector
,restore_parameters_from_vector
函数实现了将模型参数转化为向量和从向量恢复模型参数的功能。grads_to_vector
和vector_to_grads
也起类似作用。基于此我们可以实现梯度检查。
def parameters_to_vector(self):
= [self.w1, self.b1, self.w2, self.b2, self.w3, self.b3]
ret = [it.reshape((-1,)) for it in ret]
ret return np.concatenate(ret)
= parameters_to_vector
MLPRegression.parameters_to_vector
def grads_to_vector(self, grads):
= [grads[k] for k in ['dw1', 'db1', 'dw2', 'db2', 'dw3', 'db3']]
ret = [it.reshape((-1,)) for it in ret]
ret return np.concatenate(ret)
= grads_to_vector
MLPRegression.grads_to_vector
def vector_to_grads(self, vec):
= [self.w1, self.b1, self.w2, self.b2, self.w3, self.b3]
params = ['dw1', 'db1', 'dw2', 'db2', 'dw3', 'db3']
names = [it.size for it in params]
param_sizes = [0] + param_sizes
param_offsets for i in range(len(param_offsets) - 1):
+ 1] += param_offsets[i]
param_offsets[i = [vec[param_offsets[i]:param_offsets[i + 1]].reshape(p.shape) for i, p in enumerate(params)]
grads return {k: v for k, v in zip(names, grads)}
= vector_to_grads
MLPRegression.vector_to_grads
def restore_parameters_from_vector(self, vec):
= [self.w1, self.b1, self.w2, self.b2, self.w3, self.b3]
params = [it.size for it in params]
param_sizes = [0] + param_sizes
param_offsets for i in range(len(param_offsets) - 1):
+ 1] += param_offsets[i]
param_offsets[i = [vec[param_offsets[i]:param_offsets[i + 1]].reshape(p.shape) for i, p in enumerate(params)]
params2 = params2
w1, b1, w2, b2, w3, b3 self.w1 = w1
self.b1 = b1
self.w2 = w2
self.b2 = b2
self.w3 = w3
self.b3 = b3
= restore_parameters_from_vector
MLPRegression.restore_parameters_from_vector
= MLPRegression()
r = np.random.randn(1, 2)
x = np.random.randn(1, 2)
y = r.forward(x, y)
yh, loss, cache = r.backward(cache)
grads = r.grads_to_vector(grads)
grads_vec = grads_vec * 0
grads_est = r.parameters_to_vector()
parameters_vec print(grads_vec.shape)
= len(grads_vec)
num_parameters = 1e-4
eps for i in range(num_parameters):
= parameters_vec.copy()
parameters_vec_copy += eps
parameters_vec_copy[i]
r.restore_parameters_from_vector(parameters_vec_copy)= r.forward(x, y)
_, loss, _
= parameters_vec.copy()
parameters_vec_copy -= eps
parameters_vec_copy[i]
r.restore_parameters_from_vector(parameters_vec_copy)= r.forward(x, y)
_, loss2, _
= (loss - loss2) / (2 * eps)
grad = grad
grads_est[i]
= np.max(np.abs(grads_vec - grads_est))
difference print('Diff:', difference)
(609,)
Diff: 3.414629690112747e-11
由代码输出可见,文章给出的梯度计算方法与梯度估算法的结果相差无几,这证明了本文推导的正确性。
3 训练
以下程序以最基础的SGD方法训练了给出的模型,并打印损失函数。
import tqdm.notebook as tqdm
= 1e-3
learning_rate = MLPRegression()
r = 10000
total_iters = tqdm.tqdm(total=total_iters)
pbar = []
loss_records for i in range(total_iters):
= np.random.rand(1, 16) * 10 - 5
x = np.sin(x)
y = r.forward(x, y)
yh, loss, cache = r.backward(cache)
grads for k, v in grads.items():
assert k.startswith('d')
= k[1:]
param_name = getattr(r, param_name)
param assert param.shape == v.shape, (param.shape, v.shape)
setattr(r, param_name, param - v * learning_rate)
if i % 10 == 0:
loss_records.append((i, loss))
f'loss: {loss:.4f}')
pbar.set_description( pbar.update()
import matplotlib.pyplot as plt
= np.array(loss_records)
loss_records 0], loss_records[:, 1]) plt.plot(loss_records[:,
训练完成后,模型的推理效果如图所示。由图可见,模型成功拟合了 函数。
这里模型的拟合效果并不完美。毕竟这只是一个三层的小型MLP,它还是有很大改进空间的。
= np.linspace(-5, 5, 1000)[None, :]
x = r.forward(x)
y, _, _
plt.figure()= x.reshape((-1,))
x = y.reshape((-1,))
y
plt.plot(x, np.sin(x))
plt.plot(x, y)
plt.legend(['sin(x)',
'pred'
]) plt.show()
4 附录
附录提供了正文未使用到的一些常用函数的梯度推导。这些推导对实现分类器会有帮助,但本文就不给出详细实现了。
4.1 Sigmoid的梯度推导
设\(\sigma(x)=\frac{1}{1+e^{-x}}\)是sigmoid函数。那么\(\sigma(x)\) 的导数为 \[\begin{aligned} \sigma'(x) &= \frac{e^{-x}}{(1 + e^{-x})^2} \\ &= \frac{1}{1 + e^{-x}} \frac{e^{-x}}{1+e^{-x}} \\ &= \sigma(x)(1-\sigma(x)) \end{aligned} \]
4.2 二值交叉熵函数的梯度推导
假设模型的最后一层激活函数为sigmoid函数,即\(\vec a = \sigma(\vec h)\). 损失函数为: \[ J(\vec a)=-\frac{1}{m} \sum_i^m \left( y_i \log (a_i) + (1-y_i)\log(1 - a_i) \right) \] 于是 \[ \frac{\partial J}{\partial a_i} = -\frac{1}{m} \left( y_i \frac{1}{a_i} -(1 - y_i)\frac{1}{1-a_i} \right) \] \[ \begin{aligned} \therefore \frac{\partial J}{\partial h_i} = \frac{\partial J}{\partial a_i} \frac{\partial a_i}{\partial h_i} & = -\frac{1}{m} \left( y_i \frac{1}{a_i} -(1 - y_i)\frac{1}{1-a_i} \right) (a_i)(1 - a_i) \\ &= -\frac{1}{m} \left(y_i(1-a_i) - (1 - y_i)a_i \right)\\ &= -\frac{1}{m} \left(y_i - y_ia_i - a_i + y_ia_i\right) \\ &= \frac{1}{m} (a_i - y_i) \end{aligned} \] \[ \therefore \frac{\partial J}{\partial \vec h} = \frac{1}{m}(\mathbf A - \mathbf Y) \] 假设\(\mathbf A\) 是由特征\(\vec x\)经过一层线性层,再经过softmax激活函数得来的,即 \[ \mathbf A=\sigma(\vec h) = \sigma( \vec w^T\mathbf X+ \vec b), \] 那么 \[ \begin{aligned} \therefore \frac{\partial J}{\partial \vec w} &= \frac{1}{m}\mathbf X(\mathbf A- \mathbf Y)^T \\ \frac{\partial J}{\partial \vec b} &= \frac{1}{m}(\mathbf A - \mathbf Y) \end{aligned} \]