NN的学习中需要计算权重和偏置参数的梯度,对于梯度的计算,很容易想到数值导数,即前向差分
d x = f ( x + h ) − f ( x ) h dx=\frac{f(x+h)-f(x)}{h} dx=hf(x+h)−f(x)
或者改进一点,用中心差分(更接近准确的导数值)
d x = f ( x + h ) − f ( x − h ) 2 h dx=\frac{f(x+h)-f(x-h)}{2h} dx=2hf(x+h)−f(x−h)
h取一个接近0的数值,如0.0001
但这样计算是比较慢的,尤其是大型网络,参数众多的情况,所以我们用一种更快的方法计算参数的梯度值——反向传播。
正是链式法则的应用简化了梯度计算,而且链式法则和反向传播都特别简单,非常好理解。
由于太简单,本文跳过原理叙述直接写代码,如果想仔细学习BP可以看斋藤康毅的《Deep Learning from Scratch》(很火的红色封面,有一条灰色的鱼,CSDN下载链接,我下载看过,非常清晰)中第五章关于反向传播的讲解,极为透彻!!!用计算图作为示例,一点一点,推导加法器,乘法器,RELU,sigmoid,softmax,除法器,exp运算,log运算等,总之所有的NN中用到的运算模块的反向导数,还推导了softmax-with-cross_entropy_error层的反向导数求解,得到了极为漂亮的结果,也正是这个结果使得梯度的计算如此简单。
NN的每一层都实现为一个类,包括Affine层,Sigmoid层,Relu层,Softmax-with-Loss层(loss用cross-entropy-error),类中定义前向传播和反向传播的计算方法。
y = { x , x > 0 0 , x ≤ 0 y=\left\{ \begin{aligned} x,x>0\\ 0,x\leq0\\ \end{aligned} \right. y={x,x>00,x≤0
导数自然就是:
∂ y ∂ x = { 1 , x > 0 0 , x ≤ 0 \frac{\partial y}{\partial x}=\left\{ \begin{aligned} 1,x>0\\ 0,x\leq0\\ \end{aligned} \right. ∂x∂y={1,x>00,x≤0
代码:
# relu层的类
class Relu:
def __init__(self):
self.mask = None
# 前向传播的计算
def forward(self, x):
self.mask = (x <= 0)
out = x.copy() # out就等于x
out[self.mask] = 0
return out
# 反向传播的计算
def backward(self, dout):
dout[self.mask] = 0
dx = dout
return dx
y = 1 1 + e x p ( − x ) y=\frac{1}{1+exp(-x)} y=1+exp(−x)1
代码:
class Sigmoid:
def __init__(self):
self.out = None
def forward(self, x):
out = 1 / 1 + np.exp(-x)
self.out = out
return out
def backward(self, dout):
dx = dout * self.out * (1 - self.out)
return dx
NN中前向传播需要用矩阵乘法,在每个学习层都会遇到这样的计算:
Y = X . W + B Y=X.W+B Y=X.W+B
运算里有缩放和平移,在几何学上称为仿射变换(affine),所以我们把这个计算专门定义为一层,叫做affine层,这样每一个学习层都可以重复调用这个类。
上2图对于理解代码的backward()方法非常重要!!!
5-25是单个输入经过affine层的正向和反向计算,但5-27展示的批版本的才是实用价值更大的,加快计算速度,而且不难,只要理解矩阵乘法就行。
黑色反向粗箭头下面写的是导数值,一点一点,依据链式法则从输出传到输入,L是假设的整个NN的最终输出的损失函数,由于affine层只是NN的一个part,所以反向传播由 ∂ L ∂ Y \frac{\partial L}{\partial Y} ∂Y∂L开始:
经过乘法节点,翻转相乘的解释:
z = x y z=xy z=xy
∂ z ∂ x = y \frac{\partial z}{\partial x}=y ∂x∂z=y
∂ z ∂ y = x \frac{\partial z}{\partial y}=x ∂y∂z=x
经过加法节点,导数不变的解释:
z = x + y z=x+y z=x+y
∂ z ∂ x = 1 \frac{\partial z}{\partial x}=1 ∂x∂z=1
∂ z ∂ y = 1 \frac{\partial z}{\partial y}=1 ∂y∂z=1
代码:
class Affine:
def __init__(self, w, b):
self.w = w
self.b = b
self.x = None
self.dw = None
self.db = None
def forward(self,x):
self.x = x
out = np.dot(x, self.w) + self.b
return out
def backward(self,dout):
dx = np.dot(dout, self.w.T)
# 权重经过的是乘法器单元,对数据x求导则让输出dout乘以权重
# 对权重求导则让dout乘以数据x
# 偏置经过加法器单元,对b求导就等于对dout求导
self.dw = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)
return dx
如图,这是一个3层NN(2个隐层)
affine层用于完成每层的加权和偏置计算
隐层用relu激活函数
输出层用softmax激活函数(因为这是分类任务,回归任务用恒等函数作为激活函数)对结果进行正规化(输出的和调整为1),正规化之前的NN输出通常被称为“得分”
注意:NN中有两个阶段,学习和推理。
softmax层只对NN的学习有用,学完之后投入推理阶段后,这一层是可有可无的,毕竟直接选择得分最大的输出作为分类结果也是可以的,所以推理阶段通常不用softmax层。
分类任务中,通常隐层会使用relu居多(比sigmoid的使用更多),而输出层affine完了后是一定会使用softmax激活函数的,并且一定要搭配交叉熵误差损失函数cross-entropy-error,因为这样搭配起来会使得Softmax-with-Loss层得到最简单漂亮的反向导数:
这个图看似很复杂,实际并不难,示例的Softmax-with-Loss层(输出层)有3个输出unit, 即这是个三元分类任务,正向的传播很简单,略过。
反向导数计算:
很显然,L是交叉熵损失,最开始的导数是 ∂ L ∂ L = 1 \frac{\partial L}{\partial L}=1 ∂L∂L=1
def softmax(a):
c = np.max(a)
exp_a = np.exp(a-c) # 防溢出
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)
# 监督数据是one-hot-vector的情况下,转换为正确解标签的索引
if t.size == y.size:
t = t.argmax(axis=1)
batch_size = y.shape[0]
return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size
class SoftmaxWithLoss:
def __init__(self):
self.loss = None
self.y = None
self.t = None # one-hot vector
def forward(self, x, t):
self.t = t
self.y = softmax(x)
self.loss = cross_entropy_error(self.y, self.t)
return self.loss
def backward(self, dout=1):
batch_size = self.t.shape[0]
dy = (self.y - self.t) / batch_size
# 注意反向传播时,将要传播的值除以批的大小(batch_size)后,
# 传递给前面的层的是单个数据的误差。
return dy