本章的核心问题是: 给定参数化模型的损失函数 f ( x , w ) f(x,w) f(x,w),其中 x x x 是输入数据, w w w 是模型参数,需要计算损失函数关于参数 w w w 的梯度。有了梯度,我们就能使用梯度下降法的各种改进方法进行模型优化了。注意损失函数输出的是标量,是一个数值,而不是向量或矩阵。
神经网络模型包含最基本的三种函数:乘法、加法和非线性激活Re LU。这三种函数的梯度计算都非常简单,列举如下:
这里所有的变量都是标量,为一个数值。 I ( x ) I(x) I(x) 是指示函数,当括号内的表达式为真时输出1 ,否则输出 0 。注意 m a x ( 0 , x ) max(0,x) max(0,x) 函数,在 0 点导数不连续,但是右导数为 1 ,左导数为 0 ,存在次导数。读者需要牢记偏导数的意义为函数值对于该变量的敏感程度,偏导数越大说明越敏感。偏导数为正, 表示变量增加,函数值增加,是正相关; 偏导数为负,表示变量增加,函数值减小,是负相关。
对于复合函数,求梯度需采用链式法则。链式法则的内容如下:
z = f ( y ) , y = g ( x ) z=f(y),y=g(x) z=f(y),y=g(x)
则: ∂ z ∂ x = ∂ z ∂ y ∂ y ∂ x \frac{{\partial z}}{{\partial x}} = \frac{{\partial z}}{{\partial y}}\frac{{\partial y}}{{\partial x}} ∂x∂z=∂y∂z∂x∂y,即 z z z 对 变量 x x x 的梯度是 z z z 对中间变量 y y y 的梯度与 y y y 对变量 x x x 的梯度的乘积。
常规神经网络和卷积网络都是多层网络,多层网络从函数角度看,就是复合函数。举个例子,为了突出链式法则,我们对模型进行简化,忽略偏置,令神经网络的每层神经元数量都为I ,这样都是标量,简化了偏导数求解, 当两层的常规神经网络用于回归任务时, 其数学模型为:
a 1 = x w 1 , h 1 = max ( 0 , a 1 ) , a 2 = h 1 w 2 , l o s s = 1 2 ( a 2 − y 0 ) 2 {a_1} = x{w_1},\ {h_1} = \max (0,{a_1}),\ \ {a_2} = {h_1}{w_2}, \ \ loss = \frac{1}{2}{({a_2} - {y_0})^2} a1=xw1, h1=max(0,a1), a2=h1w2, loss=21(a2−y0)2
其中 x x x 是输入, y 0 y_0 y0 是 x x x 对应的标签, 即需拟合的真实值。为了求 loss 对参数 w 1 w_1 w1 的梯度,必须采用链式法则,先求每个函数的梯度,然后所有梯度连乘,得到最终梯度:
这个例子表明,利用链式法则求梯度,只要能求得每个中间函数的梯度,则最终的梯度就是每个梯度的乘积。中间函数就是每层的变换函数,它们都由三种基本函数组成,求解十分简单,所以不存在困难。
这是一个稍微复杂的例子,函数形式:
f = x + y 2 x + y f = \frac{{x + y}}{{2x + y}} f=2x+yx+y
求 f f f 对 x x x 的偏导数。读者可以采用高数的方法直接求偏导数,这里 为了清晰地展示链式法则和理解深度网络,我们采用分步法。分步法的关键是一个复杂函数通过中间变量,使之变成简单模块的嵌套,每个简单模块能直接利用公式得到偏导数,每个模块相当于一层网络。具体如下,先通过一些中间变量来计算函数值:
n u m = x + y , d e n = 2 x + y , i n v d e n = 1 / d e n , f = n u m × i n v d e n num=x+y, \ den=2x+y, \ invden = 1/den, \ f=num \times invden num=x+y, den=2x+y, invden=1/den, f=num×invden
再对每个函数求偏导数,得:
这里有两个路径可以得到 f f f 对 x x x 的偏导数:
一定要注意,最终结果是这两个偏导数之和。
本节采用 n n n 层网络模型,求损失函数对参数的偏导,达到深刻理解梯度反向传播规律的目的这对理解深度网络训练特别有帮助。
同理,本节为了突出梯度反向传播规律,简化数学处理,令深度神经网络的每层神经元数量都为1 ,这样都是标量,简化了求偏导数。神经网络用于回归任务,设 x x x 是输入, y 0 y_0 y0 是 x x x 对应的标签, 即需拟合的真实值, h n h_n hn 是网络的预测值, f ( x ) f(x) f(x) 是非线性激活函数。令 h 0 h_0 h0 等于 x x x , f ’ ( a n ) f’( a_n) f’(an) 等于 1 ,则数学模型为:
首先,对每层求偏导数, 然后进行连乘,就能得到损失 loss 对任一层 参数 w i w_i wi 的梯度:
∂ l o s s ∂ w i = ( h n − y 0 ) ∏ j = i + 1 n w j ∏ j = i n f ′ ( a j ) h i − 1 \frac{{\partial loss}}{{\partial {w_i}}} = ({h_n} - {y_0})\prod\limits_{j = i + 1}^n {{w_j}} \prod\limits_{j = i}^n {f'({a_j}){h_{i - 1}}} ∂wi∂loss=(hn−y0)j=i+1∏nwjj=i∏nf′(aj)hi−1
这里有几点请读者注意:
实际的网络中,所有的变量(包括输入、中间变量和参数)都是矩阵,这样求中间函数的梯度就是求矩阵对矩阵的梯度,非常困难。在上面的例子中,由于变量都是标量,所以中间函数的梯度也是标量,十分简单。这里通过一个数学技巧,避免直接计算中间函数的梯度,达到简化计算的目的,而且更便于理解。
因为损失函数是标量,最终的目的是计算损失函数对参数的梯度,且标量对矩阵参数的梯度是矩阵,所以梯度矩阵的尺寸和矩阵参数的尺寸一致。利用这个性质,能极大地简化梯度计算。任何机器学习模型的损失函数值都是标量,这一定要牢记在心,所以直接求损失函数对各层参数的梯度会十分方便。
网络最后一层输出分值向量(向量是矩阵的特例) S S S 。假设我们得到损失函数 loss 对 S S S 的梯度,该梯度简记为 d S dS dS (注意之后的损失函数对参数的梯度都简记为 d W dW dW,省略重复的 d l o s s dloss dloss ),注意 d S dS dS 和 S S S 的尺寸相同。网络最后两层的模型如下:
再次提醒此时所有变量都是矩阵,已经得到 d S dS dS 。现在从 d S dS dS 开始反向传播,推导其他变量的梯度。
这里的核心是矩阵乘法 A = H W A=HW A=HW ,并且已知 d A dA dA ,求 d H dH dH 和 d W dW dW ,那么:
希望读者记住此公式,该公式比较好记【如果有兴趣,可以自己推一下】,首先把这些变量看成标量,则 d H = W d A , d W = H d A dH=WdA, \ \ dW=HdA dH=WdA, dW=HdA,然后把它们看作矩阵。由于矩阵乘法需要满足矩阵的尺寸一致性, 即尺寸为 m × n m \times n m×n 和尺寸为 n × k n × k n×k 的矩阵乘积为 m × k m × k m×k 的矩阵,两个相乘矩阵必须有个维度相等。可以发现,只有 d H = d A W T dH=dAW^T dH=dAWT 和 d W = H T d A d W=H^TdA dW=HTdA 的形式满足矩阵乘法的性质。
softrnax 损失函数的定义:
p k = − log ( e s k / ∑ j = 1 K e s j ) L i = − log ( p y i ) \begin{array}{l} {p_k} = - \log \left( {{e^{{s_k}}}/\sum\limits_{j = 1}^K {{e^{{s_j}}}} } \right)\\ {L_i} = - \log ({p_{{y_i}}}) \end{array} pk=−log(esk/j=1∑Kesj)Li=−log(pyi)
其中 y i y_i yi 是样本 i i i 的标签,即样本类别的序号, p k p_k pk 是第 k k k 类的概率, s k s_k sk 是第 k k k 类的分值。现在求 ∂ L i / ∂ s k \partial {L_i}/\partial {s_k} ∂Li/∂sk ,利用高数知识,可以很容易求出该梯度:
∂ L i / ∂ s k = p k − I ( y i = k ) \partial {L_i}/\partial {s_k} = {p_k} - I({y_i} = k) ∂Li/∂sk=pk−I(yi=k)
其中, l ( y i = k ) l(y_i = k) l(yi=k) 表示当 k k k 等于标签 y i y_i yi 时,则结果等于 1 , 否则为 0 。这样
我们就得到了 d S dS dS,然后就可以进行6 .4 节的梯度反向传播,获得各层参数的梯度。
式( 6.14)十分简洁,同时具有启发意义
实践中, 一般会对多个样本同时进行处理,代码如下:
import numpy as np
K = 10 # 类别数
N = 128 # 样本数量
scores = np.random.randn(N, K) # 一行一个样本的分值向量
labels = np.random.randint(K, size = N)
exp_scores = np.exp(scores)
exp_scores_sum = np.sum(exp_scores, axis = 1, keepdims=True)
probs = exp_scores/exp_scores_sum
dscores = probs.copy()
dscores[range(N), labels] -= 1 ## 语句1
dscores /= N
随机生成分值矩阵和样本标签,其中分值矩阵的一行是一个样本的分值向量。
然后计算概率矩阵和分值矩阵的梯度,最后取平均。
注意语句①的索引方式,用于获得样本的类别分值。
分值梯度dscores的尺寸和scores 一致, 每行对应一个样本。
全连接层梯度的计算和6.4 节所述很像,只是多了偏置。公式为
import numpy as np
D = 784 # 数据维度
K = 10 # 类别数
N = 128 # 样本数量
X = np.random.randn(N, D) # 一行一个样本
W = 0.01 * np.random.randn(D,K)
b = np.zeros((1,K))
scores = np.dot(X, W) + b
# 反向传播
dscores = np.random.randn(N, K) # 与scores的尺寸一样
dX = np.dot(dscores, W.T)
dW = np.dot(X.T, dscores)
db = np.sum(dscores, axis=0, keepdims=True) ## 1
首先前向计算分值矩阵,同样每一行是一个样本的分值向量。
随机生成分值矩阵的梯度dscores 后,计算 d X dX dX 和 d W dW dW。
需要特别注意语句①中 d b db db 的计算方式, d b db db 中的每个元素是 dscores 每列之和。因为如果不使用NumPy 的广播机制,则 b b b 向量需扩增为矩阵。计算 d b db db 的每个元素,有 N N N 条路径, N N N 条路径之和就是 d b db db 元素的值。
首先,给出一个定义:对于 y = f ( x ) y=f(x) y=f(x) ,那么 d x = d y ⋅ f ′ ( x ) dx = dy \cdot f'(x) dx=dy⋅f′(x)
当激活函数采用ReLU 时,公式为 H o u t = R e L U ( H i n ) H_{out} = ReLU(H_{in}) Hout=ReLU(Hin), 其中 H i n H_{in} Hin 是激活层的输入, H o u t H_{out} Hout 是输出。注意,该公式是逐元素计算的。已知 d H o u t dH_{out} dHout,需要计算 d H i n dH_{in} dHin。 ReLU 的梯度计算特别简单,因为当输入小于0 时,梯度为0 ;当输入大于0 时,梯度为1 。所以 H i n H_{in} Hin 的每个元素只需简单地和 0 比较,如果大于0 , 则输出梯度等于输入梯度,否则为0。代码如下:
import numpy as np
dim1 = 164
dim2 = 128
Hin = np.random.randn(dim1, dim2)
Hout = np.maximum(0, Hin) # ReLU激活
dHout = np.random.randn(dim1, dim2) # 与Hout的尺寸一样
# 反向传播
dHin = dHout
dHin[Hin <= 0] = 0
对于随机生成的输入矩阵 H i n H_{in} Hin 和梯度 d H o u t dH_{out} dHout,先进行 ReLU 激活,输入梯度先赋值为输出梯度, 然后进行梯度反传,即把输入值 H i n H_{in} Hin 小于等于0 的梯度设置为0。该代码十分清晰地展示了梯度反向传播,但是增加了很多中间变量,存储开销比较大。实践中代码可以简化如下:
dim1 = 164
dim2 = 128
hidden_data = np.random.randn(dim1, dim2)
hidden_data = np.maximum(0, hidden_data) # ReLU激活
dhidden_data = np.random.randn(dim1, dim2) # 与hidden_data的尺寸一样
# 反向传播
dhidden_data[hidden_data <= 0] = 0 # ①
由于输入数据和输出数据的尺寸相同,又不需要存储中间结果,所以输入和输出数据可以共享存储空间,使用同一个变量。
与其他激活函数的梯度反向传播程序类似, 只是语句① 有所不同,现在以 ELU 激活函数为例进行介绍,公式为:
其梯度为:
而 e x = f ( x ) + 1 e^x = f(x)+1 ex=f(x)+1,所以语句①的代码为:dhidden_data[hidden_data<=0] *= (hidden_data+1)
卷积层的梯度反向传播比较复杂,本书采用大矩阵乘法来实现。梯度反向传播的计算过程和卷积层的正向计算过程正好相反,是其逆过程,所以本节首先简单回顾一下如何利用大矩阵乘法实现卷积层的正向计算过程。其核心有三步。
(1)将输入特征图变换为大矩阵 matric_data 。
(2)进行矩阵相乘和非线性激活( 等价于全连接层)后得到 filter_ data 。
(3)将filter_ data 变换为输出特征图。
卷积层梯度反向传播是己知输出特征图的梯度,求输入特征图的梯度及卷积核的梯度,其过程如下。
(1)把输出特征图的梯度 d o u t _ d a t a dout\_data dout_data 变换为矩阵形式(正向计算第( 3 ) 步的逆过程)。
(2)将全连接层和激活层的梯度进行反向传播。
(3)把第( 2 )步得到的大矩阵梯度变换为特征图形状的梯度,即得到输入特征图的梯度。
梯度反向传播时,需要正向计算过程得到的 matric_data 和 filter_data ,读者只要理解了正向计算过程,其逆过程很简单,所以这里直接给出代码。若需要示意图,可以参考4.2 . 5 节的图4.4 。
首先,定义一些超参数, 同时随机生成一些必要的数据,这些数据能够在正向计算中获得:
import numpy as np
filter_size = 3
filter_size2 = filter_size*filter_size
stride = 1
padding = (filter_size - 1)//2
batch = 8
(in_height, in_width, in_depth) = (32, 48, 16)
(out_height, out_width, out_depth) = (32, 48, 32)
out_size = out_height*out_width
dout_data = np.random.randn(batch, out_height, out_width, out_depth)
matric_data = np.random.randn( out_size*batch, filter_size2*in_depth )
filter_data = np.random.randn( out_size*batch, out_depth )
weights = 0.01 * np.random.randn(filter_size2*in_depth, out_depth)
其中, d o u t _ d a t a dout\_data dout_data 是上次梯度反向传播得到的,是输入梯度, m a t r i c _ d a t a matric\_data matric_data 、 f i l t e r _ d a t a filter\_data filter_data 和 w e i g h t s weights weights 都是在正向计算中得到的。
然后把 d o u t _ d a t a dout\_data dout_data 变换为矩阵形式 d f i l t e r _ d a t a dfilter\_data dfilter_data , 即尺寸与 f i l t e r _ d a t a filter\_data filter_data 一致。 d o u t _ d a t a dout\_data dout_data 的每个深度列就是 d f i l t e r _ d a t a dfilter\_data dfilter_data 的一行,代码如下:
#first backprop the dout_data to dfilter_data
#one depth of dout_data into one row of dfilter_data
dfilter_data = np.zeros_like(filter_data)
for i_batch in range(batch):
i_batch_size = i_batch*out_size
for i_height in range(out_height):
i_height_size = i_batch_size + i_height*out_width
for i_width in range(out_width):
dfilter_data[i_height_size + i_width, :] = dout_data[i_batch, i_height, i_width, :]
其次,进行激活层和全连接层反向传播,得到权重 d w e i g h t s dweights dweights 、偏置的梯度 d b i a s dbias dbias 以及矩阵形式的梯度 d m a t r i c d a t a dmatric_data dmatricdata:
#backprop the RELU non-linearity
dfilter_data[filter_data <= 0] = 0
#backprop the dot product filter_data = np.dot(matric_data, weights) + bias
dweights = np.dot(matric_data.T, dfilter_data)
dbias = np.sum(dfilter_data, axis=0, keepdims=True)
dmatric_data = np.dot(dfilter_data, weights.T)
最后, 把 d m a t r i c _ d a t a dmatric\_data dmatric_data 变换为特征图形状的梯度, 即得到输入特征图的梯度:
#backprop the dmatric_data to dpadding_data
padding_height = in_height + 2*padding
padding_width = in_width + 2*padding
dpadding_data = np.zeros((batch, padding_height, padding_width, in_depth) )
height_ef = padding_height - filter_size + 1
width_ef = padding_width - filter_size + 1
for i_batch in range(batch):
i_batch_size = i_batch*out_size
for i_h, i_height in zip(range(out_height), range(0, height_ef, stride)):
i_height_size = i_batch_size + i_h*out_width
for i_w, i_width in zip(range(out_width), range(0, width_ef, stride)):
dpadding_data[i_batch, i_height : i_height + filter_size,
i_width : i_width + filter_size, :] += dmatric_data[
i_height_size + i_w, :].reshape(filter_size, filter_size, -1) ## 语句①
#backprop the dpadding_data to din_data
if padding:
din_data = dpadding_data[:,padding:-padding,padding:-padding,:]
else:
din_data = dpadding_data
其中, d m a t r i c _ d a t a dmatric\_data dmatric_data 的每一行数据就是 d p a d d i n g _ d a t a dpadding\_data dpadding_data 的局部窗口的一个数据,注意行向量需要 reshape 成3D 矩阵。特别注意语句① 中的加号,这是因为 d p a d d i n g _ d a t a dpadding\_data dpadding_data 数据有多条路径(即局部窗口有重叠)得到梯度,所以需要累加。
现在有了 d i n _ d a t a din\_data din_data ,又可以向前一层进行梯度反向传播了,这样一层一层传播下去,直到第一层。
最大值池化层梯度反向传播类似于ReLU 激活层梯度反向传播。ReLU 是求 m a x ( 0 , x ) max(0, x) max(0,x) 的偏导数,最大值池化层是求 f = m a x ( a , b , c , d ) f= max(a,b, c, d) f=max(a,b,c,d) 的偏导数,其中 a , b , c a,b,c a,b,c 和 d d d 是输入 2 × 2 2 × 2 2×2 窗口的数据。该函数的偏导数很简单,对 a 、 b 、 c a、b 、c a、b、c 和 d d d 中最大值的梯度为1 ,其他为0 。计算过程如下。
import numpy as np
(batch, in_height, in_width, in_depth) = (8, 32, 48, 16)
filter_size = 2
filter_size2 = filter_size*filter_size
stride = 2
out_height = (in_height - filter_size)//stride + 1
out_width = (in_width - filter_size)//stride + 1
out_depth = in_depth
out_size = out_height*out_width
dout_data = np.random.randn(batch, out_height, out_width, out_depth)
matric_data_max_pos = np.random.randn(out_size*in_depth*batch, filter_size2) # 1
matric_data_max_pos = matric_data_max_pos > 0 # 2
matric_data_not_max_pos = ~matric_data_max_pos # 3
din_data = np.zeros((batch, in_height, in_width, in_depth), dtype = np.float64)
height_ef = in_height - filter_size + 1
width_ef = in_width - filter_size + 1
for i_batch in range(batch):
i_batch_size = i_batch*out_size*in_depth
for i_h_out, i_height in zip(range(out_height), range(0, height_ef, stride)):
i_height_size = i_batch_size + i_h_out*out_width*in_depth
for i_w_dout, i_w, i_width in zip(range(out_width),
range(0, in_depth*out_width, in_depth),
range(0, width_ef, stride)):
md = matric_data_not_max_pos[i_height_size + i_w : i_height_size + i_w + in_depth, : ] # 4
din = din_data[i_batch, i_height : i_height + filter_size,
i_width : i_width + filter_size, :] # 5
dout = dout_data[i_batch, i_h_out, i_w_dout, :] # 6
for i in range(filter_size):
for j in range(filter_size):
din[i, j, :] = dout[:] # 7
din[i, j, :][md[:, i*filter_size + j]] = 0 # 8