资讯详情

纯Python实现CNN:从零手写卷积反向传播与特征图计算

📅 2026/9/10 14:44:49 | 华诺云谱 👁 阅读
纯Python实现CNN:从零手写卷积反向传播与特征图计算
简介本资源是一份面向深度学习初学者与实践者的CNN卷积神经网络完整实现方案聚焦图像分类核心任务适用于高校课程设计、AI入门项目实训及模型复现训练。资源包含LeNet-5在MNIST手写数字数据集上的端到端训练与识别代码以及AlexNet在CIFAR-10自然图像数据集上的建模、训练与评估全流程实现配套详细设计报告Word、模型权重文件.pth、可视化辅助脚本LeNetVis.py及环境配置说明README.md共7个文件涵盖3个核心Python源码、1个预训练权重、1个Markdown文档、1个Word报告和1个LICENSE协议压缩包仅872KB轻量易部署。已有1797人学习下载内容结构清晰、注释充分提供可直接运行的PyTorch 1.6.0TorchVision 0.7.0代码框架支持CUDA 10.2 GPU加速并附带Windows 10 VS Code开发环境适配说明便于快速上手、理解网络结构差异与训练调参逻辑。1. 为什么用纯 Python 实现 CNN 训练与识别反而更容易搞懂反向传播和特征图流动很多人一看到“CNN 卷积神经网络训练与识别”第一反应是直接上 PyTorch 或 TensorFlow——这没错但恰恰掩盖了最核心的困惑卷积层输出尺寸怎么算池化后梯度怎么回传权重更新时为何要翻转卷积核当你用model.fit()一键启动这些细节全被封装在 C 后端里调试时只能看 loss 曲线抖不抖却不知道哪一层的 padding 设错了导致特征图坍缩成 1×1。本项目《基于Python实现的CNN卷积神经网络训练与识别.zip》的价值正在于它完全不依赖深度学习框架仅用 NumPy Matplotlib 构建前向传播、手动推导反向传播、逐层实现卷积/池化/全连接的梯度计算并在 MNIST 和自定义手写数字图像上完成端到端训练与识别。它不是为了替代 PyTorch而是作为“可透视的 CNN 教学沙盒”你能打印出每一层的 feature map 形状、查看某次 forward 中第 3 个卷积核对第 7 张输入图的激活值、甚至把某次 backward 的 dW 矩阵保存为 .npy 文件用 ImageJ 查看权重更新方向。适合三类人刚学完《深度学习》第5章想动手验证公式的本科生在嵌入式或边缘设备上需轻量级推理逻辑的工程师以及被框架报错InvalidArgumentError: input must be 4-dimensional卡住三天、急需回归原理的调参者。2. 从零构建 CNN 前向传播卷积、ReLU、池化、全连接四步不可省略2.1 卷积层实现必须显式处理 stride、padding 和 kernel 翻转卷积操作的本质是滑动窗口互相关cross-correlation但反向传播要求梯度计算时使用卷积核的 180° 翻转即 flip。因此前向传播中我们严格按互相关定义实现不提前翻转而梯度计算时再调用np.rot90(kernel, 2)。关键参数必须显式声明def conv_forward(x, w, b, stride1, pad0): x: (N, C, H, W) 输入张量 w: (F, C, HH, WW) 卷积核F 为输出通道数 b: (F,) 偏置向量 stride: 步长默认1 pad: 零填充宽度默认0 返回: out (N, F, H_out, W_out), cache 用于反向传播 N, C, H, W x.shape F, _, HH, WW w.shape # 计算输出尺寸H_out floor((H 2*pad - HH) / stride) 1 H_out (H 2 * pad - HH) // stride 1 W_out (W 2 * pad - WW) // stride 1 # 初始化输出 out np.zeros((N, F, H_out, W_out)) # 对每个样本、每个输出通道执行卷积 for n in range(N): x_pad np.pad(x[n], ((0, 0), (pad, pad), (pad, pad)), modeconstant) for f in range(F): for i in range(H_out): for j in range(W_out): h_start, h_end i * stride, i * stride HH w_start, w_end j * stride, j * stride WW # 互相关不翻转卷积核 out[n, f, i, j] np.sum(x_pad[:, h_start:h_end, w_start:w_end] * w[f]) b[f] cache (x, w, b, stride, pad) return out, cache注意此处np.pad(x[n], ((0,0), (pad,pad), (pad,pad)))的 padding 维度顺序必须与(C, H, W)对齐若误写为((pad,pad), (pad,pad), (0,0))将导致通道维度被填充引发形状错误。这是新手最常踩的坑之一。2.2 ReLU 激活与最大池化必须保留 mask 用于梯度路由ReLU 的反向传播是门控函数输入 0 时梯度原样通过否则截断为 0。因此前向传播必须缓存输入x的正负 mask最大池化则需记录每个池化窗口内最大值的位置索引否则反向无法定位梯度应流向何处。def relu_forward(x): 前向输出 max(0,x)缓存输入 x out np.maximum(0, x) cache x return out, cache def max_pool_forward(x, pool_height2, pool_width2, stride2): x: (N, C, H, W) 返回: out (N, C, H_out, W_out), cache 包含 x 和 max_idx N, C, H, W x.shape H_out (H - pool_height) // stride 1 W_out (W - pool_width) // stride 1 out np.zeros((N, C, H_out, W_out)) max_idx np.zeros((N, C, H_out, W_out, 2), dtypeint) # 存储 (h_idx, w_idx) for n in range(N): for c in range(C): for i in range(H_out): for j in range(W_out): h_start, h_end i * stride, i * stride pool_height w_start, w_end j * stride, j * stride pool_width window x[n, c, h_start:h_end, w_start:w_end] out[n, c, i, j] np.max(window) # 获取最大值在 window 中的相对坐标 idx np.unravel_index(np.argmax(window), window.shape) max_idx[n, c, i, j] [h_start idx[0], w_start idx[1]] cache (x, max_idx, pool_height, pool_width, stride) return out, cache提示np.unravel_index(np.argmax(window), window.shape)是获取二维数组最大值坐标的最简方式。若用np.where(window np.max(window))返回的是 tuple of arrays需额外取[0][0], [1][0]易出索引错误。2.3 全连接层需完成空间展平flatten与矩阵乘法解耦CNN 输出是(N, F, H_out, W_out)四维张量而全连接层权重w是二维(F*H_out*W_out, num_classes)因此必须在前向传播中显式执行x.reshape(N, -1)。该 reshape 操作不可省略且必须与反向传播中的dx.reshape(x.shape)严格配对。def affine_forward(x, w, b): x: (N, D1, D2, ..., Dk) 或 (N, D) w: (D, M) b: (M,) 返回: out (N, M), cache N x.shape[0] x_reshaped x.reshape(N, -1) # 展平除 batch 外所有维度 out x_reshaped w b cache (x, w, b) return out, cache模块输入 shape输出 shape关键缓存内容常见错误Conv(N,C,H,W)(N,F,H,W)x, w, b, stride, padpad 维度错位导致 shape mismatchReLU(N,F,H,W)同输入输入 x用于判断 0忘记缓存反向无法生成 maskMaxPool(N,F,H,W)(N,F,H,W)x, max_idx, stride未存绝对坐标反向无法映射梯度Affine(N,F,H,W)(N,num_classes)x原始 shape、w、breshape 后未缓存原始 shape反向 reshape 失败3. 手动推导 CNN 反向传播从 softmax loss 到卷积核梯度的完整链路3.1 Softmax Cross-Entropy Loss 的梯度必须归一化为 (N, C)分类任务常用 softmax loss其前向输出是概率分布反向梯度公式为dL/dz softmax(z) - y_true。注意y_true必须是 one-hot 编码且梯度需除以 batch sizeN实现损失均值化否则权重更新幅度过大。def softmax_loss(z, y): z: (N, C) logits y: (N,) 整数标签 返回: loss 标量, dz (N, C) 梯度 N z.shape[0] # softmax 前向 exp_z np.exp(z - np.max(z, axis1, keepdimsTrue)) # 防溢出 probs exp_z / np.sum(exp_z, axis1, keepdimsTrue) # cross-entropy loss correct_logprobs -np.log(probs[np.arange(N), y]) loss np.sum(correct_logprobs) / N # 反向dz probs - y_onehot再 / N dz probs.copy() dz[np.arange(N), y] - 1 dz / N # 关键必须归一化 return loss, dz注意np.max(z, axis1, keepdimsTrue)是数值稳定的关键。若直接np.exp(z)当z中有较大正值如 100时exp(100)会溢出为inf导致后续probs全为nan。此技巧在任何 softmax 实现中都不可省略。3.2 全连接层反向reshape 还原与矩阵乘法梯度分解Affine 层反向需将dz二维还原为x的原始 shape再计算dw和db。dw是x_reshaped.T dzdb是np.sum(dz, axis0)。def affine_backward(dout, cache): dout: (N, M) cache: (x, w, b) 返回: dx (N, D1, D2, ...), dw (D, M), db (M,) x, w, b cache N x.shape[0] x_reshaped x.reshape(N, -1) dx (dout w.T).reshape(x.shape) # 先矩阵乘再 reshape 回原 shape dw x_reshaped.T dout db np.sum(dout, axis0) return dx, dw, db3.3 最大池化反向梯度只流向最大值位置其余为 0池化层无参数反向传播只需将dout中每个位置的梯度赋给前向时记录的最大值坐标处其余位置置 0。def max_pool_backward(dout, cache): dout: (N, C, H_out, W_out) cache: (x, max_idx, pool_height, pool_width, stride) 返回: dx (N, C, H, W) x, max_idx, pool_h, pool_w, stride cache N, C, H, W x.shape dx np.zeros_like(x) for n in range(N): for c in range(C): for i in range(dout.shape[2]): for j in range(dout.shape[3]): # 获取前向时最大值的绝对坐标 h_idx, w_idx max_idx[n, c, i, j] dx[n, c, h_idx, w_idx] dout[n, c, i, j] return dx3.4 卷积层反向输入梯度需 full 卷积权重梯度需互相关这是最易混淆的部分dx计算对dout做 zero-padding 后与翻转的w做 full 卷积即convolve2d(modefull)dw计算对x做 valid 卷积无 padding与dout做互相关由于我们不用 SciPy需手动实现def conv_backward(dout, cache): dout: (N, F, H_out, W_out) cache: (x, w, b, stride, pad) 返回: dx (N, C, H, W), dw (F, C, HH, WW), db (F,) x, w, b, stride, pad cache N, C, H, W x.shape F, _, HH, WW w.shape # 初始化梯度 dx np.zeros_like(x) dw np.zeros_like(w) db np.zeros_like(b) # db: 对 dout 每个通道求和 db np.sum(dout, axis(0, 2, 3)) # dw: 对每个样本、每个输出通道累加 x 与 dout 的互相关 for n in range(N): for f in range(F): for c in range(C): for i in range(HH): for j in range(WW): # x 的局部区域与 dout 对应位置相乘累加 for p in range(dout.shape[2]): for q in range(dout.shape[3]): h_start p * stride w_start q * stride if 0 h_starti H and 0 w_startj W: dw[f, c, i, j] x[n, c, h_starti, w_startj] * dout[n, f, p, q] # dx: 对 dout 做 zero-padding与翻转的 w 做 full 卷积 # 先对 dout 做 padding上下左右各 padHH-1, WW-1 dout_pad np.pad(dout, ((0,0), (0,0), (HH-1, HH-1), (WW-1, WW-1)), modeconstant) w_rot np.rot90(w, 2, axes(2,3)) # 翻转卷积核 for n in range(N): for c in range(C): for i in range(H): for j in range(W): # 在 dout_pad 上取 (F, HH, WW) 区域与 w_rot[c] 逐通道点乘 for f in range(F): h_start, h_end i, i HH w_start, w_end j, j WW dx[n, c, i, j] np.sum(dout_pad[n, f, h_start:h_end, w_start:w_end] * w_rot[f, c]) return dx, dw, db4. 在 MNIST 上训练与识别数据加载、超参调优与精度验证全流程4.1 使用tensorflow.keras.datasets.mnist加载并预处理数据虽然项目强调“纯 Python”但数据加载环节可借助 Keras 的成熟接口因其本质是 NumPy 数组读取不引入框架计算图。重点在于归一化与 shape 调整import numpy as np from tensorflow.keras.datasets import mnist def load_mnist_data(): (x_train, y_train), (x_test, y_test) mnist.load_data() # 归一化到 [0,1] 并扩展通道维度(N, 28, 28) - (N, 1, 28, 28) x_train x_train.astype(np.float32) / 255.0 x_test x_test.astype(np.float32) / 255.0 x_train x_train.reshape(-1, 1, 28, 28) x_test x_test.reshape(-1, 1, 28, 28) # one-hot 编码标签仅用于 loss 计算预测时用 argmax y_train_onehot np.eye(10)[y_train] y_test_onehot np.eye(10)[y_test] return (x_train, y_train, y_train_onehot), (x_test, y_test, y_test_onehot) # 加载 train_data, test_data load_mnist_data() x_train, y_train, y_train_oh train_data x_test, y_test, y_test_oh test_data print(fTrain shape: {x_train.shape}, Test shape: {x_test.shape}) # 输出Train shape: (60000, 1, 28, 28), Test shape: (10000, 1, 28, 28)4.2 定义 CNN 架构与训练循环学习率衰减与 mini-batch 分割本项目采用经典 LeNet-5 结构简化版Conv(1-6,5x5) → ReLU → Pool(2x2) → Conv(6-16,5x5) → ReLU → Pool(2x2) → Affine(16*4*4→120) → ReLU → Affine(120→10)。训练时需手动实现 mini-batch 分割与学习率衰减# 初始化权重He 初始化 def init_weights(shape): return np.random.randn(*shape) * np.sqrt(2.0 / np.prod(shape[:-1])) # 构建网络参数 params {} params[w1] init_weights((6, 1, 5, 5)) # Conv1 params[b1] np.zeros((6,)) params[w2] init_weights((16, 6, 5, 5)) # Conv2 params[b2] np.zeros((16,)) params[w3] init_weights((16*4*4, 120)) # Affine1 params[b3] np.zeros((120,)) params[w4] init_weights((120, 10)) # Affine2 params[b4] np.zeros((10,)) # 训练超参 learning_rate 1e-3 batch_size 128 num_epochs 5 num_batches len(x_train) // batch_size for epoch in range(num_epochs): # 打乱数据 indices np.random.permutation(len(x_train)) x_train_shuffled x_train[indices] y_train_shuffled y_train[indices] y_train_oh_shuffled y_train_oh[indices] epoch_loss 0.0 for i in range(num_batches): start_idx i * batch_size end_idx start_idx batch_size x_batch x_train_shuffled[start_idx:end_idx] y_batch y_train_shuffled[start_idx:end_idx] y_batch_oh y_train_oh_shuffled[start_idx:end_idx] # 前向传播 # Conv1 → ReLU → Pool1 out1, cache1 conv_forward(x_batch, params[w1], params[b1], stride1, pad0) out1_relu, cache1_relu relu_forward(out1) out1_pool, cache1_pool max_pool_forward(out1_relu, 2, 2, 2) # Conv2 → ReLU → Pool2 out2, cache2 conv_forward(out1_pool, params[w2], params[b2], stride1, pad0) out2_relu, cache2_relu relu_forward(out2) out2_pool, cache2_pool max_pool_forward(out2_relu, 2, 2, 2) # Affine1 → ReLU → Affine2 out3, cache3 affine_forward(out2_pool, params[w3], params[b3]) out3_relu, cache3_relu relu_forward(out3) scores, cache4 affine_forward(out3_relu, params[w4], params[b4]) # Loss Backward loss, dscores softmax_loss(scores, y_batch) epoch_loss loss # Backward dout4, dw4, db4 affine_backward(dscores, cache4) dout3_relu relu_backward(dout4, cache3_relu) dout3, dw3, db3 affine_backward(dout3_relu, cache3) dout2_pool max_pool_backward(dout3, cache2_pool) dout2_relu relu_backward(dout2_pool, cache2_relu) dout2, dw2, db2 conv_backward(dout2_relu, cache2) dout1_pool max_pool_backward(dout2, cache1_pool) dout1_relu relu_backward(dout1_pool, cache1_relu) dout1, dw1, db1 conv_backward(dout1_relu, cache1) # 参数更新SGD params[w1] - learning_rate * dw1 params[b1] - learning_rate * db1 params[w2] - learning_rate * dw2 params[b2] - learning_rate * db2 params[w3] - learning_rate * dw3 params[b3] - learning_rate * db3 params[w4] - learning_rate * dw4 params[b4] - learning_rate * db4 avg_loss epoch_loss / num_batches print(fEpoch {epoch1}/{num_epochs}, Avg Loss: {avg_loss:.4f})4.3 识别准确率评估与混淆矩阵可视化训练完成后对测试集进行批量预测并统计 top-1 准确率。使用sklearn.metrics.confusion_matrix生成混淆矩阵Matplotlib 绘制热力图from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt def predict(x, params): # 前向传播无 dropout无 train flag out1, _ conv_forward(x, params[w1], params[b1], 1, 0) out1 relu_forward(out1)[0] out1 max_pool_forward(out1, 2, 2, 2)[0] out2, _ conv_forward(out1, params[w2], params[b2], 1, 0) out2 relu_forward(out2)[0] out2 max_pool_forward(out2, 2, 2, 2)[0] out3, _ affine_forward(out2, params[w3], params[b3]) out3 relu_forward(out3)[0] scores, _ affine_forward(out3, params[w4], params[b4]) return scores # 批量预测 test_scores predict(x_test, params) pred_labels np.argmax(test_scores, axis1) accuracy np.mean(pred_labels y_test) print(fTest Accuracy: {accuracy:.4f}) # 混淆矩阵 cm confusion_matrix(y_test, pred_labels) plt.figure(figsize(8, 6)) plt.imshow(cm, interpolationnearest, cmapplt.cm.Blues) plt.title(Confusion Matrix) plt.colorbar() tick_marks np.arange(10) plt.xticks(tick_marks, range(10), rotation45) plt.yticks(tick_marks, range(10)) plt.ylabel(True Label) plt.xlabel(Predicted Label) plt.tight_layout() plt.show()5. 提升识别鲁棒性的三个实战技巧数据增强、权重初始化与梯度裁剪5.1 在训练循环中嵌入轻量级图像增强平移与旋转MNIST 图像虽已居中但加入 ±2 像素平移和 ±5° 旋转可提升泛化性。使用scipy.ndimage.shift和scipy.ndimage.rotate注意保持输出 shape 不变from scipy.ndimage import shift, rotate def augment_batch(x_batch): x_batch: (N, 1, 28, 28) 返回: 增强后的 batch同 shape N x_batch.shape[0] x_aug np.zeros_like(x_batch) for i in range(N): img x_batch[i, 0] # (28,28) # 随机平移[-2,2] 像素 shift_x np.random.randint(-2, 3) shift_y np.random.randint(-2, 3) img shift(img, (shift_x, shift_y), modeconstant, cval0.0) # 随机旋转[-5,5] 度 angle np.random.uniform(-5, 5) img rotate(img, angle, reshapeFalse, modeconstant, cval0.0) # 截断到 [0,1] 并赋值 img np.clip(img, 0, 1) x_aug[i, 0] img return x_aug # 在训练循环中替换 x_batch # x_batch augment_batch(x_batch)5.2 权重初始化必须匹配激活函数ReLU 用 He 初始化Sigmoid 用 Xavier不同激活函数对应不同初始化策略。本项目用 ReLU故卷积核与全连接权重均采用 He 初始化std sqrt(2/fan_in)若改用 Sigmoid则应切换为 Xavierstd sqrt(1/fan_in)。错误的初始化会导致早期训练 loss 不下降# He 初始化ReLU def he_init(shape): fan_in np.prod(shape[:-1]) # 输入节点数 return np.random.normal(0, np.sqrt(2.0 / fan_in), shape) # Xavier 初始化Sigmoid/Tanh def xavier_init(shape): fan_in np.prod(shape[:-1]) fan_out shape[-1] limit np.sqrt(6.0 / (fan_in fan_out)) return np.random.uniform(-limit, limit, shape)5.3 梯度爆炸时启用梯度裁剪监控np.linalg.norm(dw)并截断在训练初期若发现 loss 突然变为nan大概率是梯度爆炸。可在每次参数更新前检查梯度范数超过阈值则缩放def clip_gradients(params_grad, max_norm5.0): 对所有梯度张量做全局裁剪 total_norm 0.0 for grad in params_grad.values(): if grad is not None: total_norm np.sum(grad ** 2) total_norm np.sqrt(total_norm) if total_norm max_norm: clip_coef max_norm / (total_norm 1e-6) for k in params_grad: if params_grad[k] is not None: params_grad[k] * clip_coef return params_grad # 在训练循环中在更新参数前调用 # grads {dw1: dw1, db1: db1, ..., dw4: dw4, db4: db4} # grads clip_gradients(grads, max_norm5.0)提示梯度裁剪阈值max_norm5.0是经验起点。若训练 loss 下降缓慢可尝试增大至 10若仍频繁出现nan则需检查前向传播中是否有未处理的inf如除零或log(0)。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。