先引入一个很简单的问题:在超市买了 2 2 2个 100 100 100元一个的苹果,消费税是 10 % 10\% 10%,请计算支付金额。我们画出计算图如下:
接着进行简单的修改,如下图所示:
现在我们换个问题:在超市买了 2 2 2个苹果、 3 3 3个橘子。其中,苹果每个 100 100 100元,橘子每个 150 150 150元。消费税是 10 % 10\% 10%,请计算支付金额。同样,我们画出相应的计算图如下:
在计算图上,我们是从左向右进行计算的,是一种正方向上的传播,简称为正向传播( f o r w a r d p r o p a g a t i o n forward\ propagation forward propagation)。既然有正向传播这个名称,当然也可以考虑反向(从图上看的话,就是从右向左)的传播。实际上,这种传播称为反向传播( b a c k w a r d p r o p a g a t i o n backward\ propagation backward propagation)。反向传播将在接下来的导数计算中发挥重要作用。
计算图的特征是可以通过传递局部计算获得最终结果。即无论全局发生了什么,都能只根据与自己相关的信息输出接下来的结果。比如,在超市买了 2 2 2个苹果和其他很多东西,可以用以下计算图表示:
这里的重点是,各个节点处的计算都是局部计算。这意味着,例如苹果和其他很多东西的求和运算( 4000 + 200 = 4200 4000+200=4200 4000+200=4200)并不关心 4000 4000 4000这个数字是如何计算而来的,只要把两个数字相加就可以了。此外,计算图的另一个优点是可以将中间的计算结果全部保存起来。
假设我们想知道苹果价格的上涨会在多大程度上影响最终的支付金额,即求支付金额关于苹果的价格的导数。设苹果的价格为 x x x,支付金额为 L L L,则相当于求 ∂ L ∂ x \frac {\partial L}{\partial x} ∂x∂L,这个导数的值表示当苹果的价格稍微上涨时,支付金额会增加多少。
这个值可以通过计算图的反向传播求出来。先看一下结果,如下图所示:
从这个结果中可知,“支付金额关于苹果的价格的导数”的值是 2.2 2.2 2.2。这意味着,如果苹果的价格上涨 1 1 1元,最终的支付金额会增加 2.2 2.2 2.2元。
反向传播将局部导数向正方向的反方向(从右到左)传递,其原理是基于链式法则的。我们先来看一个使用计算图的反向传播的例子。假设存在 y = f ( x ) y=f(x) y=f(x)的计算,其反向传播如下图所示:
反向传播的计算顺序是,将信号E乘以节点的局部导数 ∂ y ∂ x \frac {\partial y}{\partial x} ∂x∂y,然后将结果传递给下一个节点。这里所说的局部导数是指正向传播中 y = f ( x ) y=f(x) y=f(x)的导数。
介绍链式法则时,我们需要先从复合函数说起。复合函数是由多个函数构成的函数。比如, z = ( x + y ) 2 z=(x+y)^2 z=(x+y)2是由以下两个式子构成的:
链式法则是关于复合函数的导数的性质,定义为:如果某个函数由复合函数表示,则该复合函数的导数可以用构成复合函数的各个函数的导数的乘积表示。
以上式为例, ∂ z ∂ x \frac {\partial z}{\partial x} ∂x∂z可以用 ∂ z ∂ t \frac {\partial z}{\partial t} ∂t∂z和 ∂ t ∂ x \frac {\partial t}{\partial x} ∂x∂t的乘积表示,即:
现在我们尝试将以上链式法则的计算用计算图表示出来:
根据链式法则, ∂ z ∂ z ∂ z ∂ t ∂ t ∂ x = ∂ z ∂ t ∂ t ∂ x = ∂ z ∂ x \frac {\partial z}{\partial z}\frac {\partial z}{\partial t}\frac {\partial t}{\partial x}=\frac {\partial z}{\partial t}\frac {\partial t}{\partial x}=\frac {\partial z}{\partial x} ∂z∂z∂t∂z∂x∂t=∂t∂z∂x∂t=∂x∂z成立,对应“ z z z关于 x x x的导数”。也就是说,反向传播是基于链式法则的。
首先来考虑加法节点的反向传播。这里以 z = x + y z=x+y z=x+y为对象,观察它的反向传播。 z = x + y z=x+y z=x+y的导数可由下式(解析性地)计算出来:
我们假定一个最终输出值为 L L L的大型计算图。 z = x + y z=x+y z=x+y的计算位于这个大型计算图的某个地方,从上游会传来 ∂ L ∂ z \frac {\partial L}{\partial z} ∂z∂L的值,并向下游传递 ∂ L ∂ x \frac {\partial L}{\partial x} ∂x∂L和 ∂ L ∂ y \frac {\partial L}{\partial y} ∂y∂L,如下图所示:
用计算图表示的话,如下图所示,因为加法节点的反向传播只乘以 1 1 1,所以输入的值会原封不动地流向下一个节点:
接下来,我们看一下乘法节点的反向传播。这里我们考虑 z = x y z=xy z=xy。这个式子的导数用下式表示:
乘法的反向传播会将上游的值乘以正向传播时的输入信号的“翻转值”后传递给下游,如下图所示:
假设有 10 × 5 = 50 10\times 5=50 10×5=50这一计算,反向传播时,从上游会传来值 1.3 1.3 1.3。用计算图表示的话如下图所示:
乘法的反向传播需要正向传播时的输入信号值。因此,实现乘法节点的反向传播时,要保存正向传播的输入信号。
使用反向传播计算最初的两个问题时,可画出计算图如下:
我们把要实现的计算图的乘法节点称为“乘法层”(MulLayer),加法节点称为“加法层”(AddLayer)。层的实现中有两个共通的方法(接口)forward()
和backward()
。forward()
对应正向传播,backward()
对应反向传播。
现在来实现乘法层。乘法层作为MulLayer
类,其实现过程如下所示:
class MulLayer:
def __init__(self):
self.x = None
self.y = None
def forward(self, x, y):
self.x = x
self.y = y
out = x * y
return out
def backward(self, dout):
dx = dout * self.y # 翻转x和y
dy = dout * self.x
return dx, dy
现在我们使用MulLayer
实现前面的购买苹果的例子:
apple = 100
apple_num = 2
tax = 1.1
# layer
mul_apple_layer = MulLayer()
mul_tax_layer = MulLayer()
# forward
apple_price = mul_apple_layer.forward(apple, apple_num)
price = mul_tax_layer.forward(apple_price, tax)
print(price) # 220
# backward
dprice = 1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)
print(dapple, dapple_num, dtax) # 2.2 110 200
接下来,我们实现加法节点的加法层:
class AddLayer:
def __init__(self):
pass # 加法层不需要特意进行初始化
def forward(self, x, y):
out = x + y
return out
def backward(self, dout):
dx = dout * 1
dy = dout * 1
return dx, dy
使用MulLayer
和AddLayer
实现前面的购买苹果和橘子的例子:
apple = 100
apple_num = 2
orange = 150
orange_num = 3
tax = 1.1
# layer
mul_apple_layer = MulLayer()
mul_orange_layer = MulLayer()
add_apple_orange_layer = AddLayer()
mul_tax_layer = MulLayer()
# forward
apple_price = mul_apple_layer.forward(apple, apple_num) # (1)
orange_price = mul_orange_layer.forward(orange, orange_num) # (2)
all_price = add_apple_orange_layer.forward(apple_price, orange_price) # (3)
price = mul_tax_layer.forward(all_price, tax) # (4)
# backward
dprice = 1
dall_price, dtax = mul_tax_layer.backward(dprice) # (4)
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price) # (3)
dorange, dorange_num = mul_orange_layer.backward(dorange_price) # (2)
dapple, dapple_num = mul_apple_layer.backward(dapple_price) # (1)
print(price) # 715
print(dapple_num, dapple, dorange, dorange_num, dtax) # 110 2.2 3.3 165 650
这里,我们把构成神经网络的层实现为一个类。先来实现激活函数的 R e L U ReLU ReLU层和 S i g m o i d Sigmoid Sigmoid层。 R e L U ReLU ReLU如下式所示:
可以求出 y y y关于 x x x的导数如下:
因此 R e L U ReLU ReLU层的计算图如下图所示:
使用Python实现代码如下:
class Relu:
def __init__(self):
self.mask = None
def forward(self, x):
self.mask = (x <= 0)
out = x.copy()
out[self.mask] = 0
return out
def backward(self, dout):
dout[self.mask] = 0
dx = dout
return dx
Relu
类有实例变量mask
。这个变量是由True/False
构成的NumPy数组,它会把正向传播时的输入 x x x的元素中小于等于 0 0 0的地方保存为True
,其他地方(大于 0 0 0的元素)保存为False
。
接下来,我们来实现 S i g m o i d Sigmoid Sigmoid函数,其公式如下:
其正向传播的计算图如下图所示,除了 “ × ” “\times” “×”和 “ + ” “+” “+”节点外,还出现了新的 “ e x p ” “exp” “exp”和 “ / ” “/” “/”节点。 “ e x p ” “exp” “exp”节点会进行 y = e x p ( x ) y=exp(x) y=exp(x)的计算, “ / ” “/” “/”节点会进行 y = 1 x y=\frac {1}{x} y=x1的计算:
现在我们来分析其反向传播流程:
(1) “ / ” “/” “/”节点表示 y = 1 x y=\frac {1}{x} y=x1它的导数可以解析性地表示为下式:
反向传播时,会将上游的值乘以 − y 2 -y^2 −y2后,再传给下游,计算图如下图所示:
(2) “ + ” “+” “+”节点将上游的值原封不动地传给下游,计算图如下图所示:
(3) “ e x p ” “exp” “exp”节点表示 y = e x p ( x ) y=exp(x) y=exp(x),它的导数由下式表示:
计算图中,上游的值乘以正向传播时的输出(这个例子中是 e x p ( − x ) exp(-x) exp(−x))后,再传给下游,计算图如下图所示:
(4) “ × ” “×” “×”节点将正向传播时的值翻转后做乘法运算。因此,这里要乘以 − 1 -1 −1。至此, S i g m o i d Sigmoid Sigmoid层整体的计算图如下图所示:
从上图的结果可知,反向传播的输出为 ∂ L ∂ y y 2 e x p ( − x ) \frac {\partial L}{\partial y}y^2exp(-x) ∂y∂Ly2exp(−x),这个值会传播给下游的节点。这里要注意, ∂ L ∂ y y 2 e x p ( − x ) \frac {\partial L}{\partial y}y^2exp(-x) ∂y∂Ly2exp(−x)这个值只根据正向传播时的输入 x x x和输出 y y y就可以算出来。因此,以上的计算图可以画成下图所示的集约化的 “ S i g m o i d ” “Sigmoid” “Sigmoid”节点:
另外, ∂ L ∂ y y 2 e x p ( − x ) \frac {\partial L}{\partial y}y^2exp(-x) ∂y∂Ly2exp(−x)可以进一步整理如下:
使用Python实现Sigmoid
层代码如下:
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 * (1.0 - self.out) * self.out
return dx
神经网络的正向传播中,为了计算加权信号的总和,使用了矩阵的乘积运算。现在将这里进行的求矩阵的乘积与偏置的和的运算用计算图表示出来。将乘积运算用 “ d o t ” “dot” “dot”节点表示的话,则 n p . d o t ( X , W ) + B np.dot(X,W)+B np.dot(X,W)+B的运算可用下图所示的计算图表示出来(注意: X , W , B X,W,B X,W,B是矩阵):
神经网络的正向传播中进行的矩阵乘积运算的层就称为 A f f i n e Affine Affine层。
现在我们来考虑上图的反向传播。以矩阵为对象的反向传播,按矩阵的各个元素进行计算时,步骤和以标量为对象的计算图相同。实际写一下的话,可以得到下式:
式中 W T W^T WT的 T T T表示转置。转置操作会把 W W W的元素 ( i , j ) (i,j) (i,j)换成元素 ( j , i ) (j,i) (j,i)。用数学式表示的话,可以写成下面这样:
现在我们尝试写出计算图的反向传播,如下图所示:
我们看一下上图中各个变量的形状。尤其要注意, X X X和 ∂ L ∂ X \frac {\partial L}{\partial X} ∂X∂L形状相同, W W W和 ∂ L ∂ W \frac {\partial L}{\partial W} ∂W∂L形状相同。从下面的数学式可以很明确地看出 X X X和 ∂ L ∂ X \frac {\partial L}{\partial X} ∂X∂L形状相同。
为什么要注意矩阵的形状呢?因为矩阵的乘积运算要求对应维度的元素个数保持一致,通过确认一致性,就可以很容易地推导出公式,如下图所示:
现在我们考虑 N N N个数据一起进行正向传播的情况,也就是批版本的 A f f i n e Affine Affine层。先给出批版本的 A f f i n e Affine Affine层的计算图如下图所示:
加上偏置时,需要特别注意。正向传播时,偏置被加到 X ⋅ W X\sdot W X⋅W的各个数据上。比如, N = 2 N=2 N=2(数据为 2 2 2个)时,偏置会被分别加到这 2 2 2个数据(各自的计算结果)上,具体的例子如下所示:
X_dot_W = np.array([[0, 0, 0], [10, 10, 10]])
B = np.array([1, 2, 3])
X_dot_W + B # array([[1, 2, 3], [11, 12, 13]])
正向传播时,偏置会被加到每一个数据(第 1 1 1个、第 2 2 2个、 … \dots …)上。因此,反向传播时,各个数据的反向传播的值需要汇总为偏置的元素。用代码表示的话,如下所示:
dY = np.array([[1, 2, 3], [4, 5, 6]])
dB = np.sum(dY, axis=0) # array([5, 7, 9])
综上所述, A f f i n e Affine Affine的实现如下所示:
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)
self.dW = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)
return dx
最后介绍一下输出层的 S o f t m a x Softmax Softmax函数。前面我们提到过, S o f t m a x Softmax Softmax函数会将输入值正规化之后再输出。比如手写数字识别时, S o f t m a x Softmax Softmax层的输出如下图所示:
下面来实现 S o f t m a x Softmax Softmax层。考虑到这里也包含作为损失函数的交叉熵误差( c r o s s e n t r o p y e r r o r cross\ entropy\ error cross entropy error),所以称为 S o f t m a x − w i t h − L o s s Softmax-with-Loss Softmax−with−Loss层,其化简后的计算图如下图所示:
图中要注意的是反向传播的结果, ( y 1 − t 1 , y 2 − t 2 , y 3 − t 3 ) (y_1-t_1,y_2-t_2,y_3-t_3) (y1−t1,y2−t2,y3−t3)是 S o f t m a x Softmax Softmax层的输出和教师标签的差分。神经网络的反向传播会把这个差分表示的误差传递给前面的层,这是神经网络学习中的重要性质。
现在来进行 S o f t m a x − w i t h − L o s s Softmax-with-Loss Softmax−with−Loss层的实现,实现过程如下所示,请注意反向传播时,将要传播的值除以批的大小( b a t c h _ s i z e batch\_size batch_size)后,传递给前面的层的是单个数据的误差:
class SoftmaxWithLoss:
def __init__(self):
self.loss = None # 损失
self.y = None # softmax的输出
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]
dx = (self.y - self.t) / batch_size
return dx
现在我们可以通过上一节实现的层来构建神经网络,误差反向传播法会在上一章构建神经网络的步骤(2)中出现,之前利用数值微分求得了这个梯度,现在使用误差反向传播法可以更高效地求解梯度。
同样我们实现一个 2 2 2层的神经网络TwoLayerNet
:
class TwoLayerNet:
def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01):
# initialize the weight
self.params = {}
self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)
# creat the layers
self.layers = OrderedDict()
self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1'])
self.layers['Relu1'] = Relu()
self.layers['Affine2'] = Affine(self.params['W2'], self.params['b2'])
self.lastLayer = SoftmaxWithLoss()
def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)
return x
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)
def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
if t.ndim != 1:
t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy
# 数值微分计算梯度
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)
grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])
return grads
# 误差反向传播计算梯度
def gradient(self, x, t):
# forward
self.loss(x, t)
# backward
dout = 1
dout = self.lastLayer.backward(dout)
layers = list(self.layers.values())
layers.reverse() # 从后往前传播
for layer in layers:
dout = layer.backward(dout)
# setting grads
grads = {}
grads['W1'] = self.layers['Affine1'].dW
grads['b1'] = self.layers['Affine1'].db
grads['W2'] = self.layers['Affine2'].dW
grads['b2'] = self.layers['Affine2'].db
return grads
将神经网络的层保存为OrderedDict
这一点非常重要。OrderedDict
是有序字典,“有序”是指它可以记住向字典里添加元素的顺序。因此反向传播只需要按照相反的顺序调用各层即可。
最后,我们来看一下使用了误差反向传播法的神经网络的学习的实现,和之前的实现相比,不同之处仅在于通过误差反向传播法求梯度这一点:
# 导入模块
...
# 读入数据
(x_train, t_train), (x_test, t_test) = load_mnist(normalize = True, one_hot_label = True)
# 创建网络
network = TwoLayerNet(input_size = 784, hidden_size = 50, output_size = 10)
# 设定超参数
...
for i in range(iters_num):
# mini-batch
...
# 计算梯度
# grad = network.numerical_gradient(x_batch, t_batch)
grad = network.gradient(x_batch, t_batch) # 误差反向传播法计算梯度
# 更新参数
...
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
if i % iter_per_epoch == 0:
# 记录accuracy
...
# 绘制图形
...
下一节:【学习笔记】深度学习入门:基于Python的理论与实现-与学习相关的技巧。