这份代码是笔者为了巩固神经网络当中反向传播而写的,同时也巩固了一些对于细节的理解,当然由于笔者水平有限,搭建的仅仅是序列模型,而且有些地方没想清楚就开始写了,导致有点冗长,甚至有些地方还有一些bug或者错误,仅用于学习,代码放在https://github.com/Spock343/deeplearning_stepbystep
由于模型不怎么样,则仅用于做简单的分类(自己生成数据集)
在这个模型中,我将每层得到的结果放在该模型的caches中,将对于该层caches的导数放在模型的dcaches中,将对于权重,偏置和卷积核以及相应的导数放在层当中。这里总共写了四种层,卷积层,池化层,扁平化层和全连接层,由于维度的原因,这里将矩阵的二级指针当中,即caches[l][0][0](l为层数)当中。
首先其中大部分操作都是根据理论直接写的,没什么特别的,这里说一些遇到的困难
在一开始是还没写dropout正则化时,过拟合现象及其严重传统的L2正则化几乎不起效果,在加入dropout之后才稍微解决。
然后是发现训练速度很慢,然后写入Adam优化器之后训练速度很快就上来了。
最后是梯度过大的问题,无论是加了梯度衰减还是,调整学习率都效果不好,但是后来加了梯度裁剪后,才得以解决。
接下来就是调模型和调参数了,在调的过程中可以发现在单层加了很多卷积核的效果远不如做两层卷积核,当时一开始做了两层,卷积核个数分别是16和32,精确度只有85左右,但是改成了三层卷积核个数为4,8和16之后,就可以跑到97了。
之前是用Tensorflow几十句代码实现,很多细节别人都写好了,感觉细节的理解上不太清晰,这次自己写过后,细节的理解加深了不少,继续努力吧。
两年后因为课程的原因用python又写了一遍,但是这次的代码风格要比之前好一些,依旧是从零开始搭建,用的是mnist数据集,基于numpy和cuda编程实现
经过测试使用cuda编程调用gpu做并行运算比cpu要快,不过毕竟水平有限只能快几倍(取决与网络的规模),而官方的包(比如Tensorflow)可以快几十倍
完整的代码和C的一样放在https://github.com/Spock343/deeplearning_stepbystep
下面简单讲解以下个人认为比较难的两个部分,一个是卷积层反向传播的部分,还有一个是对应的cuda实现
首先先看卷积层前向传播(if not self.isgpu部分)
# a_slice为从原图片中剪切出来的一块,高度和宽度与卷积核一致,通道数为原图片的通道数,k为卷积核,b为偏置
def conv2d_step(self, a_slice, k, b):
# 单步卷积
temp = a_slice * [k]
s = temp.sum(axis=(1, 2, 3))
z = s + [b]
return z
# x为输入图片,k为卷积核,b为偏置,stride为步长,pad为填充大小
def conv2d_forward(self, x, k, b, stride, pad):
# 卷积层前向传播
(m, h_pre, w_pre, c_pre) = x.shape
(c, kh, kw, c_pre) = k.shape
h = int((h_pre - kh + 2 * pad) / stride) + 1 # 计算卷积后的高度
w = int((w_pre - kw + 2 * pad) / stride) + 1 # 计算卷积后的宽度
z = np.zeros([m, h, w, c])
x_pad = self.zero_pad(x, pad)
if not self.isgpu:
for i in range(h):
for j in range(w):
for l in range(c):
# 计算滑动窗口的左上角坐标和右下角坐标
h_start = i * stride
h_end = i * stride + kh
w_start = j * stride
w_end = j * stride + kw
a_slice = x_pad[:, h_start:h_end, w_start:w_end, :] # 滑动窗口到每一个片段做卷积运算
z[:, i, j, l] = self.conv2d_step(a_slice, k[l], b[l])
else:
x_pad2 = cuda.to_device(x_pad)
k2 = cuda.to_device(k)
b2 = cuda.to_device(b)
z2 = cuda.to_device(z)
conv2d_forward_gpu[(m, h), (w, c)](x_pad2, k2, b2, stride, kh, kw, z2)
cuda.synchronize()
z = z2.copy_to_host()
a = self.relu(z)
return z, a
很显然可以通过对循环内的一个单步卷积做偏导,然后逐步叠加,从而得到整个偏导,比如说对卷积核求偏导,单步卷积中的偏导恰好就是原图片数值,然后再根据链式法则乘上卷积后那一层的偏导数,便得到卷积核的偏导,再一一叠加,类似的对原图片(da_pre)的偏导数类似,就是卷积核的数值,再根据链式法则乘上卷积后那一层的偏导数,下面是代码的实现(if not self.isgpu部分)。
# da为激活后那一层的偏导,a_pre为卷积层之前的数据,k为卷积核,b为偏置,z为卷积层之后的数据,stride为步长,pad为填充
def conv2d_backward(self, da, a_pre, k, b, z, stride, pad):
# 卷积层反向传播
dz = self.drelu(da, z)
(m, h_pre, w_pre, c_pre) = a_pre.shape
(c, kh, kw, c_pre) = k.shape
(m, h, w, c) = dz.shape
da_pre = np.zeros([m, h_pre, w_pre, c_pre])
dk = np.zeros([c, kh, kw, c_pre])
db = np.zeros([c, 1])
a_pre_pad = self.zero_pad(a_pre, pad)
if not self.isgpu:
da_pre_pad = self.zero_pad(da_pre, pad)
# 直接按照原来的顺序循环,循环内部直接对单步卷积求偏导然后叠加即可,
for i in range(h):
for j in range(w):
for l in range(c):
# 计算滑动窗口的左上角坐标和右下角坐标
h_start = i * stride
h_end = i * stride + kh
w_start = j * stride
w_end = j * stride + kw
a_slice = a_pre_pad[:, h_start:h_end, w_start:w_end, :] # 取出滑动窗口的每一个片段
da_pre_pad[:, h_start:h_end, w_start:w_end, :] += [k[l]] * dz[:, i:i+1, j:j+1, l:l+1] # 与卷积核做点积
dk[l] += (a_slice * dz[:, i:i+1, j:j+1, l:l+1]).sum(axis = 0) # 片段与上一个导数卷积
db[l] += dz[:, i, j, l].sum() # 直接求和
da_pre = da_pre_pad[:, pad:-pad, pad:-pad, :]
else:
k2 = cuda.to_device(k)
dz2 = cuda.to_device(dz)
dk2 = cuda.to_device(dk)
# db部分numpy快一些
# db2 = cuda.to_device(db)
a_pre_pad2 = cuda.to_device(a_pre_pad)
da_pre2 = cuda.to_device(da_pre)
conv2d_backward_gpu_da_pre[(m, h_pre), (w_pre, c_pre)](da_pre2, k2, dz2, kh, kw)
conv2d_backward_gpu_dk[(c, kh), (kw, c_pre)](dk2, a_pre_pad2, dz2, kh, kw)
# conv2d_backward_gpu_db[c, 1](db2, dz2)
cuda.synchronize()
dk = dk2.copy_to_host()
# db = db2.copy_to_host()
db = dz.sum(axis=(0, 1, 2)).reshape(-1, 1)
da_pre = da_pre2.copy_to_host()
dk /= len(a_pre)
db /= len(a_pre)
return da_pre, dk, db
由于cpu的运行太慢了,因此笔者基于numba尝试了cuda编程,加快了运算,即上述代码else部分,和一些函数,这里不针对cuda编程做讲解
首先我们知道这里调用gpu做的是并行运算,而对cpu编程的做法是逐个叠加,但是并行运算中,像这种逐个叠加,且异步运行会导致结果的不确定性,举个例子
def add1(a):
b = a + 1
return b
def add2(a):
b = a + 2
return b
a = 0
a = add1(a) # 1
a = add2(a) # 2
假设1和2是并行运行的话,那么最后全部运行完a的结果可以是1,2和3,取决于各个指令的运行顺序,而这个顺序再并行中是不确定的,如果强行同步的话有失去了并行运算的优点,因此需要想一种可以并行有能得到确定结果的方式
于是这里直接对元素求偏导,而不是叠加,先看对原图片的偏导
这里da_pre为四位张量,维数分别为m(批次大小),h(高度),w(宽度),c(通道数),那么有m*h*w*c个偏导,这几个偏导可以直接并行计算
首先观察卷积后的图片和原图片有关的部分,可以发现他们索引有的偏差有一定的关系,他们的行列的索引的偏差最多为(卷积核对应维度大小 - 1) // 2,那就好办了,直接对这块区域做循环,当然如果步长不为0的话,循环步长也得变大,这里笔者为方便没有实现通用的代码,然后根据这个偏差,和原图片的索引计算出,卷积核的索引和卷积后图片的索引,即可计算偏导了,然后在gpu的一个线程中叠加即可,这里的叠加是在一个线程中,不会出现之前的问题,直接得到该点的偏导
然后在在观察卷积后的图片和卷积和有关的部分,可以发现卷积核的每一个数值都和卷积后的图片的相关,仅有通道数上有限制,那么我们直接遍历卷积后的图片该通道数上的每一个点,计算偏导叠加即可,同样可以直接得到该点的偏导。
下面是代码(偏置在实现后发现还没直接调用numpy.sum快,这里就不讲解了)
# 调用gpu计算卷积反向(da_pre)
@cuda.jit
def conv2d_backward_gpu_da_pre(da_pre, k, dz, kh, kw):
n, i = cuda.blockIdx.x, cuda.blockIdx.y
j, l = cuda.threadIdx.x, cuda.threadIdx.y
(m, h_pre, w_pre, c_pre) = da_pre.shape
(m, h, w, c) = dz.shape
h_start = (1 - kh) // 2
h_end = (kh - 1) // 2 + 1
w_start = (1 - kw) // 2
w_end = (kw - 1) // 2 + 1
kh2 = (kh - 1) // 2
kw2 = (kw - 1) // 2
tmp = 0
# 行偏移
for ii in range(h_start, h_end):
# 列偏移
for jj in range(w_start, w_end):
h_index = ii + i
w_index = jj + j
if(h_index >= 0 and h_index < h and w_index >= 0 and w_index < w):
for ll in range(k.shape[0]):
ki = kh2 - ii
kj = kw2 - jj
tmp += k[ll][ki][kj][l] * dz[n][h_index][w_index][ll]
da_pre[n][i][j][l] = tmp
# 调用gpu计算卷积反向(卷积核)
@cuda.jit
def conv2d_backward_gpu_dk(dk, a_pre_pad, dz, kh, kw):
c, kh = cuda.blockIdx.x, cuda.blockIdx.y
kw, c_pre = cuda.threadIdx.x, cuda.threadIdx.y
tmp = 0
for i in range(dz.shape[1]):
for j in range(dz.shape[2]):
for n in range(a_pre_pad.shape[0]):
tmp += a_pre_pad[n][i+kh][j+kw][c_pre] * dz[n][i][j][c]
dk[c][kh][kw][c_pre] = tmp