用过 tensorflow 等的都知道,神经网络不同的 Layers 像拼图一样几乎可以随心所欲地拼接。
每一个层是一个对象,可以“独立地”实现前向传播、反向传播、参数更新,神经网络只需要按顺序调用它们就行了。
那么,如果试图使用纯 numpy 写这样的神经网络,应该如何实现呢?
实际上,把每一个层视作独立的对象后,反向传播的逻辑并未变复杂,反而更加清晰。
主要代码
想要实现一个名为Neuro的神经网络类,它有一个名为的 layers 列表。
列表中每一个层(layer)都是一个类对象,能被调用 forward, backward, update 三个函数进行前向传播、反向传播、参数更新。
class Neuro():
def __init__(self,layers):
self.layers = layers
def fit(self,train_x,train_y,epochs,batch_size,learning_rate=0.1):
# initialization
for layer in self.layers:
layer.learning_rate = learning_rate
for epoch in range(epochs):
print('epoch = %d/%d '%(epoch+1,epochs))
# random shuffle
index = np.random.permutation(train_x.shape[0])
for batch_num in range(train_x.shape[0],batch_size-1,-batch_size):
_x = train_x[index[batch_num-batch_size : batch_num] , :]
self.layers[-1].y = train_y[index[batch_num-batch_size : batch_num] , :]
#forward
for layer in self.layers:
_x = layer.forward(_x)
#backward
_derivative = None
for layer in self.layers[::-1]:
_derivative = layer.backward(_derivative)
#update
for layer in self.layers:
layer.update()
def predict(self,_x):
for layer in self.layers:
_x = layer.forward(_x)
return _x
以下是全连接层的一个框架模板:
class Dense():
# Dense Layer
def __init__(self,height=1,width=1,act_type=1,learning_rate=0.1):
# self.w , self.b = ... ,...
def forward(self,_x):
# return f(_x * self.w + self.b)
def backward(self,_derivative):
# return g(_derivative)
def update(self):
# self.w -= ... , self.b -= ...
前向传播
前向传播的每一层就像一个加工厂,将输进去的数据计算加工,把答案输出给下一层:
前向传播中,每一层根据输入计算输出,将其传给后一层。
注意!对于每一层,还需要用变量记住该层的输入,这是为了给反向传播做铺垫。
每一层可能是全连接,池化,卷积或者是reshape,但不管怎样都是将处理后的数据继续往下传递。
拿一个全连接层作为一个对象举例,由上一层传入 _ x \_x _x, 计算出下一层的数据(乘上权重 w w w ,加上转置 b b b ,并通过激活函数):
def forward(self,_x):
# b should be broadcast to shape ( batch_size , width )
# (automatically broadcast by numpy)
self.x = _x
self.z = np.dot(_x,self.w) + self.b
return self.activator.function()(self.z)
这里用了 self.x 来记录该层的输入
反向传播
先上思考得出的结论:看似复杂的反向传播,千言万语汇成一句话:
反向传播中,每一层计算损失函数关于该层输入的梯度,将其传给前一层。
这句话无论对于全连接层,还是输出层、池化层、卷积层、Flatten层……都直接套用雷打不动,不过为了方便,先拿全连接层举个例子。
以上面的流程图为例,假设第二层是全连接层。即,假设在前向传播中,第一层给第二层输入了数据 x 1 x_1 x1 , 第二层对此加工给输出层输出了 x 2 x_2 x2 。
其中 ,
x
2
=
f
(
x
1
w
+
b
)
x_2 = f(x_1w+b)
x2=f(x1w+b),
f
\ f
f为激活函数,
w
,
b
\ w,b
w,b为权重和偏置,
L
L
L为最终的损失函数。
x
1
,
x
2
x_1,x_2
x1,x2 均为行向量 或 batch_size 个行向量构成的矩阵。
现在到了反向传播的时间,输出层把
∂
L
∂
x
2
\frac{\partial L}{\partial x_2}
∂x2∂L 传给了第二层。
(因为对于输出层,它的输入是来自于第二层的
x
2
x_2
x2)
那么,第二层应该把
∂
L
∂
x
1
\frac{\partial L}{\partial x_1}
∂x1∂L 传给第一层。
至于如何计算,即已知 x 1 , ∂ L ∂ x 2 x_1,\frac{\partial L}{\partial x_2} x1,∂x2∂L 以及 x 2 = f ( x 1 w + b ) x_2 = f(x_1w+b) x2=f(x1w+b),求 ∂ L ∂ x 1 \frac{\partial L}{\partial x_1} ∂x1∂L:
很自然地,先把激活函数去掉,令
z
=
x
1
w
+
b
z=x_1w+b
z=x1w+b
∂
L
∂
z
=
∂
L
∂
x
2
∂
x
2
∂
z
=
∂
L
∂
x
2
⊙
f
′
(
z
)
\frac{\partial L}{\partial z}=\frac{\partial L}{\partial x_2}\frac{\partial x_2}{\partial z}=\frac{\partial L}{\partial x_2}\odot f'(z)
∂z∂L=∂x2∂L∂z∂x2=∂x2∂L⊙f′(z)
所以,
∂
L
∂
x
1
=
∂
L
∂
z
∂
z
∂
x
1
=
∂
L
∂
z
w
T
(
1
)
\frac{\partial L}{\partial x_1}=\frac{\partial L}{\partial z}\frac{\partial z}{\partial x_1}=\frac{\partial L}{\partial z}w^T \quad (1)
∂x1∂L=∂z∂L∂x1∂z=∂z∂LwT(1)
参数更新
经过前向传播,每一层都知道了该层的输入
x
x
x,以及该层对下一层的输出(也就是下一层的输入),记作
x
∗
x^*
x∗。
经过反向传播,每一层都得到了下一层传来的
∂
L
∂
x
∗
\frac{\partial L}{\partial x^*}
∂x∗∂L。
那么最后一步,便是用
x
x
x 和
∂
L
∂
x
∗
\frac{\partial L}{\partial x^*}
∂x∗∂L 计算损失函数关于该层参数的微分。
还是以全连接为例,已知了
x
x
x 和
∂
L
∂
x
∗
\frac{\partial L}{\partial x^*}
∂x∗∂L ,以及输出与输入的关系
x
∗
=
f
(
x
w
+
b
)
x^*=f(xw+b)
x∗=f(xw+b)
需要求出
∂
L
∂
w
,
∂
L
∂
b
\frac{\partial L}{\partial w},\frac{\partial L}{\partial b}
∂w∂L,∂b∂L。
类似地,第一步当然是把激活函数去掉,假设
z
=
x
w
+
b
z=xw+b
z=xw+b, 则可求得
∂
L
∂
z
=
∂
L
∂
x
∗
∂
x
∗
∂
z
=
∂
L
∂
x
∗
⊙
f
′
(
z
)
\frac{\partial L}{\partial z}=\frac{\partial L}{\partial x^*}\frac{\partial x^*}{\partial z}=\frac{\partial L}{\partial x^*}\odot f'(z)
∂z∂L=∂x∗∂L∂z∂x∗=∂x∗∂L⊙f′(z)
第二步——也是最后一步,用
∂
L
∂
z
\frac{\partial L}{\partial z}
∂z∂L计算
∂
L
∂
w
,
∂
L
∂
b
\frac{\partial L}{\partial w},\frac{\partial L}{\partial b}
∂w∂L,∂b∂L:
由于
z
=
x
w
+
b
z=xw+b
z=xw+b, 故(注意确保一下矩阵乘法维数对齐了)
∂
L
∂
b
=
∂
L
∂
z
(
2
)
∂
L
∂
w
=
∂
z
∂
w
∂
L
∂
z
=
x
T
∂
L
∂
z
(
3
)
\frac{\partial L}{\partial b}=\frac{\partial L}{\partial z}\quad (2) \\\ \\\ \frac{\partial L}{\partial w}=\frac{\partial z}{\partial w}\frac{\partial L}{\partial z}=x^T\frac{\partial L}{\partial z}\quad (3)
∂b∂L=∂z∂L(2) ∂w∂L=∂w∂z∂z∂L=xT∂z∂L(3)
参数更新即让 w , b w,b w,b 分别减去 学习速率*梯度。大功告成。
现在也就能顺便回答为什么要有误差项的问题了,原因极其朴素——
(
1
)
(
2
)
(
3
)
(1)(2)(3)
(1)(2)(3)式都用到了
∂
L
∂
z
\frac{\partial L}{\partial z}
∂z∂L,算一次用三次,省时省力,所以记误差项
δ
=
∂
L
∂
z
\delta = \frac{\partial L}{\partial z}
δ=∂z∂L
并非奇思妙想抑或神来之笔,只不过是减少运算,仅此而已。
全连接层反向传播与参数更新的代码:
def backward(self,_derivative):
# _derivative is an array with shape ( batch_size , width )
# it is dLoss / dX(i+1)
self.error = self.activator.derivative()(self.z) * _derivative
return np.dot( self.error ,self.w.transpose() )
def update(self):
self.w -= np.dot(self.x.transpose() , self.error) * self.learning_rate
self.b -= np.sum(self.error,axis=0) * self.learning_rate