参考博客:
卷积神经网络反向传播理论推导https://blog.csdn.net/Hearthougan/article/details/72910223
【python】如何用 numpy 实现https://blog.csdn.net/qq_36393962/article/details/99354969
目录
一、最开始的说明(很重要)
按照大部分公式的约定,C表示代价,z 表示网络层输出,a 表示激活函数值,w 为权重,b 为偏置。
如果没有特别说明,卷积指的是"valid"卷积,激活层为sigmoid。
其实,神经网络中的每一层都相当于一个函数f,将“前层”的输出x映射到y,也就是“后层”输出为y=f(x)。不断地利用“前层”的输出带入到“后层”的函数中,求出“后层”的输出!这就是前向传播!
神经网络的最终层输出是代价函数C,C是一个多维函数,可以对网络中任何一个变量求偏导,比如对每一层的y求偏导得到
为什么要对每一层的输出y求偏导呢?因为这样就可以利用上“多维函数求偏导的链式法则”了,大概就是说可以从最终层的偏导开始,不断地利用“后层”的偏导计算“前层”的偏导,利用每一层的偏导可以进而求出每一层中权重w和偏置b的偏导!有了权重w和偏置b的偏导就可以更新权重w和偏置b啦!这就是反向传播!
一点点领悟,后面看例子就会明白的!
完整代码链接:https://download.csdn.net/download/jin739738709/11831051
二、激活层
正向公式:
反向公式:
实现代码:
class FuncLayer(Layer):
def __init__(self, activate_fn: Func):
self.f = activate_fn
self.z: np.ndarray = None
def __call__(self, x: np.ndarray) -> np.ndarray:
self.z = x
return self.f(x)
def backward(self, dc_da: np.ndarray) -> np.ndarray:
da_dz = self.f.derivate(self.z)
if self.f.jacobin:
# 如果求导结果只能表示成雅克比矩阵,得使用矩阵乘法
dc_dz = dc_da.dot(da_dz.T)
else:
# 求导结果为对角矩阵,可以采用哈达马积(逐值相乘)来简化运算
dc_dz = dc_da * da_dz
return dc_dz
三、全连接层
class FullConnectedLayer(Layer):
def __init__(self, input_size, output_size):
self.i_size = input_size
self.o_size = output_size
if self.i_size is not None:
self.__init(self.i_size)
def __init(self, input_size):
self.i_size = input_size
self.w = np.random.normal(loc=0.0, scale=1.0, size=(self.i_size, self.o_size))
self.b = np.random.normal(loc=0.0, scale=1.0, size=(1, self.o_size))
self.x: np.ndarray = None # input
def __call__(self, x: np.ndarray) -> np.ndarray:
x = x.reshape(1, -1)
# 如果 self.i_size 还没有确定,则根据x.shape来初始化
if self.i_size is None:
self.__init(x.shape[1])
self.x = x
self.z = x.dot(self.w)+self.b
return self.z
def backward(self, dc_dz: np.ndarray) -> np.ndarray:
dc_dx = dc_dz.dot(self.w.T)
self.w += self.x.T.dot(dc_dz)
self.b += dc_dz
return dc_dx
四、池化层
实现代码:
class MeanPoolingLayer(Layer):
def __init__(self, kernel_size: int, stride: int):
self.ks = kernel_size
self.kernel_shape = (kernel_size, kernel_size)
self.channels: int = None
self.stride = stride
self.input_shape: tuple = None # row_cnt,col_cnt,channels
self.target_shape: tuple = None # 目标的shape
def __call__(self, mat: np.ndarray) -> np.ndarray:
self.input_shape = mat.shape
self.channels = mat.shape[2]
row, col = mat.shape[0], mat.shape[1]
(kr, kc), s = self.kernel_shape, self.stride
self.target_shape = ((row-kr)//s+1, (col-kc)//s+1, self.channels)
target = np.zeros(self.target_shape)
for i in range(self.target_shape[0]):
for j in range(self.target_shape[1]):
r, c = i*s, j*s
target[i, j] = np.average(mat[r:r+kr, c:c+kc], axis=(0, 1))
return target
def backward(self, d_out: np.ndarray) -> np.ndarray:
d_input = np.zeros(self.input_shape)
n = self.kernel_shape[0]*self.kernel_shape[1]
d_mat = d_out/n # mean-pooling 求导后恰好是 1/n
(kr, kc), s = self.kernel_shape, self.stride
for i in range(self.target_shape[0]):
for j in range(self.target_shape[1]):
r, c = i*s, j*s
d_input[r:r+kr, c:c+kc] += d_mat[i, j]
return d_input
五、爱因斯坦求和约定(numpy::einsum)
参考博客:
NumPy中einsum的基本介绍http://www.atyun.com/32288.html
在说明卷积层和卷积层的实现代码之前,必须说一下numpy的一个强大矩阵运算工具einsum。
由于其强大的表现力和智能循环,它在速度和内存效率方面通常可以超越我们常见的array函数。
但缺点是,可能需要一段时间才能理解符号,有时需要尝试才能将其正确的应用于棘手的问题。
不过缺点也不是缺点,举个例子大家就会明白的,其实也没有多难理解!
import numpy as np
mm = np.array(
[
[[1,2], [1,2]],
[[1,2], [1,2]]
]
)
print(mm,mm.shape)
nn = np.array(
[
[[1,2,3], [1,2,0]],
[[1,2,3], [1,2,3]]
]
)
print(nn,nn.shape)
print(np.einsum("ijk,ijl->kl",mm,nn,dtype=np.float64))
#结果:
#[[ 4. 8. 9.]
# [ 8. 16. 18.]]
print(np.einsum("ijk,ijl->l",mm,nn,dtype=np.float64))
# 结果: [12 24 27]
代码中的矩阵mm和矩阵nn可以由上图表示,
比如说,对于矩阵nn,当i=0,j=0,l=2,则nn[0,0,2]==3;当i=0,j=1,l=2,则nn[0,1,2]==0
所以,当执行下列语句时,
np.einsum("ijk,ijl->kl",mm,nn,dtype=np.float64)
产生结果如图所示
这个结果是怎么产生的呢?比如说当k=0,l=2
矩阵mm中元素mm[:,:,0]和矩阵nn中元素nn[:,:,2],按照对应位置元素相乘,然后把四个相乘的结果相加,也就是1*3+1*0+1*3+1*3=9
所以,当执行下列语句时,
np.einsum("ijk,ijl->l",mm,nn,dtype=np.float64)
产生结果如图所示
式子中"ijk,ijl->l",之前的k被省略了,输出中省略的字母意味着沿该轴的值将相加,如下图所示
六、卷积层
class ConvolutionLayer(Layer):
def __init__(self, in_channels, out_channels, kernel_size, stride):
self.in_channels = in_channels
self.out_channels = out_channels
self.ks = kernel_size
self.kernel_shape = (kernel_size, kernel_size)
self.stride = stride
self.x: np.ndarray = None # input
# 卷积核: row,col,channel 顺序
self.kernel = np.random.normal(loc=0.0, scale=1.0, size=(kernel_size, kernel_size, out_channels))
self.bias = np.random.normal(loc=0.0, scale=1.0, size=(out_channels,))
# self.kernel =np.ones((kernel_size,kernel_size,out_channels))
# self.bias = np.ones((out_channels,))
def check_x_mat_shape(self, x_mat):
'''
要求卷积核在卷积过程中可以把矩阵铺满(stride空隙不算)
右侧(下侧)不能有多余的列(行)
如 28x28 不能用(5x5,stride=2)的卷积核,因为它只能覆盖(27x27)
'''
row, col = x_mat.shape[0], x_mat.shape[1]
k, s = self.ks, self.stride
assert (row-k)//s*s+k == row
assert (col-k)//s*s+k == col
def __call__(self, x_mat: np.ndarray) -> np.ndarray:
self.check_x_mat_shape(x_mat)
self.x = x_mat
return self.__conv(
stride=self.stride,
mat=x_mat,
kernel=self.kernel,
bias=None)#bias=self.bias
def backward(self, dc_dz: np.ndarray) -> np.ndarray:
# 反向卷积的目标是dc_dz补0之后的矩阵(张量)
# (padding + dilation)
# 补0规则为:边缘padding kernel_size-1 层0;间隔处补 stride-1 层0
# 只看横向,如果dc_dz有c列,那该矩阵有 2kernel_size+(m-1)stride-1 列
# 反向卷积的stride固定为1
(kr, kc, ch), s = self.kernel.shape, self.stride
dc_dz_with_zeros_shape = (
2*kr + (dc_dz.shape[0]-1)*s-1,
2*kc + (dc_dz.shape[1]-1)*s-1,
dc_dz.shape[2]
)
D = np.zeros(dc_dz_with_zeros_shape) # 为了简化,用D表示补充0之后的张量
for i in range(dc_dz.shape[0]):
for j in range(dc_dz.shape[1]):
D[kr+i*s-1, kc+j*s-1] = dc_dz[i, j]
# 求 dc_da(a指的是该层的输入self.x,因为习惯上称呼上一层的激活值为a[l-1])
# 注意stride(步长)是1,而且是每个通道卷积核分别对对应通道mat卷积,最后加和到一个通道
# 卷积结果 dc_da_map 要加到 dc_da 结果的每一层
# (仔细在草稿纸上计算不难证明这点)
dc_da_map = self.__conv(
stride=1,
mat=D,
kernel=self.kernel[::-1, ::-1], # 注意不能漏了反向传播中卷积核的180度旋转 rot180(w)
bias=None,
einsum_formula="ijk,ijk->",
out_channels=1)
dc_da = np.repeat(dc_da_map.reshape((dc_da_map.shape[0], -1, 1)), self.in_channels, axis=2)
# 求 dc_dw(即dc_d kernel)
# 也是卷积,只不过是用 rot180(a_input) 对 D 卷积
dc_dw = self.__conv(
stride=1,
mat=D,
kernel=self.x[::-1, ::-1],
bias=None,
einsum_formula="ijk,ijl->k",
out_channels=self.kernel.shape[2])
# 求 dc_db
dc_db = np.einsum("ijk->k", dc_dz)
# 更新w(kernel)和b(bias),并返回 dc_da
self.kernel += dc_dw
self.bias += dc_db
return dc_da
def __conv(self,
stride: int,
mat: np.ndarray,
kernel: np.ndarray,
bias: np.ndarray = None,
einsum_formula: str = "ijk,ijl->l",
out_channels: int = None):
'''
注意 out_channels 要与 einsum_formula 相对应
'''
# 卷积运算 sub_np_tensor * kernel_np_tensor + bias
if bias is None:
def f(m): return np.einsum(
einsum_formula, m, kernel)
else:
def f(m): return np.einsum(einsum_formula, m, kernel) + bias
row, col = mat.shape[0], mat.shape[1]
(kr, kc, ch), s = kernel.shape, stride
# out_channels 默认为 kernel.shape[2]
out_channels = ch if out_channels is None else out_channels
target_shape = ((row-kr)//s+1, (col-kc)//s+1, out_channels)
target = np.zeros(target_shape)
for i in range(target_shape[0]):
for j in range(target_shape[1]):
r, c = i*s, j*s
target[i, j] = f(mat[r:r+kr, c:c+kc])
return target
七、举个例子
下图为例子的整个网络模型,图中展示了网络的前向传播、反向传播以及参数更新。全部公式严格按照前面几章的内容进行推导。
测试代码:
a = np.array(
[
[[1, 1, 3], [2, 2, 3], [3, 3, 5], [4, 4, 5], [3, 3, 5], [4, 4, 5]],
[[0, 0, 3], [1, 1, 3], [0, 0, 5], [1, 1, 5], [3, 3, 5], [4, 4, 5]],
[[5, 5, 3], [0, 0, 3], [9, 9, 5], [1, 1, 5], [3, 3, 5], [4, 4, 5]],
[[5, 5, 3], [0, 0, 3], [9, 9, 5], [1, 1, 5], [3, 3, 5], [4, 4, 5]],
[[5, 5, 3], [0, 0, 3], [9, 9, 5], [1, 1, 5], [3, 3, 5], [4, 4, 5]],
[[6, 6, 3], [3, 3, 3], [7, 7, 5], [1, 1, 5], [3, 3, 5], [4, 4, 5]]
]
)
label = np.array([[1, 0]])
from funcs import sigmoid
from lossfuncs import sse
from nn import NN
# conv = ConvolutionLayer(2,1,2,1)
my_nn = NN((6, 6, 3), (1, 2))
my_nn.set_layers([
ConvolutionLayer(3, 6, 1, 1),#卷积层
FuncLayer(sigmoid),#sigmoid激活层
MeanPoolingLayer(2,2),#平均池化层
FuncLayer(sigmoid),#sigmoid激活层
ConvolutionLayer(6, 1, 2, 1),#卷积层
FuncLayer(sigmoid),#sigmoid激活层
ReshapeLayer(None,(1,-1)),#reshape
FullConnectedLayer(None, 2),#全连接层
FuncLayer(sigmoid),#sigmoid激活层
])
y1 = my_nn.forward(a)
for i in range(20000):
my_nn.train(a, label, sse, 0.1)
y2 = my_nn.forward(a)
print("训练前:",y1) # 训练前: [[0.54831853 0.54148219]]
print("训练后:",y2) # 训练后: [[0.99493565 0.00505818]]
print("答案:",label) # 答案: [[1 0]]