《斋藤康毅-深度学习入门》读书笔记05-误差反向传播法

511 阅读9分钟

有生的日子天天快乐

将抽象的算式可视化——计算图

计算图(computational graph)是一种将算式表示为有向无环图的技术,通过计算图可以帮助我们理解误差反向传播法的原理。 计算图本身并不复杂,甚至连小学生都可以理解。一个计算图由节点两种元素组成,节点代表运算符,边代表用于运算的数据。用计算图进行解题分两步:

  1. 构建计算图
  2. 在计算图上从左向右进行计算

下面用买水果的例子进行说明。

太郎在超市买了2个苹果、3个橘子。其中,苹果每个100元,橘子每个150元。消费税是10%,请计算支付金额

用代数的方法可以求解总支付金额 y=(2*100+3*150)*1.1,这道题目的计算图表示如下:

image.png 图:买水果计算图

通过计算图可以得知共花费715元。

运算流程在计算图里有两个方向,分别是从左到右的正向传播,从右到左的反向传播

  • 正向传播 forward propagation:计算流从左向右传播,用于计算输出
  • 反向传播 backward propagation:计算流从右向左传播,用于计算导数

之所以引入计算图的概念,有以下好处:

  1. 关注点分离:每一个计算步骤只需要关心它上游-1与下游+1的运算过程,那些与它没有直接接触的运算细节可以被隐藏
  2. 高效计算导数:使用计算图协助理解误差的反向传播过程,从而高效计算导数(相比于微分计算)
  3. 并发:计算图的并发流式结构有助于应用在GPU这种并行计算硬件上

正向传播求解,反向传播求导

image.png 图:基于反向传播的导数传递

反向传播的链式法则

计算图反向传播的基础是链式法则,其思想是化简为繁,步步为营

image.png 图:反向传播链式法则原理

用一个例子说明:z=(x+y)^2,引入中间变量t=x+y,则它的计算图绘制如下:

image.png 图:z=(x+y)^2

反向的粗体箭头则是求导的传递过程,可知

dz/dx=dz/dz * dz/dt * dt/dx,因此原始问题演化成求dz/dzdz/dtdt/dx三个子运算的导数。

  • dz/dz = 1
  • dz/dt = 2*t = 2(x+y)
  • dt/dx = d(x+y)/dx = 1

因此dz/dx=2(x+y),每一步的过程如下(从右向左看):

image.png

反向传播:简单层之加法与乘法

加法/乘法节点的反向传播规则如下:

  • 加法:完璧归赵——直接传递
  • 乘法:借刀杀人——乘以另一个乘数

image.png image.png 上:加法的反向传播,下:乘法的反向传播

实践:简单层之加法与乘法

用Python实现加法与乘法的反向传播,将神经网络中的每一层定义为一个类,加法层AddLayer,乘法层MulLayer,以及下文将要讨论的ReLUSigmoidAffine都各自称为一个类。每一层要实现通用的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激活函数

image.png image.png 图: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的用法。

image.png

Sigmoid

同样,先复习下Sigmoid激活函数,它能够在保留原始大小关系的基础上,将输出局限在(0,1)的区间内。

image.png 图:Sigmoid的算式

image.png 图:Sigmoid计算图表示,已包含正向和反向传播

自然对数exp(x)的导数是它本身exp(x)

经过分布计算后,可以得出Sigmoid函数的反向传播为dL/dy * y^2 * exp(-x),进而将上图抽象为简洁版,简洁版有助于隐藏函数内部细节,只专注于输入和输出。

image.png 图:Sigmoid计算图简洁版

通过换算,可以将xy代替从而得到更简洁的算式:

image.png image.png 图: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()层,矩阵的乘积运算在几何学领域被称为仿射变换。在反向传播求导的过程中用到了矩阵转置的概念。

image.png 图:矩阵转置,沿对角线翻折

矩阵乘积Y=X·W的导数运算规则如下,口诀一替换二转置务必背下来

image.png 图:Y=X·W的求导运算

因此可以得出Affine层的反向传播导数

image.png

batch版本的Affine层

考虑N个数据一起进行正向传播,注意计算dL/dB时会进行在数据方向的求和

image.png

这里面有个很有意思的运算,在计算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个。

image.png

神经网络的推理(inference)过程不需要softmax,学习(learn)阶段才需要

处理softmax反向传播时,常常结合交叉熵误差来计算,称为softmax-with-loss层。之所以这么做,是因为这两者的反向传播经合并后得到的算式非常简单。

image.png 图: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步:

  1. mini-batch:从训练数据中随机选取一部分
  2. gradient:计算损失函数关于各个权重参数的梯度
  3. step upgrade:将权重参数沿梯度方向进行微笑的更新
  4. 重复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