前言
本文是手写BP神经网络的第三篇文章,也是接续上一篇文章,细致地介绍如何完成BP神经网络这一程序。在上一篇文章之中,作者搭好了一个深度学习框架:如果读者朋友们将那几个具体的计算的函数当做一个实现功能的黑盒,那么在上一篇文章中已经清楚网络是怎么训练,推理,优化的。具体的链接见下:
手写感知器的反向传播算法 Part2:基于理论推导的代码实践:如何实现自己的第一个神经网络完成手写体识别?(上)-CSDN博客
那么在这一文章之中,作者将把上一篇文章中的黑盒子打开,具体介绍运算部分的代码是什么样的。而这个部分主要就是“BP_foward_for_classification”类。
Part0:准备阶段
1.copy函数:
这个部分作者写了一个用来复制矩阵的函数。其目的是为了将一个矩阵或者一个列向量保持形状地复制到另一个矩阵之中。代码如下:
def copy(tuple1, tuple2):
if tuple2.ndim != 1:
x = np.size(tuple2, 0)
y = np.size(tuple2, 1)
if tuple1.ndim!=1:
for i in range(x):
for j in range(y):
tuple1[i][j] = tuple2[i][j]
else:
if np.size(tuple2,1) == 1:
for i in range(x):
tuple1[i] = tuple2[i][0]
else:
raise RuntimeError("problem arouse")
# for i in range(x):
# tuple1[i] = tuple2[i][0]
# 这里只能是列向量
elif tuple2.ndim == 1 and tuple1.ndim == 1:
x = np.size(tuple2, 0)
for i in range(x):
tuple1[i] = tuple2[i]
else:
x = np.size(tuple2, 0)
for i in range(x):
tuple1[i][0] = tuple2[i]
保存原有形状地copy和保存是在整个程序中相当重要的一个特性。
2.ReLU函数
def relu(x):
t = np.size(x, 0)
for i in range(t):
x[i] = 0 if x[i]<=0 else x[i]
return x
Part1:“BP_foward_for_classification”类
1.构造函数
先给出代码:
def __init__(self, layer_num, neuron_sit, learning_rate, last_weight = None, last_bias = None):
self.layer_num = layer_num
self.neuron_sit = neuron_sit
self.weight_para = np.zeros((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1), dtype=float)
# 首先,按照输入的最大的神经元个数构建存放权重的矩阵。其中前两维为存放矩阵系数,第三维是存放不同的层数的结果
self.bias_para = np.zeros((np.max(neuron_sit), layer_num + 1), dtype=float)
# 其次,对于偏置矩阵来说只要构建二维矩阵即可。
self.loss_matrix = np.zeros((np.max(neuron_sit), layer_num + 1), dtype=float)
self.weight_grad = np.zeros((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1), dtype=float)
self.bias_grad = np.zeros((np.max(neuron_sit), layer_num + 1), dtype=float)
self.add_weight_grad = np.zeros((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1), dtype=float)
self.add_bias_grad = np.zeros((np.max(neuron_sit), layer_num + 1), dtype=float)
self.train_state = True
self.lr = learning_rate
self.minibatch_num = 20
self.initialize(last_weight,last_bias)
这里给出所有的成员变量的意义:
(1)layer_num,neuron_sit以及lr是从main函数中读出来的超参数
(2)weight_para和bias_para分别是用来存放每一层的参数。由于每一层的参数矩阵大小不同,所以这里直接按照max(neuron_sit)为长宽,层数作为高构建三维矩阵。读者可以将其想象成一个之余水平桌面上的一个立方体。那么每一层的参数保存在其对应的水平层中,而且为了便于观察运算结果是否正确,下面读者将看到,作者设计了一个copy函数确保除了矩阵之外其余位置都置0。这样的好处就是可以从debug中轻松的看出后续的复杂操作有没有维度上的错误:如果确实有这样的错误,那么在中间我们会发现一些本该是0的部分现在非零,从而发现错误。
(3)weight_para,bias_para,loss_matrix:这三个成员变量主要负责的是反向传播的部分。这里的loss矩阵在前面两篇文章作者都详细地提到了,这里就不多赘述了。由于Loss矩阵起到的是一个传递的作用,所以这里只保存这一矩阵,通过每一层的loss值经过简单的计算就可以得到前面这两个参数。
2:initialize函数
def initialize(self):
# initial_weight = 1 * np.random.random((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1))-0.5*np.random.random((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1))
# initial_bias = -1 * np.random.random((np.max(neuron_sit), layer_num + 1))
for i in range(layer_num + 1):
l = self.neuron_sit[i]
w = self.neuron_sit[i + 1]
# initial_weight = np.random.normal(0, 1 / neuron_sit[i+1], (w, l))
# kaiming Normalization,模式是fan_out,由于使用的是sigmoid,所以这里使用的是1
copy(self.weight_para[:, :, i], np.random.random((w,l)))
copy(self.bias_para[:, i], np.random.random(w))
# 每一个层只有需要的参数才非0,,这样安排便于检查
这个函数的目的是对于模型进行参数初始化。作者这里使用的是随机初始化的参数。到这里,对于模型的初始化就结束了。下面将开始对于训练部分的介绍。
3:forward函数:
def forward(self, x_train, neuron_sit, batch_num=0):
# 这里的batch_num的意思是现在是处于第几个batch
count = -1
# 遍历x中的所有组成元素
if self.train_state:
x = x_train
start = batch_num * self.minibatch_num
end = (batch_num + 1) * self.minibatch_num
# 得到在这个batch的情况下在x_train中的对应的列序号
for index in range(start, end):
count += 1
# 样本经历了第count个
item = x[:, index]
# 保存第一列的元素作为第一次计算的开头
copy(self.train_result[:, 0, count], item)
# 第一列直接保存x的结果
for i in range(self.layer_num):
# 1.从上次的运算结果处获得这次的运算基础X
X = self.train_result[:neuron_sit[i], i, count]
# 2.用矩阵乘法处理X Y=KX+B
mid_Y = np.dot(self.weight_para[:self.neuron_sit[i + 1], :self.neuron_sit[i], i],
X) + self.bias_para[
:self.neuron_sit[
i + 1], i]
# 将点乘后的计算结果通过sigmoid函数激活
final_Y = relu(mid_Y)
i = i + 1
copy(self.train_result[:, i, count], final_Y)
# 最后一层之外的每一个计算结果都是上一层的结果经过一层线性层和一层sigmoid激活层得到的结果
# ,并且保存到储存矩阵的下个层中用于下次的推理。
i = self.layer_num
# 单独拿出最后一层进行计算
X = self.train_result[:neuron_sit[i], i, count]
# 2.用矩阵乘法处理X Y=KX+B
mid_Y = np.dot(self.weight_para[:self.neuron_sit[i + 1], :self.neuron_sit[i], i], X) + self.bias_para[
:self.neuron_sit[
i + 1], i]
final_Y = softmax_for_column(mid_Y)
# 这里必须要进行softmax,后面反向传播的时候才是one-hot向量减掉这一层的运算结果。
i = i + 1
copy(self.train_result[:, i, count], final_Y)
# 考虑到整个模型最后一步似乎还是不需要sigmoid:如果使用了sigmoid函数会导致本来的比较大的差距被抹去,这对于softmax来说效果很差
# print("forward once is done")
else:
x = x_train
num = x_train.shape[1]
for index in range(num):
count += 1
item = x[:, index]
# 保存第一列的元素作为第一次计算的开头
copy(self.test_result[:, 0, count], item)
for i in range(self.layer_num):
# 1.从上次的运算结果处获得这次的运算基础X
X = self.test_result[:neuron_sit[i], i, count]
# 2.用矩阵乘法处理X Y=KX+B
mid_Y = np.dot(self.weight_para[:self.neuron_sit[i + 1], :self.neuron_sit[i], i],
X) + self.bias_para[
:self.neuron_sit[
i + 1], i]
# 将点乘后的计算结果通过sigmoid函数激活
final_Y = relu(mid_Y)
i = i + 1
copy(self.test_result[:, i, count], final_Y)
i = self.layer_num
X = self.test_result[:neuron_sit[i], i, count]
# 2.用矩阵乘法处理X Y=KX+B
mid_Y = np.dot(self.weight_para[:self.neuron_sit[i + 1], :self.neuron_sit[i], i], X) + self.bias_para[
:self.neuron_sit[
i + 1], i]
final_Y = softmax_for_column(mid_Y)
i = i + 1
copy(self.test_result[:, i, count], final_Y)
具体的解析文字都已经以注释的形式写在了代码之中,读者可以根据这些注释阅读明白这是在做什么。总而言之,其实就是计算出每一层的结果,先保存到运算结果矩阵的层中,然后再读出下一层的weight参数和bias参数,做点积运算,激活后保存到下一层预算结果矩阵中。
4:calc_loss函数
这个部分之前也有提到过,计算一个loss矩阵,后续利用这个loss矩阵可以计算出权重和偏置的梯度。那么这里给出一段详尽的代码:
def calc_loss(self, index, y):
neuron_sit = self.neuron_sit
layer_num = self.layer_num + 2
# 这里的层数量调整为包括一开始的输入层和最后的输出层
weight_para = self.weight_para
y = y[:, index]
# 现在的layer_num考虑了第一层输入层和第二层输出层。在这个函数的语境之下,层数是整个模型的总层数
loss_matrix = self.loss_matrix
train_result = self.train_result
for i in reversed(range(layer_num - 1)):
if i == layer_num - 2:
output = train_result[:neuron_sit[i + 1], i + 1, index]
copy(loss_matrix[:neuron_sit[i + 1], i], -(y - output))
# 对于最后一层的结果需要单独拿出来,其余的合起来完成即可
else:
last_loss = loss_matrix[:neuron_sit[i + 2], i + 1]
w_iplus1 = weight_para[:neuron_sit[i + 2], :neuron_sit[i + 1], i + 1]
z_i = train_result[:neuron_sit[i + 1], i + 1, index]
copy(loss_matrix[:, i], np.multiply(np.dot(w_iplus1.T, last_loss), (derelu(z_i))))
# 至此,所有的partial_loss/partial_M_i都已经放在了表格之中。其中M_i是经历过sigmoid函数之后的结果,是直接输入下一层作为X的内容
关于这个部分的中间的计算过程,如果有看着不太明白的请移步作者本专栏的第一篇文章。在那里,作者在最后介绍了应该怎么从数学上计算出对应的矩阵。这里作者仅贴出一张图片:
5:calc_delta_weight函数:
def calc_delta_weight(self, index):
neuron_sit = self.neuron_sit
layer_num = self.layer_num + 2
train_result = self.train_result
# 现在的layer_num考虑了第一层输入层和第二层输出层。在这个函数的语境之下,层数是整个模型的总层数
loss_matrix = self.loss_matrix
grad_weight = self.weight_grad
for i in reversed(range(layer_num - 1)):
z_i = train_result[:neuron_sit[i], i, index]
z_i = z_i.reshape(-1, 1)
loss = loss_matrix[:neuron_sit[i + 1], i]
loss = loss.reshape(-1, 1)
delta = np.dot(loss, z_i.T)
# 计算方式:loss矩阵点乘列向量z的转置
copy(grad_weight[:, :, i], delta)
self.add_weight_grad += grad_weight
# 累计梯度加到梯度矩阵之中。
这里的计算就是基于前面的loss_matrix设计的内容。运算公式同样可以参考前面的部分。
需要注意的一点是,为什么这里是加到add_weight_grad矩阵之中呢?因为我们是一个batch一次反向传播,所以后续需要对于所有的梯度做一个平均。这里先求和方便后续做平均。
6:calc_delta_bias函数:
def calc_delta_bias(self):
neuron_sit = self.neuron_sit
layer_num = self.layer_num + 2
train_result = self.train_result
# 现在的layer_num考虑了第一层输入层和最后一层输出层。在这个函数的语境之下,层数是整个模型的总层数
loss_matrix = self.loss_matrix
grad_bias = self.bias_grad
for i in reversed(range(layer_num - 1)):
delta = loss_matrix[:neuron_sit[i + 1], i]
# 这里直接就是loss矩阵的结果
copy(grad_bias[:, i], delta)
self.add_bias_grad += grad_bias
delta_bias实际上就是loss矩阵(推导同样看本系列第一篇文章)
7:backward函数:
def backward(self):
# 根据学习率减小权重矩阵和偏置矩阵
lr = self.lr
batch_num = self.minibatch_num
add_weight_grad = self.add_weight_grad
add_bias_grad = self.add_bias_grad
self.weight_para -= lr * (1 / batch_num) * add_weight_grad
self.add_weight_grad = np.zeros((np.max(neuron_sit), np.max(neuron_sit), layer_num + 1), dtype=float)
self.bias_para -= lr * (1 / batch_num) * add_bias_grad
self.add_bias_grad = np.zeros((np.max(neuron_sit), layer_num + 1), dtype=float)
这段代码的主要操作是将权重和偏置矩阵分别减去梯度和学习率的乘积,也就是完成了最朴素的反向传播操作。操作结束之后对于add_weight_grad等两个矩阵清零防止影响后续结果。
8:alter_lr函数:
def alter_lr(self, epoch):
if epoch < 10:
self.lr = self.lr * 0.95
这个部分考虑的问题是随着训练到后续可能需要学习率小些以免错过最小值,所以考虑模仿着adam逐渐完成对于学习率的调整。这个部分实际上作者是没有使用上的,但是作为一个组件先提供在这里可以供读者使用。
总结:
至此,整个反向传播的操作都已经完成。根据作者的实际体验这样的神经网络是可以运行的,而且实践效果是可以在比较困难的数据集上达到88.3的正确率的。但是这个任务还远远没有完成。作为教学,作者会在下篇文章中更新出:在写这个任务时,怎么做debug,又怎么使用当今的pytorch框架“对答案”。关于这个project,作者在上下两篇中已经将所有的技术细节全部介绍清楚了。坐着希望的是看了作者的教程之后,各位初学者能学会怎么做,而不是只复制黏贴代码,于是这里作者没有给出整合的代码。关于整合后的代码,作者也同样会发一个资源,但是会设置收费。通过这两篇博客学会的朋友给个免费的赞当酬劳就好~
都看到这里了不点个赞嘛awa