ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯NumPy实现CNN:从零手写卷积反向传播与梯度流

纯NumPy实现CNN:从零手写卷积反向传播与梯度流 简介本资源是一份面向深度学习初学者与原理探究者的纯NumPy手写数字识别实践项目聚焦CNN底层实现逻辑帮助读者摆脱框架依赖深入理解卷积、池化、全连接与激活等核心层的数学推导与代码落地。压缩包共8个文件含4个Python源码涵盖layers.py层定义、train_cnn.py主训练流程、utils.py工具函数及losses.py损失计算、1个说明文件.txt含环境配置与运行指引、1个附赠资源.docx延伸学习材料以及README.md和.gitattributes整体仅40KB轻量易读。已有96人下载学习适合高校课程设计、算法岗面试准备或自主推导式学习。读者可直接复现从MNIST数据预处理、自定义网络结构搭建、前向/反向传播实现到完整训练-测试-评估的全流程并通过源码级调试掌握梯度计算、参数更新与维度变换等关键细节。1. 为什么我坚持用纯 NumPy 写完一个能跑通 MNIST 的 CNN不是炫技是为看清梯度怎么流、权重怎么更新、内存怎么炸你手头有一份.zip文件解压后没有torch、没有tensorflow只有numpy—— 甚至没装scipy。它能从零加载 MNIST 图片、归一化、打乱、分 batch能定义卷积核、步长、填充手动实现conv2d前向反向能写maxpool2d的索引缓存与梯度回传能堆叠多层全连接、用softmax输出概率、算交叉熵损失、再把梯度一层层推回去最后还能画出训练曲线、输出测试准确率、保存模型参数为.npz。这不是玩具代码是能真实复现 CNN 核心机制的最小可执行闭环。它不追求速度但每一步都透明你看得见dL/dW是怎么从最后一层反向“挤”进卷积核的你知道stride2时池化层梯度怎么稀疏地“跳着”填回上一层你亲手处理了batch_size32下张量形状从(32,1,28,28)→(32,8,24,24)→(32,8,12,12)→(32,128)的每一次 reshape 和 transpose。适合想撕开框架黑匣子的算法工程师、备考深度学习原理面试的应届生、或需要在无 GPU/无 pip 环境如嵌入式板卡、旧服务器里验证模型逻辑的部署工程师。别急着 pip install先让 NumPy 把 CNN 的血肉一寸寸铺给你看。2. 从零构建 CNN 骨架四层模块的前向传播必须满足形状守恒否则反向传播会当场崩溃CNN 不是堆砌函数而是张量在固定规则下的形变流水线。MNIST 输入是(1, 28, 28)单通道灰度图经过卷积→激活→池化→全连接最终输出(10,)类别概率。每一层的输入输出形状必须严格对齐否则反向传播时梯度形状错位ValueError: operands could not be broadcast together会反复出现。我们按数据流顺序逐层实现所有操作均只依赖numpy不调用任何高级封装。2.1 卷积层手写 im2col 矩阵乘比调库慢 10 倍但梯度清晰可见卷积本质是滑动窗口局部加权求和。NumPy 没有原生conv2d但可用im2col将局部区域拉成列向量再用矩阵乘替代循环。这是反向传播可微的关键——因为矩阵乘的导数规则明确dL/dX dL/dY W.TdL/dW X.T dL/dY。def im2col(input_data, filter_h, filter_w, stride1, pad0): input_data: (N, C, H, W) - output: (N, C*filter_h*filter_w, out_h*out_w) 注意pad 是单边补零数out_h (H 2*pad - filter_h) // stride 1 N, C, H, W input_data.shape out_h (H 2*pad - filter_h) // stride 1 out_w (W 2*pad - filter_w) // stride 1 # 补零 img np.pad(input_data, ((0,0), (0,0), (pad,pad), (pad,pad)), constant) # 初始化 col 矩阵 col np.zeros((N, C * filter_h * filter_w, out_h * out_w)) for y in range(out_h): for x in range(out_w): y_start, y_end y * stride, y * stride filter_h x_start, x_end x * stride, x * stride filter_w # 取出所有通道的局部块reshape 成列向量 col[:, :, y * out_w x] img[:, :, y_start:y_end, x_start:x_end].reshape(N, -1) return col def conv2d_forward(x, W, b, stride1, pad0): x: (N, C_in, H, W) W: (C_out, C_in, FH, FW) b: (C_out,) return: (N, C_out, H_out, W_out) N, C_in, H, W x.shape C_out, _, FH, FW W.shape out_h (H 2*pad - FH) // stride 1 out_w (W 2*pad - FW) // stride 1 # im2col 转换输入 col im2col(x, FH, FW, stride, pad) # (N, C_in*FH*FW, out_h*out_w) col_W W.reshape(C_out, -1).T # (C_in*FH*FW, C_out) # 矩阵乘(N, C_in*FH*FW, out_h*out_w) (C_in*FH*FW, C_out) - (N, C_out, out_h*out_w) out np.dot(col.transpose(0, 2, 1), col_W) b # 注意 transpose 保证维度对齐 out out.transpose(0, 2, 1).reshape(N, C_out, out_h, out_w) # 缓存用于反向传播 cache (x, W, b, stride, pad, col) return out, cache关键参数说明stride控制滑动步长pad是单边补零数非总补零out_h/out_w必须用整除//计算否则形状错位。col的 shape 是(N, C_in*FH*FW, out_h*out_w)这是后续反向传播中dL/dW和dL/dX的计算基础。col_W.T是为了匹配np.dot的维度要求左矩阵列数右矩阵行数。2.2 池化层MaxPool2D 必须缓存最大值位置否则反向梯度无法定位池化层不更新参数但反向传播时需将上游梯度dL/dY按照前向时最大值的位置“路由”回dL/dX。若不缓存索引梯度会均匀分配到整个池化窗口导致训练失效。def max_pool2d_forward(x, pool_h2, pool_w2, stride2): x: (N, C, H, W) return: (N, C, H_out, W_out), cache(x, argmax) N, C, H, W x.shape out_h (H - pool_h) // stride 1 out_w (W - pool_w) // stride 1 # 展开为 (N*C, H, W) 方便向量化处理 col x.reshape(N*C, 1, H, W) col im2col(col, pool_h, pool_w, stride, 0) # (N*C, pool_h*pool_w, out_h*out_w) # 找每列最大值及其索引在 pool_h*pool_w 维度上 argmax np.argmax(col, axis1) # (N*C, out_h*out_w) out np.max(col, axis1) # (N*C, out_h*out_w) # 恢复形状 out out.reshape(N, C, out_h, out_w) argmax argmax.reshape(N, C, out_h, out_w) cache (x, argmax, pool_h, pool_w, stride) return out, cache def max_pool2d_backward(dout, cache): dout: (N, C, out_h, out_w) return: dx: (N, C, H, W) x, argmax, pool_h, pool_w, stride cache N, C, H, W x.shape out_h, out_w dout.shape[2], dout.shape[3] # 初始化 dx dx np.zeros_like(x) # 将 dout 展平以便索引 dout_flat dout.reshape(N*C, -1) # (N*C, out_h*out_w) # 构造 flat dx dx_flat np.zeros((N*C, H*W)) # 对每个样本-通道将 dout 值填入 argmax 指定位置 for i in range(N*C): for j in range(out_h * out_w): idx argmax.reshape(N*C, -1)[i, j] dx_flat[i, idx] dout_flat[i, j] # 恢复 dx 形状 dx dx_flat.reshape(N, C, H, W) return dx注意argmax缓存在im2col后的扁平索引空间0 到pool_h*pool_w-1因此dx_flat[i, idx] dout_flat[i, j]是唯一正确的梯度回传方式。若直接用np.unravel_index还原二维坐标再赋值效率极低且易出错。此处dx_flat是核心中间态它把梯度精准“钉”在前向时胜出的位置上。2.3 全连接层与激活层ReLU 的导数必须是 0/1 开关Sigmoid 的导数不能硬写公式全连接层Affine是Y X W b其反向传播是标准矩阵乘导数。而激活函数的导数必须与前向严格对应ReLU 前向是x if x0 else 0反向就是1 if x0 else 0Sigmoid 前向是1/(1exp(-x))反向是y*(1-y)用输出值y计算避免重复 exp 运算。def relu_forward(x): out np.maximum(0, x) cache x return out, cache def relu_backward(dout, cache): x cache dx dout * (x 0) # 布尔数组自动转为 0/1 return dx def sigmoid_forward(x): # 防止 overflow: clip x to [-500, 500] x_clipped np.clip(x, -500, 500) out 1 / (1 np.exp(-x_clipped)) cache out # 缓存输出用于反向 return out, cache def sigmoid_backward(dout, cache): y cache dx dout * y * (1 - y) return dx def affine_forward(x, W, b): x: (N, D_in), W: (D_in, D_out), b: (D_out,) return: (N, D_out), cache(x, W, b) out x W b cache (x, W, b) return out, cache def affine_backward(dout, cache): x, W, b cache dx dout W.T dW x.T dout db np.sum(dout, axis0) return dx, dW, db血泪经验sigmoid_backward若写成dout * (np.exp(-x) / (1np.exp(-x))**2)会因x过大导致exp(-x)下溢为 0或x过小导致exp(-x)上溢为inf。用y*(1-y)完全规避数值问题且计算更快。同理relu_backward中x 0返回布尔数组dout * (x 0)自动广播比np.where(x0, dout, 0)更简洁安全。3. 反向传播引擎四层梯度必须像齿轮一样咬合漏掉任意一环loss 就不会下降前向传播是数据流反向传播是梯度流。它不是独立函数而是前向cache的镜像消费过程卷积层的cache包含col池化层的cache包含argmax全连接层的cache包含x和W激活层的cache包含输入或输出。这些cache必须按前向逆序传递形成一条无断点的梯度链。我们以 MNIST 典型结构Conv(8)→ReLU→Pool→Conv(16)→ReLU→Pool→Affine(128)→ReLU→Affine(10)为例展示完整反向流程。3.1 损失层Softmax CrossEntropy 必须合二为一否则数值不稳定单独实现 Softmax 再算 CrossEntropy 会导致exp(x)溢出。正确做法是log_softmax或softmax_with_cross_entropy合并计算利用log(sum(exp(x)))的稳定性技巧。def softmax_cross_entropy_loss(z, t): z: (N, 10) logits, t: (N,) int labels return: loss scalar, dz: (N, 10) gradient w.r.t z N z.shape[0] # 数值稳定化减去每行最大值 z_shifted z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z_shifted) sum_exp_z np.sum(exp_z, axis1, keepdimsTrue) softmax exp_z / sum_exp_z # 交叉熵损失 loss -np.sum(np.log(softmax[np.arange(N), t])) / N # 梯度dz[i,j] softmax[i,j] - (1 if jt[i] else 0) dz softmax.copy() dz[np.arange(N), t] - 1 dz / N return loss, dz玄学提示dz / N是平均梯度必须做。若漏掉dL/dz会随 batch size 线性放大导致权重更新爆炸。softmax输出已归一化dz的每行和为 0这是验证反向传播正确性的黄金指标可打印np.sum(dz, axis1)检查是否全为 0。3.2 四层反向传播链从 loss 开始逐层消费 cache梯度形状必须严丝合缝假设网络结构为x → Conv1 → ReLU1 → Pool1 → Conv2 → ReLU2 → Pool2 → Affine1 → ReLU3 → Affine2 → loss反向传播顺序必须是loss → Affine2 → ReLU3 → Affine1 → Pool2 → ReLU2 → Conv2 → Pool1 → ReLU1 → Conv1 → x每一步的输入梯度dout必须与前向输出形状一致输出梯度dx必须与前向输入形状一致。例如Affine2反向输入dout dzshape(N,10)输出dx_aff2shape(N,128)ReLU3反向输入dout dx_aff2shape(N,128)输出dx_relu3shape(N,128)Affine1反向输入dout dx_relu3shape(N,128)输出dx_aff1shape(N, 16*4*4)即 Pool2 输出展平Pool2反向输入dout dx_aff1.reshape(N,16,4,4)shape(N,16,4,4)输出dx_pool2shape(N,16,8,8)Conv2反向输入dout dx_pool2shape(N,16,8,8)输出dx_conv2shape(N,8,12,12)……以此类推直到dx_conv1shape(N,1,28,28)# 示例Conv2 层反向传播接在 Pool2 之后 def conv2d_backward(dout, cache): x, W, b, stride, pad, col cache N, C_in, H, W_in x.shape C_out, _, FH, FW W.shape # dL/dW col.T dout_flat, 注意 col shape 是 (N, C_in*FH*FW, out_h*out_w) dout_flat dout.transpose(0, 2, 3, 1).reshape(-1, C_out) # (N*out_h*out_w, C_out) col_T col.transpose(0, 2, 1).reshape(-1, C_in*FH*FW) # (N*out_h*out_w, C_in*FH*FW) dW col_T.T dout_flat # (C_in*FH*FW, C_out) dW dW.reshape(C_out, C_in, FH, FW) # dL/db sum over N,H,W db np.sum(dout, axis(0, 2, 3)) # dL/dX (dout_flat W.T).reshape(col) then col2im dcol dout_flat W.reshape(C_out, -1).T # (N*out_h*out_w, C_in*FH*FW) dcol dcol.reshape(N, -1, dout.shape[2]*dout.shape[3]) # (N, C_in*FH*FW, out_h*out_w) dcol dcol.transpose(0, 2, 1) # (N, out_h*out_w, C_in*FH*FW) # col2im: 将 dcol 恢复为 (N, C_in, H_pad, W_pad) H_pad H 2*pad W_pad W_in 2*pad dx_padded np.zeros((N, C_in, H_pad, W_pad)) for i in range(dout.shape[2]): for j in range(dout.shape[3]): h_start i * stride w_start j * stride dx_padded[:, :, h_start:h_startFH, w_start:w_startFW] \ dcol[:, i*dout.shape[3]j, :].reshape(N, C_in, FH, FW) # 去掉 padding dx dx_padded[:, :, pad:H_pad-pad, pad:W_pad-pad] return dx, dW, db关键逻辑dcol是dL/dcol它需通过col2im映射回dL/dX_padded。这里用双重循环还原位置虽慢但绝对可靠。dx_padded的每个(h_start, w_start)位置累加dcol对应列的梯度正是卷积局部感受野的体现。dx dx_padded[:, :, pad:-pad, pad:-pad]是最终输入梯度形状与原始x一致。4. 训练循环与参数更新SGD with Momentum 是底线Adam 会显著提升收敛速度但需额外状态纯 NumPy 实现优化器意味着你要手动维护velocity、m、v等状态变量。MNIST 训练对优化器敏感SGD 容易震荡Momentum 能加速Adam 几乎是标配。我们实现带beta10.9,beta20.999,eps1e-8的 Adam并强调tstep必须全局递增否则m_hat m / (1 - beta1**t)会失效。4.1 Adam 优化器四个状态变量缺一不可t必须跨 batch 累加class Adam: def __init__(self, params, lr0.001, beta10.9, beta20.999, eps1e-8): self.params params # list of [W1, b1, W2, b2, ...] self.lr lr self.beta1 beta1 self.beta2 beta2 self.eps eps self.t 0 # step counter, must increment every update! # 初始化状态m (first moment), v (second moment) self.m [np.zeros_like(p) for p in params] self.v [np.zeros_like(p) for p in params] def update(self, grads): self.t 1 for i, (p, g) in enumerate(zip(self.params, grads)): # 更新一阶矩估计 self.m[i] self.beta1 * self.m[i] (1 - self.beta1) * g # 更新二阶矩估计 self.v[i] self.beta2 * self.v[i] (1 - self.beta2) * (g ** 2) # 偏差校正 m_hat self.m[i] / (1 - self.beta1 ** self.t) v_hat self.v[i] / (1 - self.beta2 ** self.t) # 参数更新 p - self.lr * m_hat / (np.sqrt(v_hat) self.eps)踩坑警告self.t必须在update()开头1且不能重置。若在每个 epoch 重置t0则1 - beta1**t在初期接近 0m_hat会爆炸。np.sqrt(v_hat) eps中eps不能省略否则v_hat接近 0 时除零。m_hat / (np.sqrt(v_hat) eps)是 Adam 的核心它让学习率自适应缩放。4.2 完整训练循环batch 处理、梯度清零、参数更新、评估频率一个都不能少def train_model(model, x_train, t_train, x_test, t_test, epochs10, batch_size100, lr0.001): model: dict with keys params, grads, forward, backward x_train: (60000, 1, 28, 28), t_train: (60000,) # 初始化 Adam optimizer Adam(model[params], lrlr) # 数据预处理归一化到 [0,1]one-hot label 可选loss 函数支持 int x_train x_train.astype(np.float64) / 255.0 x_test x_test.astype(np.float64) / 255.0 # 训练循环 train_losses [] train_accs [] test_accs [] for epoch in range(epochs): # 打乱数据 perm np.random.permutation(x_train.shape[0]) x_train_shuffled x_train[perm] t_train_shuffled t_train[perm] # mini-batch 循环 for i in range(0, x_train.shape[0], batch_size): x_batch x_train_shuffled[i:ibatch_size] t_batch t_train_shuffled[i:ibatch_size] # 前向传播 out, caches model[forward](x_batch) loss, dout softmax_cross_entropy_loss(out, t_batch) # 反向传播 grads model[backward](dout, caches) # 更新参数 optimizer.update(grads) train_losses.append(loss) # 每 epoch 评估一次 train_acc evaluate(model, x_train[:5000], t_train[:5000]) test_acc evaluate(model, x_test, t_test) train_accs.append(train_acc) test_accs.append(test_acc) print(fEpoch {epoch1}/{epochs} | Loss: {loss:.4f} | Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}) return train_losses, train_accs, test_accs def evaluate(model, x, t): 评估准确率不更新参数 out, _ model[forward](x) pred np.argmax(out, axis1) acc np.mean(pred t) return acc实操细节x_train.astype(np.float64)是必须的float32在累加梯度时易丢失精度导致 loss 不降。evaluate用全部 test set但训练中train_acc只用前 5000 样本避免每次评估太慢。print语句放在 epoch 结尾而非 batch 结尾否则日志爆炸。5. 避坑指南这 5 个错误让 90% 的纯 NumPy CNN 训练失败第 3 个最隐蔽纯 NumPy 实现 CNN最大的敌人不是性能而是无声的数值错误。它们不会报错只会让 loss 停滞、acc 不升、梯度消失或爆炸。以下是我在 37 次翻车后总结的 5 个致命坑每个都附带现象、根因和现场急救方案。5.1 现象loss 初始值巨大1000且几轮后变为nan原因Softmax CrossEntropy 未做数值稳定化exp(x)溢出导致softmax输出为[inf, 0, 0, ...]log(0)产生-inf后续梯度全nan。解决强制在softmax_cross_entropy_loss中加入z_shifted z - np.max(z, axis1, keepdimsTrue)。验证打印np.max(z_shifted)应 ≤ 0np.min(z_shifted)应 ≥ -500。5.2 现象loss 缓慢下降但 train/test acc 始终在 10%随机猜附近原因反向传播中某层dx形状错误导致梯度无法回传到前面层。最常见于conv2d_backward的col2im步骤dx_padded未正确 slice 去除 padding或dcolreshape 错误。解决在conv2d_backward结尾添加断言assert dx.shape x.shape。逐层检查dout.shape是否等于前向out.shapedx.shape是否等于前向x.shape。用print(fConv1 dx shape: {dx.shape}, expected: {x.shape})插桩。5.3 现象loss 下降但 test acc 不升甚至下降过拟合迹象然而 train acc 也卡在 95% 不动原因Batch Normalization 缺失本项目未实现但更隐蔽的是ReLU 的 dead neuron某层输出大量 0导致后续层梯度为 0。尤其在第一层卷积若W初始化全为正或全为负x*Wb永远 ≤0则ReLU输出全 0梯度死亡。解决权重初始化必须用 He initialization。对于 ReLUW ~ N(0, 2/fan_in)。在Conv2D初始化时W np.random.randn(C_out, C_in, FH, FW) * np.sqrt(2/(C_in*FH*FW))。验证打印np.mean(model[params][0])应 ≈ 0np.std(model[params][0])应 ≈np.sqrt(2/(C_in*FH*FW))。5.4 现象loss 振荡剧烈忽高忽低learning rate 调小仍无效原因Adam 的t未全局递增或m/v状态未正确初始化。若t每 batch 重置为 0则1 - beta1**t≈ 0m_hat被除爆。若m/v初始化为None而非np.zeros_like(p)则m beta1*m ...会报TypeError。解决在Adam.__init__()中显式初始化self.m和self.v为np.zeros_like(p)在update()开头self.t 1且绝不重置。验证打印self.t应随 batch 线性增长。5.5 现象训练速度极慢1 epoch 30 分钟CPU 占用率仅 20%原因im2col和col2im使用 Python 循环而非向量化。本项目中im2col的双层 for 循环是瓶颈尤其在大 kernel 或大 feature map 时。解决用numpy.lib.stride_tricks.sliding_window_view替代手写循环NumPy ≥ 1.20。例如from numpy.lib.stride_tricks import sliding_window_view def im2col_fast(x, FH, FW, stride1, pad0): x_padded np.pad(x, ((0,0),(0,0),(pad,pad),(pad,pad)), constant) windows sliding_window_view(x_padded, (FH, FW), axis(-2,-1)) # windows.shape (N, C, H_out, W_out, FH, FW) return windows[:, :, ::stride, ::stride, :, :].reshape(N, C, -1, FH*FW).transpose(0, 2, 1, 3).reshape(N, -1, FH*FW)注意sliding_window_view返回视图不复制内存但reshape可能触发 copy。若内存紧张保留原版循环接受速度代价——毕竟目标是理解不是生产。6. 模型持久化与推理部署.npz是纯 NumPy 的终极交付物它比.pth更轻、更可控、更易审计训练完成的模型参数是若干np.ndarray卷积核W1、偏置b1、全连接权重W2、偏置b2……它们分散在model[params]列表中。.npz格式是 NumPy 原生的压缩存档支持键值存储、懒加载、跨平台且无需任何框架依赖。这才是纯 NumPy 项目的交付终点——不是 Jupyter notebook不是 training log而是一个可被任何 Python 环境np.load()加载、model.forward()调用的二进制文件。6.1 保存模型用np.savez_compressed键名必须与加载时一致def save_model(params, filepath): params: list [W1, b1, W2, b2, ...] Save as .npz with keys W1,b1,W2,b2,... # 定义键名与模型构建顺序严格对应 keys [W1, b1, W2, b2, W3, b3, W4, b4] if len(params) ! len(keys): raise ValueError(fparams length {len(params)} ! keys length {len(keys)}) # 创建字典并保存 save_dict {k: v for k, v in zip(keys, params)} np.savez_compressed(filepath, **save_dict) print(fModel saved to {filepath}) # 示例训练后保存 save_model(model[params], mnist_cnn.npz)关键约束键名W1/b1/W2/b2...必须与模型前向函数中参数读取顺序完全一致。若forward()中先用params[0]作为W1则save_dict[W1]必须是params[0]。任何错位都会导致加载后W1被当b1用模型彻底失效。6.2 加载与推理三步完成零依赖连import torch都不需要def load_model(filepath): Load .npz and return params list in order loaded np.load(filepath) # 按约定键名顺序提取 keys_order [W1, b1, W2, b2, W3, b3, W4, b4] params [loaded[k] for k in keys_order] return params def predict_single_image(model_params, x): x: (1, 1, 28, 28) normalized image return: class_id (0-9) # 手动执行前向复用 forward 函数但不依赖 class # 假设模型结构Conv1→ReLU→Pool→Conv2→ReLU→Pool→Affine1→ReLU→Affine2 W1, b1, W2, b2, W3, b3, p a hrefhttps://download.csdn.net/download/2401_89451588/91685922 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表