在之前的文章中,我介绍了如何用NumPy实现卷积正向传播。
在这篇文章里,我会继续介绍如何用NumPy复现二维卷积的反向传播,并用PyTorch来验证结果的正确性。通过阅读这篇文章,大家不仅能进一步理解卷积的实现原理,更能领悟到一般算子的反向传播实现是怎么推导、编写出来的。
项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN
本文代码在dldemos/BasicCNN/np_conv_backward.py
这个文件里。
实现思路
回忆一下,在正向传播中,我们是这样做卷积运算的:
for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper, :]
kernel_slice = weight[i_c]
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
output[i_h, i_w, i_c] += bias[i_c]
我们遍历输出图像的每一个位置,选择该位置对应的输入图像切片和卷积核,做一遍乘法,再加上bias。
其实,一轮运算写成数学公式的话,就是一个线性函数y=wx+b
。对w, x, b
求导非常简单:
dw_i = x * dy
dx_i = w * dy
db_i = dy
在反向传播中,我们只需要遍历所有这样的线性运算,计算这轮运算对各参数的导数的贡献即可。最后,累加所有的贡献,就能得到各参数的导数。当然,在用代码实现这段逻辑时,可以不用最后再把所有贡献加起来,而是一算出来就加上。
dw += x * dy
dx += w * dy
db += dy
这里要稍微补充一点。在前向传播的实现中,我加入了dilation, groups
这两个参数。为了简化反向传播的实现代码,只展示反向传播中最精华的部分,我在这份卷积实现中没有使用这两个参数。
代码实现
在开始实现反向传播之前,我们先思考一个问题:反向传播的函数应该有哪些参数?从数学上来讲,反向传播和正向传播的参数是相反的。设正向传播的输入是A_prev, W, b
(输入图像、卷积核组、偏差),则应该输出Z
(输出图像)。那么,在反向传播中,应该输入dZ
,输出dA_prev, dW, db
。可是,在写代码时,我们还需要一些其他的输入参数。
我的反向传播函数的函数定义如下:
def conv2d_backward(dZ: np.ndarray, cache: Dict[str, np.ndarray], stride: int,
padding: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""2D Convolution Backward Implemented with NumPy
Args:
dZ: (np.ndarray): The derivative of the output of conv.
cache (Dict[str, np.ndarray]): Record output 'Z', weight 'W', bias 'b'
and input 'A_prev' of forward function.
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.
Outputs:
Tuple[np.ndarray, np.ndarray, np.ndarray]: The derivative of W, b,
A_prev.
"""
虽然我这里把所有参数都写在了一起,但从逻辑上来看,这些参数应该分成三个类别。在编程框架中,这三类参数会储存在不同的地方。
dZ
: 反向传播函数真正的输入。cache
: 正向传播中的一些中间变量Z, W, b
。由于我们必须在一个独立的函数里完成反向传播,这些中间变量得以输入参数的形式供函数访问。stride, padding
: 这两个参数是卷积的属性。如果卷积层是用一个类表示的话,这些参数应该放在类属性里,而不应该放在反向传播的输入里。
给定这三类参数,就足以完成反向传播计算了。下面我来介绍conv2d_backward
的具体实现。
首先,获取cache
中的参数,并且新建储存梯度的张量。
W = cache['W']
b = cache['b']
A_prev = cache['A_prev']
dW = np.zeros(W.shape)
db = np.zeros(b.shape)
dA_prev = np.zeros(A_prev.shape)
_, _, c_i = A_prev.shape
c_o, f, f_2, c_k = W.shape
h_o, w_o, c_o_2 = dZ.shape
assert (f == f_2)
assert (c_i == c_k)
assert (c_o == c_o_2)
之后,为了实现填充操作,我们要把A_prev
和dA_prev
都填充一下。注意,算完了所有梯度后,别忘了要重新把dA_prev
从dA_prev_pad
里抠出来。
A_prev_pad = np.pad(A_prev, [(padding, padding), (padding, padding),
(0, 0)])
dA_prev_pad = np.pad(dA_prev, [(padding, padding), (padding, padding),
(0, 0)])
接下来,就是梯度的计算了。
for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f
input_slice = A_prev_pad[h_lower:h_upper, w_lower:w_upper, :]
# forward
# kernel_slice = W[i_c]
# Z[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
# Z[i_h, i_w, i_c] += b[i_c]
# backward
dW[i_c] += input_slice * dZ[i_h, i_w, i_c]
dA_prev_pad[h_lower:h_upper,
w_lower:w_upper, :] += W[i_c] * dZ[i_h, i_w, i_c]
db[i_c] += dZ[i_h, i_w, i_c]
在算导数时,我们应该对照着正向传播的计算,算出每一条计算对导数的贡献。如前文所述,卷积操作只是一个简单的y=wx+b
,把对应的w, x, b
从变量里正确地取出来并做运算即可。
最后,要把这些导数返回。别忘了把填充后的dA_prev
恢复一下。
if padding