尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用NumPy手写二维卷积神经网络:前向传播与反向传播全解析

用NumPy手写二维卷积神经网络:前向传播与反向传播全解析 简介手写实现的二维卷积神经网络Python代码面向图像处理与计算机视觉初学者以及希望深入理解CNN底层原理的学习者。资源以PyCharm为开发环境从零搭建网络结构完整涵盖卷积层卷积核扫描与特征提取、ReLU激活函数引入非线性、最大池化降低空间维度、批量归一化加速收敛、全连接层输出预测并配置交叉熵损失与Adam优化器清晰演示前向传播、反向传播及参数更新流程。压缩包共2个文件一个可直接运行的Python脚本一个实验报告Word文档整体仅24KB轻量便于快速查阅与二次修改。目前已有895人浏览学习。脚本给出构建网络、加载数据集、训练与验证的参考代码报告补充实验设计、结果分析及常见问题适合结合具体图像识别任务调试和扩展是深入理解深度学习原理、强化动手能力的实用入门材料。 几年前我学习深度学习时遇到一个特别尴尬的情况用PyTorch搭一个二维卷积神经网络分类MNIST几行代码就能跑到99%的准确率可一旦有人问我卷积层反向传播的梯度到底是怎么算出来的我就只能哑火。后来一咬牙用了两个周末纯手写了一套二维卷积神经网络的Python代码不借助任何深度学习框架只用NumPy做矩阵运算才把这件事彻底想明白。这篇文章就是把那套代码拆开揉碎讲一遍适合已经会用PyTorch/TensorFlow跑模型、但总觉得底层原理隔着一层纱的人也适合想从零理解卷积前向传播和反向传播的初学者。1. 为什么放着PyTorch不用偏要手写CNN先聊点实在的。很多人觉得框架都封装好了torch.nn.Conv2d一行代码就搞定卷积为什么还要自找麻烦我的看法是手写不是为了让生产环境去跑纯NumPy代码而是为了证明你真正懂这个东西。调框架的时候你看到的是接口和tensor形状变化看不到的是数据在内存里怎么流动、梯度从损失函数一路传回每一层时发生了什么。面试里被问卷积层输入的dx是怎么算的卡住的人我见过太多了。这不是背不背得下来公式的问题而是脑子里缺少一个从数学符号到代码实现的对应关系。手写一遍二维卷积神经网络你会获得几个扎扎实实的能力能徒手推导任意层的反向传播梯度而不是只会调.backward()。能看懂框架源码里im2col、col2im这些优化在干什么。模型训练出NaN、梯度爆炸、loss不下降时你能从底层机制去排查不用瞎调参。换到研究里接触新结构、写自定义算子时不至于一头雾水。我觉得每个学深度学习的人都值得做一次这种笨功夫。它不是用来替代框架的它是用来帮你理解框架的。做完之后你会发现自己回去用PyTorch时思路完全不一样了。2. 先定骨架一个能在MNIST上跑出97%的小网络既然要手写就不能一上来搞太复杂的网络。我做了一个刻意精简的结构参考了LeNet-5的经典思路但把通道数和全连接层规模都缩小了方便跑动层输出尺寸参数说明输入1x28x28MNIST灰度图单通道Conv18x28x283x3卷积核1个paddingReLU8x28x28逐元素激活MaxPool8x14x142x2窗口步长2Conv216x14x143x3卷积核1个paddingReLU16x14x14逐元素激活MaxPool16x7x72x2窗口步长2Flatten784维16x7x7展平FC1128维全连接层ReLU128维逐元素激活FC210维映射到10个类别Softmax交叉熵10维概率分布分类头数据集我就选了MNIST。原因很实际图像小、类别少、训练快而且手写数字识别的场景人人都能理解不需要额外的行业知识。手写数字识别的本质就是让二维卷积神经网络自动学习从图像局部纹理到全局语义的多层特征表达这也是CNN最经典的舞台。数据加载上我直接用keras.datasets.mnist来读取原始数据但网络的前向、反向、训练全部手写。很多人会纠结数据加载算不算作弊我的看法是这不重要。你要练的是网络本身不是数据管道。数据加载用现成工具完全合理。另外要注意数据预处理的两个细节import numpy as np from keras.datasets import mnist (x_train, y_train), (x_test, y_test) mnist.load_data() x_train x_train.reshape(-1, 1, 28, 28).astype(np.float64) / 255.0 x_test x_test.reshape(-1, 1, 28, 28).astype(np.float64) / 255.0 def one_hot(y, num_classes10): return np.eye(num_classes)[y] y_train_oh one_hot(y_train) y_test_oh one_hot(y_test)第一归一化。像素值从[0,255]缩放到[0,1]这能让梯度更新更平稳模型收敛更快。如果不做归一化初始loss很大训练早期的梯度很容易把权重推到离谱的地方。第二标签要转成one-hot。交叉熵损失要求模型输出跟真实标签在同一个表示空间里做比较0到9这样的整数标签没法直接算出一个分布距离。然后是权重初始化。这一步比大多数人想象的重要得多。我用的是He初始化理由很直接后面的激活函数全是ReLUReLU会把负数置零导致神经元输出期望不是零均值。He初始化的标准差考虑了ReLU的单侧抑制特性def he_init(shape): fan_in np.prod(shape[:-1]) std np.sqrt(2.0 / fan_in) return np.random.randn(*shape) * std偏置b直接初始化为零就行。3. 卷积层的前向传播手写时最容易错的地方卷积前向传播的原理不复杂用一个小窗口扫过输入特征图窗口内做加权求和再加上偏置。真正写代码时维度处理才是坑最多的地方。我建议分两步走先写一个零填充函数再写卷积单步操作最后组装成完整的前向函数。这样每个环节都能单独验证出错了也好排查。def zero_pad(X, pad): 对输入X进行零填充X形状为(m, H, W, C) return np.pad( X, ((0, 0), (pad, pad), (pad, pad), (0, 0)), modeconstant )这里有个很关键的选型把通道维放到最后一个维度。也就是说我内部统一使用NHWC布局输入是(m, H, W, C)卷积核是(F_h, F_w, C_in, C_out)。这个布局虽然不如NCHW在GPU上性能好但对纯NumPy实现来说切片和广播都更直观写起来不容易晕。单步卷积操作的实现如下def conv_single_step(a_slice, W): return np.sum(a_slice * W)就这么简单。一个三维输入块跟一个三维卷积核逐元素相乘再求和就是卷积的输出。接下来是完整的前向函数def conv_forward(A_prev, W, b, hparameters): (m, H_prev, W_prev, C_prev) A_prev.shape (F_h, F_w, C_prev, C_out) W.shape stride hparameters[stride] pad hparameters[pad] H_out (H_prev - F_h 2 * pad) // stride 1 W_out (W_prev - F_w 2 * pad) // stride 1 Z np.zeros((m, H_out, W_out, C_out)) A_prev_pad zero_pad(A_prev, pad) W_prev W for i in range(m): a_prev_pad A_prev_pad[i] for h in range(H_out): for w in range(W_out): for c in range(C_out): vert_start h * stride vert_end vert_start F_h horiz_start w * stride horiz_end horiz_start F_w a_slice a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] Z[i, h, w, c] conv_single_step(a_slice, W_prev[:, :, :, c]) b[c] cache (A_prev, W, b, hparameters) return Z, cache输出尺寸的计算公式是H_out (H_prev - F_h 2 * pad) // stride 1这个公式必须刻在脑子里。MNIST输入28x283x3卷积核、padding1、stride1输出还是28x28就是靠它算出来的。这里有个新手最容易栽的坑a_slice a_prev_pad[...]取出来的块最后一维是输入通道数C_prev而W[:, :, :, c]前三维恰好也是(F_h, F_w, C_prev)两者能直接相乘。一旦你把卷积核的维度排成(C_out, C_in, F_h, F_w)这个切片操作就会错位得到的错误结果极难排查。所以动手前先确定布局写进注释里。纯循环版本写起来没什么技术含量但性能确实一般。想提速有两条路一是用as_strided或im2col把切片的循环批量向量化二是对batch维度做并行处理。对理解原理来说这个直观版本已经足够。4. 卷积层反向传播推导逻辑和四层循环的对应关系卷积层反向传播是手写CNN里劝退最多人的地方。其实核心就是三个梯度的计算对输入求梯度dA、对卷积核求梯度dW、对偏置求梯度db。记住一个总原则梯度必须和参数的形状完全一致。偏置的梯度最简单。$dZ$在空间位置上累加就行因为同一个卷积核在输出特征图的每个位置共享同一个偏置db np.sum(dZ, axis(0, 1, 2)).reshape(-1)卷积核的梯度要这样看输出特征图上一个位置(h, w)的梯度dZ[i, h, w, c]乘以产生这个输出所用的输入块a_slice就是dW在这个位置上的贡献。所有位置累加就得到完整的dWdW[:, :, :, c] a_slice * dZ[i, h, w, c]输入梯度dA稍微绕一点。它是本层梯度往上游传播的关键把卷积核的权重按同样的滑动窗口路径反向铺回输入特征图。数学上写成链式法则就是$\frac{\partial L}{\partial a} \sum \frac{\partial L}{\partial z} \cdot \frac{\partial z}{\partial a}$对应到代码里梯度是按窗口位置累加回到输入图上的da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] W[:, :, :, c] * dZ[i, h, w, c]这里有个细节非常容易被忽略由于前向时给输入做了padding反向算出来的dA_prev_pad包含了padding区域的梯度返回给上一层之前必须裁剪掉。不然维度对不上训练时loss会直接爆炸。def conv_backward(dZ, cache): A_prev, W, b, hparameters cache (m, H_prev, W_prev, C_prev) A_prev.shape (F_h, F_w, C_prev, C_out) W.shape stride hparameters[stride] pad hparameters[pad] (m, H_out, W_out, C_out) dZ.shape dA_prev np.zeros_like(A_prev) dW np.zeros_like(W) db np.zeros_like(b) A_prev_pad zero_pad(A_prev, pad) dA_prev_pad np.zeros_like(A_prev_pad) for i in range(m): a_prev_pad A_prev_pad[i] da_prev_pad dA_prev_pad[i] for h in range(H_out): for w in range(W_out): for c in range(C_out): vert_start h * stride vert_end vert_start F_h horiz_start w * stride horiz_end horiz_start F_w a_slice a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] W[:, :, :, c] * dZ[i, h, w, c] dW[:, :, :, c] a_slice * dZ[i, h, w, c] db[c] dZ[i, h, w, c] dA_prev[i, :, :, :] da_prev_pad[pad:pad H_prev, pad:pad W_prev, :] return dA_prev, dW, db实现过程中有个反复考验人的点所有累加操作都不能用覆盖必须用。无论是dW还是dA_prev_pad同一个位置会被多个卷积窗口共同影响初始化为0后累加才是正确语义。这个逻辑跟我前向时的结论是一致的卷积本质上就是一个可滑动的共享权重矩阵反向传播时梯度自然要回到每一个窗口位置。5. 池化层、全连接层和交叉熵损失最后一公里的走向卷积层后面接的池化层通常用最大池化它的前向很好理解每个2x2窗口里只取最大值。反向传播需要记录前向时每个最大值的索引然后把梯度还给那个位置其他位置补零。用代码实现就是维护一个maskdef pool_forward(A_prev, hparameters): ... for i in range(m): for h in range(H_out): for w in range(W_out): for c in range(C_prev): a_slice A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] A[i, h, w, c] np.max(a_slice) def pool_backward(dA, cache): A_prev, hparameters cache dA_prev np.zeros_like(A_prev) ... for i in range(m): for h in range(H_out): for w in range(W_out): for c in range(C_prev): a_slice A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] mask (a_slice np.max(a_slice)) dA_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] mask * dA[i, h, w, c]注意最大池化的mask里可能不止一个元素等于最大值所以反向传播用的是而不是直接赋值避免多个最大值的位置都收到梯度。这在有大量重复像素的图像上不是罕见情况值得记住。全连接层本质就是矩阵乘法加偏置前向是Z A W b反向对输入梯度是dA_prev dZ W.T对权重梯度是dW A.T dZ对偏置梯度是db np.sum(dZ, axis0)。这四行公式是全连接层的全部秘密跟纯线性回归的反向推导一模一样。分类头我用的是Softmax加交叉熵。Softmax把网络输出的原始分数转成概率分布交叉熵损失衡量预测分布与真实one-hot分布的差距。训练中最重要的一个梯度假象是Softmax和交叉熵结合后对网络原始输出Z的梯度恰好是dZ A - Y也就是预测概率减真实标签。这看起来像魔法实际上是链式法则中分式化简的结果。这个结论一定要记牢因为几乎所有分类网络的反向传播都依赖它。参数更新的SDG部分最直白learning_rate 0.01 for param, grad in zip(params, grads): param - learning_rate * grad到这里整个二维卷积神经网络的梯度流就闭合了损失函数算出标量逐层回传梯度每层根据梯度更新自己的参数。完整训练循环就可以写出来——每个epoch里把整个训练集切成batch依次做前向、计算损失、反向、更新参数。6. 训练实测结果还有三个让我头秃的坑我在1万张MNIST子集上训练了大概10个epochbatch size设成128学习率0.01。前几个epoch训练准确率从85%左右快速爬升后面增速放缓10个epoch结束在测试集上稳定达到97%上下。对一个纯NumPy手写网络来说这个成绩算是交差了。但说实话跑通这个效果之前我踩了三个特别典型的坑这里挨个说清楚。第一个坑是权重初始化全零。最初为了图省事我把所有参数初始化为0结果训练了好几个epoch准确率一直卡在10%左右不再动。原因很简单同一个网络层里所有神经元初始状态完全对称反向传播时梯度也是对称的权重更新后依然对称神经元全都退化了。破局方法就是改用He初始化打破对称性。第二个坑是梯度检查没过。写完后我用了数值梯度对照解析梯度取$J(\theta\epsilon)$和$J(\theta-\epsilon)$的差分近似epsilon设成1e-7。一跑下来卷积层的dW相对误差在1e-8量级这能验证反向传播公式和代码实现的一致性极大程度帮我确认了前面的推导没写错。你应该把梯度检查当成手写网络的标配测试不要凭感觉说我觉着写对了。在一个隐含层上做梯度检查也可以但最稳妥是全网络所有参数逐层测一遍。第三个坑是学习率太高导致loss变成NaN。我把学习率调到0.5时第一个epoch的loss从正常值直接跳到了几百多随后变成NaN。原因是Softmax输出经过交叉熵的梯度幅度变大参数一步更新过猛数值溢出。解决办法是让学习率回到0.01附近同时稳妥起见在Softmax实现里减去每行的最大值再算指数def softmax(z): z z - np.max(z, axis-1, keepdimsTrue) exp_z np.exp(z) return exp_z / np.sum(exp_z, axis-1, keepdimsTrue)这个技巧是数值稳定性里最常用的一个深度学习框架内部也是这么干的。如果你也想手写一遍自己的二维卷积神经网络我的建议是别急着加batch normalization、dropout、残差连接这些特性先把最朴素的卷积、池化、全连接加交叉熵跑通。跑通之后再去读框架源码你会发现原来im2col是把你手动循环的切片过程做了矩阵化重排原来cuDNN的各种算法是在工程层面对卷积做了更极致的加速。到那个阶段你已经不是在看代码了而是在验证自己脑子里的模型。本文还有配套的精品资源点击获取
返回列表