有生的日子天天快乐
将抽象的算式可视化——计算图
计算图(computational graph)是一种将算式表示为有向无环图的技术,通过计算图可以帮助我们理解误差反向传播法的原理。
计算图本身并不复杂,甚至连小学生都可以理解。一个计算图由节点、边两种元素组成,节点代表运算符,边代表用于运算的数据。用计算图进行解题分两步:
- 构建计算图
- 在计算图上从左向右进行计算
下面用买水果的例子进行说明。
太郎在超市买了2个苹果、3个橘子。其中,苹果每个100元,橘子每个150元。消费税是10%,请计算支付金额
用代数的方法可以求解总支付金额 y=(2*100+3*150)*1.1,这道题目的计算图表示如下:
图:买水果计算图
通过计算图可以得知共花费715元。
运算流程在计算图里有两个方向,分别是从左到右的正向传播,从右到左的反向传播。
- 正向传播 forward propagation:计算流从左向右传播,用于计算
输出 - 反向传播 backward propagation:计算流从右向左传播,用于计算
导数
之所以引入计算图的概念,有以下好处:
- 关注点分离:每一个计算步骤只需要关心它上游
-1与下游+1的运算过程,那些与它没有直接接触的运算细节可以被隐藏 - 高效计算导数:使用计算图协助理解误差的反向传播过程,从而高效计算导数(相比于微分计算)
- 并发:计算图的并发流式结构有助于应用在GPU这种并行计算硬件上
正向传播求解,反向传播求导
图:基于反向传播的导数传递
反向传播的链式法则
计算图反向传播的基础是链式法则,其思想是化简为繁,步步为营。
图:反向传播链式法则原理
用一个例子说明:z=(x+y)^2,引入中间变量t=x+y,则它的计算图绘制如下:
图:z=(x+y)^2
反向的粗体箭头则是求导的传递过程,可知
dz/dx=dz/dz * dz/dt * dt/dx,因此原始问题演化成求dz/dz、dz/dt、dt/dx三个子运算的导数。
- dz/dz = 1
- dz/dt = 2*t = 2(x+y)
- dt/dx = d(x+y)/dx = 1
因此dz/dx=2(x+y),每一步的过程如下(从右向左看):
反向传播:简单层之加法与乘法
加法/乘法节点的反向传播规则如下:
- 加法:完璧归赵——直接传递
- 乘法:借刀杀人——乘以另一个乘数
上:加法的反向传播,下:乘法的反向传播
实践:简单层之加法与乘法
用Python实现加法与乘法的反向传播,将神经网络中的每一层定义为一个类,加法层AddLayer,乘法层MulLayer,以及下文将要讨论的ReLU、Sigmoid、Affine都各自称为一个类。每一层要实现通用的forward()和backward()函数,对应正向传播和反向传播。
# 加法层
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
# 乘法层
class MulLayer:
def __init__(self): # 乘法层在正向传播时记录下x、y
self.x = None
self.y = None
def forward(self, x, y): # 记录x、y,以便反向传播使用
self.x = x
self.y = y
out = x * y
return out
def backward(self, dout): # 使用前面记录的x、y
dx = dout * self.y
dy = dout * self.x
return dx, dy
下面是对买水果例子的应用。
# coding: utf-8
from layer_naive import * # 引入上面定义好的类
apple = 100 # Python比Kotlin更简洁
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:", int(price))
print("dApple:", dapple)
print("dApple_num:", int(dapple_num))
print("dOrange:", dorange)
print("dOrange_num:", int(dorange_num))
print("dTax:", dtax)
反向传播:激活层之ReLU和Sigmoid
ReLU
复习一下ReLU激活函数
图:ReLU及其导数
ReLU是以0为界分段的,x>0时可视为加法(+0),x<=0时则归零。因此其反向传播也可以分段来表述。
class Relu:
def __init__(self):
self.mask = None
def forward(self, x):
self.mask = (x <= 0)
out = x.copy() # copy一份,不要修改参数
out[self.mask] = 0 # ReLU定义
return out
def backward(self, dout):
dout[self.mask] = 0
dx = dout # 反向传播以0为界
return dx
Relu类里维护了一个mask成员变量,它记录输入参数x中那些下标<=0的数字,可以用来批量操作。
下面这段demo代码有助于理解mask的用法。
Sigmoid
同样,先复习下Sigmoid激活函数,它能够在保留原始大小关系的基础上,将输出局限在(0,1)的区间内。
图:Sigmoid的算式
图:Sigmoid计算图表示,已包含正向和反向传播
自然对数
exp(x)的导数是它本身exp(x)
经过分布计算后,可以得出Sigmoid函数的反向传播为dL/dy * y^2 * exp(-x),进而将上图抽象为简洁版,简洁版有助于隐藏函数内部细节,只专注于输入和输出。
图:Sigmoid计算图简洁版
通过换算,可以将x用y代替从而得到更简洁的算式:
图:Sigmoid计算图,只使用变量y
# Sigmoid Layer 层
class Sigmoid:
def __init__(self):
self.out = None
def forward(self, x):
out = sigmoid(x)
self.out = out
return out
def backward(self, dout):
dx = dout * (1.0 - self.out) * self.out
return dx
反向传播:Affine和Softmax
Affine
Affine层也就是矩阵乘法np.dot()层,矩阵的乘积运算在几何学领域被称为仿射变换。在反向传播求导的过程中用到了矩阵转置的概念。
图:矩阵转置,沿对角线翻折
矩阵乘积Y=X·W的导数运算规则如下,口诀一替换二转置务必背下来
图:Y=X·W的求导运算
因此可以得出Affine层的反向传播导数
batch版本的Affine层
考虑N个数据一起进行正向传播,注意计算dL/dB时会进行在数据方向的求和
这里面有个很有意思的运算,在计算X·W+B的过程中,偏置B会被加到N条数据里的每一个,numpy会自动处理这种场景。下面写个demo说明其运作原理
import numpy as np
a = np.array([[0,0,0],[10,10,10]])
b = np.array([1,1,1])
a + b # b会被广播broadcast给a内部每一个元素,得到[[1,1,1], [11,11,11]]
c = np.array([[1,1,1],[2,2,2]])
a + c # 一一对应相加,得到[[1,1,1], [12,12,12]]
d = np.array([[1,1,1], [2,2,2], [3,3,3]])
a + d # 报错!无法进行broadcast
完整的batch版本Affine Layer代码如下,包含了四维张量Tensor的写法
class Affine:
def __init__(self, W, b):
self.W =W
self.b = b
self.x = None
self.original_x_shape = None
# 权重和偏置参数的导数
self.dW = None
self.db = None
def forward(self, x):
# 对应张量
self.original_x_shape = x.shape # 记录batch信息,用于张量Tensor
x = x.reshape(x.shape[0], -1)
self.x = x
out = np.dot(self.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) # 在第0轴求和
dx = dx.reshape(*self.original_x_shape) # 还原输入数据的形状(对应张量)
return dx
Softmax-with-Loss
Softmax函数将数据正规化后进行输出,正规化是指全部数据的和为1,适用于概率的场景。在识别手写数字的问题中,softmax的输入和输出数量都是10个。
神经网络的推理(
inference)过程不需要softmax,学习(learn)阶段才需要
处理softmax反向传播时,常常结合交叉熵误差来计算,称为softmax-with-loss层。之所以这么做,是因为这两者的反向传播经合并后得到的算式非常简单。
图:softmax-with-loss layer 反向传播
Softmax层的反向传播得到了(y1-t1, y2-t2, y3-t3)的结果,非常工整,这也是交叉熵函数之所以这样设计的原因。
Softmax代码实现如下,其中cross_entropy_error函数已经在以往定义过。
# softmax layer
class SoftmaxWithLoss:
def __init__(self):
self.loss = None
self.y = None # softmax的输出
self.t = None # 监督数据
def forward(self, x, t): #前向传播,直接计算出loss
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]
if self.t.size == self.y.size: # 监督数据是one-hot-vector的情况
dx = (self.y - self.t) / batch_size
else:
dx = self.y.copy()
dx[np.arange(batch_size), self.t] -= 1
dx = dx / batch_size # 除以batch大小
return dx
实践:使用误差反向传播进行学习
误差反向传播为我们提供了替换数值微分的梯度计算方法,它的优点是计算简便效率更高,神经网络学习过程的全貌维持不变,依然是下面4步:
- mini-batch:从训练数据中随机选取一部分
- gradient:计算损失函数关于各个权重参数的梯度
- step upgrade:将权重参数沿梯度方向进行微笑的更新
- 重复1~3
误差反向传播在第2步发挥作用,它的Python代码实现如下
# coding: utf-8
import sys, os
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
import numpy as np
from common.layers import *
from common.gradient import numerical_gradient
from collections import OrderedDict
class TwoLayerNet:
def __init__(self, input_size, hidden_size, output_size, weight_init_std = 0.01):
# 初始化权重,这部分与上一章完全相同,权重按照高斯分布进行随机初始化
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)
# 生成层,有序添加
self.layers = OrderedDict() # 有序查找表,类似Java中的LinkedHashMap
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()
# 预测过程,按顺序执行layer-list,注意这里没有执行最后的softmax
def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)
return x
# x:输入数据, t:监督数据
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t) # 执行softmax和cross-entropy
def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1) # 在axis=1处的最大值
if t.ndim != 1 : t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0]) # 批量计算准确度
return accuracy
# x:输入数据, t:监督数据
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,重点!使用误差反向传播法计算,初始值是1
dout = 1
dout = self.lastLayer.backward(dout)
layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout) # 传递dout
# 设定
grads = {}
grads['W1'], grads['b1'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W2'], grads['b2'] = self.layers['Affine2'].dW, self.layers['Affine2'].db
return grads