☰
纯NumPy手写CNN:从卷积前向到反向传播全流程实现
2026/10/19 7:26:32 网站建设 项目流程

简介:本资源是一份面向深度学习初学者与原理探究者的纯Python卷积神经网络(CNN)实现方案,不依赖TensorFlow、PyTorch等框架,聚焦底层计算逻辑与反向传播机制的理解。适合高校学生、转行学习者及希望夯实CNN数学基础的开发者,用于图像识别原理教学、课程设计或自主实验。压缩包共6个文件,含4个核心Python脚本(myCnn.py实现卷积/池化/全连接层及训练流程;analysisMNIST.py提供完整MNIST手写数字识别训练与评估流程;myCnnTest.py用于模块功能验证;gParam.py统一管理网络结构、学习率等关键参数)及2个.gz格式MNIST测试集数据文件,整体大小仅1.58MB,轻量易解压运行。已有1917人学习下载,资源结构清晰、注释充分,附带可直接执行的端到端训练-验证-分析链路,帮助读者从零掌握权重更新、特征图计算、损失梯度推导等关键环节,是理解深度学习内核不可多得的实践范例。

1. 从零手写 CNN:不调用 PyTorch/TensorFlow,纯 Python + NumPy 实现前向传播、反向传播与参数更新

你有没有试过,在调试一个黑匣子模型时,突然卡在某层梯度消失的报错里,翻遍文档却找不到Conv2D层内部dL/dW是怎么算的?或者在教新手理解卷积核滑动机制时,发现torch.nn.Conv2d(3, 64, 3)这行代码背后连“步长为1时第3个输出像素到底对应输入哪9个数”都讲不清?这个资源就是为这类时刻准备的——它是一份完全脱离深度学习框架依赖的卷积神经网络实现,仅用 Python 原生语法 + NumPy 数组操作,完整复现了卷积层、池化层、全连接层、Softmax 分类头、交叉熵损失及完整的链式求导过程。它不追求训练 ImageNet,但能让你在单步调试中看清每个np.sum(dL_dout * kernel)的索引对齐逻辑;它不封装model.fit(),但每行反向传播代码都对应《深度学习》(Ian Goodfellow)第6章的数学推导。适合正在啃反向传播原理的初学者、需要定制底层梯度逻辑的研究者,以及想给学生拆解“为什么卷积是互相关”的一线导师。这不是玩具代码,而是一份可打断点、可改维度、可插日志的教学级生产就绪型实现。


2. 核心模块设计:为什么选 NumPy 而非纯 Python?卷积层为何必须手动实现 im2col?

2.1 选型依据:NumPy 是唯一兼顾可读性与计算可行性的载体

纯 Python 循环实现卷积(三层嵌套 for)在 32×32 输入上单次前向就要 2 秒以上,根本无法支撑反向传播验证;而引入 PyTorch 就违背了“无框架”初衷。NumPy 成为唯一解:它提供向量化内存视图(.strides)、广播机制(broadcasting)和 C 底层加速,同时保留数组索引的完全可控性。关键在于——我们不把 NumPy 当加速器用,而当“可视化计算纸”用。比如conv_layer.weights是一个(64, 3, 3, 3)的 ndarray,打印其.shape和.dtype就能立刻确认通道顺序;conv_layer.weights[0, :, :, 0]直接取出第一个卷积核在 R 通道的权重矩阵,这种确定性是框架封装层永远隐藏的。

2.2 卷积层核心:im2col 手动展开与 stride 索引对齐

标准卷积的数学定义是:
$$ \text{out}[i,j,k] = \sum_{c=0}^{C_{in}-1} \sum_{p=0}^{K-1} \sum_{q=0}^{K-1} \text{input}[c, i\times S + p, j\times S + q] \times \text{weight}[k,c,p,q] $$
其中 $S$ 为步长(stride)。若用三重循环硬算,时间复杂度 $O(C_{in} \cdot K^2 \cdot H_{out} \cdot W_{out})$,且反向传播时索引映射极易出错。本项目采用im2col 手动展开法:将输入特征图按卷积窗口滑动,拉成二维矩阵col_matrix(形状为(K*K*C_in, H_out*W_out)),再与展平的卷积核weight_col(形状为(K*K*C_in, C_out))做矩阵乘法。关键代码如下:

def im2col(input_data, kernel_h, kernel_w, stride=1, pad=0): """ 手动实现 im2col:将 4D 输入 (N,C,H,W) 展开为 2D 矩阵 (K*K*C, N*H_out*W_out) 注意:pad 是单边补零量,stride 控制滑动步长 """ N, C, H, W = input_data.shape out_h = (H + 2*pad - kernel_h) // stride + 1 out_w = (W + 2*pad - kernel_w) // stride + 1 # 补零:在 H、W 维度两侧各补 pad 行/列 padded = np.pad(input_data, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant') # 初始化 col 矩阵:每列是一个卷积窗口拉平后的向量 col = np.zeros((C * kernel_h * kernel_w, N * out_h * out_w)) # 遍历每个输出位置 (i,j),提取对应窗口 for i in range(out_h): for j in range(out_w): # 计算该窗口在补零后输入中的起始坐标 h_start = i * stride w_start = j * stride # 提取 (C, kernel_h, kernel_w) 窗口并拉平 window = padded[:, :, h_start:h_start+kernel_h, w_start:w_start+kernel_w] col[:, i * out_w + j] = window.reshape(-1) # 拉平为 (C*K*K,) 向量 return col, (N, C, out_h, out_w) # 使用示例:假设输入 (1,3,32,32),卷积核 3x3,步长 1,无 padding input_batch = np.random.randn(1, 3, 32, 32) col_matrix, out_shape = im2col(input_batch, kernel_h=3, kernel_w=3, stride=1, pad=0) print(f"im2col 输出形状: {col_matrix.shape}") # -> (27, 1024) 即 (3*3*3, 1*30*30)

提示:col_matrix的列数1024对应输出特征图30×30=900?不对!此处out_h=out_w=30是针对单样本,但im2col返回的col_matrix第二维是N * out_h * out_w,即批量大小 × 输出高 × 输出宽。若N=1,则为1×30×30=900列。代码中out_h=30来自(32+0-3)//1+1=30,因此col_matrix.shape应为(27, 900)。上述示例注释已修正为准确值。

该函数返回两个值:col_matrix(用于前向矩阵乘)和out_shape(用于重构输出张量)。反向传播时,dL/dinput的计算只需将dL/dcol重新映射回原始输入空间,这比循环累加更易验证索引正确性。

2.3 池化层:最大池化的梯度掩码机制

最大池化不可导,其反向传播本质是梯度路由:前向时记录每个池化窗口内最大值的位置(argmax),反向时仅将上游梯度传回该位置,其余置零。本项目用np.unravel_index和布尔索引实现,避免tf.nn.max_pool_with_argmax类封装带来的黑盒感:

class MaxPooling2D: def __init__(self, pool_h=2, pool_w=2, stride=2): self.pool_h, self.pool_w = pool_h, pool_w self.stride = stride self.arg_max = None # 存储前向时每个窗口的最大值索引 def forward(self, x): N, C, H, W = x.shape out_h = (H - self.pool_h) // self.stride + 1 out_w = (W - self.pool_w) // self.stride + 1 out = np.zeros((N, C, out_h, out_w)) self.arg_max = np.zeros((N, C, out_h, out_w), dtype=int) for i in range(out_h): for j in range(out_w): # 提取窗口 window = x[:, :, i*self.stride:i*self.stride+self.pool_h, j*self.stride:j*self.stride+self.pool_w] # 记录每个样本每个通道的最大值及其在窗口内的扁平索引 window_flat = window.reshape(N, C, -1) argmax_flat = np.argmax(window_flat, axis=2) # 形状 (N,C) self.arg_max[:, :, i, j] = argmax_flat # 取最大值填入输出 out[:, :, i, j] = np.max(window, axis=(2,3)) return out def backward(self, dout): N, C, H, W = dout.shape dx = np.zeros((N, C, H * self.stride, W * self.stride)) # 上游梯度尺寸需还原 for i in range(H): for j in range(W): # 获取该输出位置对应窗口的最大值索引 argmax = self.arg_max[:, :, i, j] # 将 dout[i,j] 的梯度分配到原输入窗口中对应 argmax 的位置 for n in range(N): for c in range(C): # 将扁平索引转为二维坐标 h_offset, w_offset = np.unravel_index(argmax[n, c], (self.pool_h, self.pool_w)) dx[n, c, i*self.stride + h_offset, j*self.stride + w_offset] += dout[n, c, i, j] return dx

此实现清晰暴露了最大池化反向传播的稀疏性:dx中绝大多数位置为 0,仅argmax对应位置有值。调试时打印np.count_nonzero(dx)即可验证梯度是否正确路由。


3. 反向传播全流程:从 Softmax 损失到卷积核权重更新的链式推导

3.1 Softmax + Cross-Entropy 的解析梯度

许多教程将 Softmax 和 Cross-Entropy 合并为一个“稳定版”公式,但本项目坚持分步实现,因为这是理解梯度起点的关键。前向时先算exp(x)/sum(exp(x)),再算-log(y_true_class);反向时先得dL/dsoftmax_output,再乘以dsoftmax_output/dz。关键推导如下:
设 logits 为z,Softmax 输出为s_i = exp(z_i)/∑_j exp(z_j),真实标签为 one-hot 向量y,则交叉熵损失L = -∑_i y_i log(s_i)。可证:
$$ \frac{\partial L}{\partial z_i} = s_i - y_i $$
即 Softmax-CrossEntropy 的联合梯度等于预测概率减去真实标签。本项目严格按此实现:

class SoftmaxWithLoss: def __init__(self): self.loss = None self.y_pred = None # Softmax 输出 self.y_true = None # 真实标签(one-hot) def forward(self, x, t): """x: (N, C) logits, t: (N, C) one-hot labels""" self.y_true = t # Softmax 前向:减去每行最大值防溢出 x_shifted = x - np.max(x, axis=1, keepdims=True) exp_x = np.exp(x_shifted) self.y_pred = exp_x / np.sum(exp_x, axis=1, keepdims=True) # Cross-Entropy 损失 self.loss = -np.sum(t * np.log(self.y_pred + 1e-7)) / x.shape[0] return self.loss def backward(self, dout=1): """dout 通常为 1,因 L 是标量;返回 dL/dx 形状同 x""" batch_size = self.y_true.shape[0] # 核心:dL/dz = y_pred - y_true dx = (self.y_pred - self.y_true) / batch_size return dx

注意:/ batch_size是平均损失的梯度缩放,若训练时用sum损失则去掉该除法。此处采用mean更符合 PyTorch 默认行为。

3.2 全连接层反向:权重梯度的矩阵维度对齐

全连接层y = x @ W + b的反向传播需同时计算dL/dx、dL/dW、dL/db。本项目用 NumPy 的@和sum显式写出,强制开发者关注维度:

  • dL/dW = x.T @ dL/dy:(in_dim, N) @ (N, out_dim) = (in_dim, out_dim)
  • dL/db = sum(dL/dy, axis=0):沿 batch 维度求和,得(out_dim,)
  • dL/dx = dL/dy @ W.T:(N, out_dim) @ (out_dim, in_dim) = (N, in_dim)
class Affine: def __init__(self, W, b): self.W = W # (in_dim, out_dim) self.b = b # (out_dim,) self.x = None self.dW = None self.db = None def forward(self, x): self.x = x # (N, in_dim) out = x @ self.W + self.b # (N, out_dim) return out def backward(self, dout): """dout: (N, out_dim)""" dx = dout @ self.W.T # (N, in_dim) self.dW = self.x.T @ dout # (in_dim, out_dim) self.db = np.sum(dout, axis=0) # (out_dim,) return dx

若dout.shape=(32,10)(batch=32,10分类),self.x.shape=(32,128),则self.dW.shape必须为(128,10)。运行时打印self.dW.shape是验证反向传播是否写错维度的最快方法。

3.3 卷积层反向:从 dL/dout 到 dL/dweight 的四维张量操作

卷积层反向传播是最易出错环节。本项目将dL/dweight拆解为两步:

  1. dL/dcol:由dL/dout重塑为col_matrix形状,再与weight_col.T相乘(类似全连接)
  2. dL/dweight:将dL/dcol按卷积核结构聚合,即对col_matrix的每列(对应一个输出像素)的梯度,加到其覆盖的输入区域对应的权重上
def col2im(col, input_shape, kernel_h, kernel_w, stride=1, pad=0): """im2col 的逆操作:将 col_matrix 重构为输入形状""" N, C, H, W = input_shape out_h = (H + 2*pad - kernel_h) // stride + 1 out_w = (W + 2*pad - kernel_w) // stride + 1 padded = np.zeros((N, C, H + 2*pad, W + 2*pad)) # 遍历每个输出位置,将 col 的列梯度加到对应窗口 for i in range(out_h): for j in range(out_w): col_idx = i * out_w + j # 提取该列梯度,并重塑为 (C, kernel_h, kernel_w) grad_window = col[:, col_idx].reshape(C, kernel_h, kernel_w) # 加到补零后输入的对应区域 h_start, w_start = i * stride, j * stride padded[:, :, h_start:h_start+kernel_h, w_start:w_start+kernel_w] += grad_window # 去掉补零部分,返回原始输入形状 return padded[:, :, pad:H+pad, pad:W+pad] # 卷积层 backward 核心逻辑(简化版) def backward(self, dout): # dout: (N, C_out, H_out, W_out) N, C_out, H_out, W_out = dout.shape # 1. 将 dout 展平为 (N*C_out, H_out*W_out) 用于矩阵运算 dout_flat = dout.transpose(0, 2, 3, 1).reshape(-1, C_out) # (N*H_out*W_out, C_out) # 2. 计算 dL/dcol = dout_flat @ weight_col.T dcol = dout_flat @ self.weight_col.T # (N*H_out*W_out, C_in*K*K) # 3. 将 dcol 重构为输入梯度 dx dx = col2im(dcol, self.input_shape, self.kernel_h, self.kernel_w, self.stride, self.pad) # (N, C_in, H, W) # 4. 计算 dL/dweight:将 dcol 按卷积核结构聚合 # 先将 dcol 重塑为 (N, H_out*W_out, C_in*K*K) dcol_reshaped = dcol.reshape(N, H_out*W_out, -1) # 输入数据 im2col 后的 col_matrix 形状为 (C_in*K*K, N*H_out*W_out) # 故 dL/dweight = col_matrix @ dcol_reshaped.T 的转置?不,正确是: # dL/dweight = sum_over_batch_and_position( input_window * dout_pixel ) # 实现:对每个输出位置,取 input_window 和 dout_pixel,外积后累加 self.dweight = np.zeros_like(self.weights) # (C_out, C_in, K, K) input_padded = np.pad(self.x, ((0,0), (0,0), (self.pad,self.pad), (self.pad,self.pad))) for i in range(H_out): for j in range(W_out): # 提取输入窗口 window = input_padded[:, :, i*self.stride:i*self.stride+self.kernel_h, j*self.stride:j*self.stride+self.kernel_w] # (N, C_in, K, K) # 提取该位置的 dout dout_ij = dout[:, :, i, j] # (N, C_out) # 对每个样本和每个输出通道,计算外积并累加 for n in range(N): for k in range(C_out): self.dweight[k] += window[n] * dout_ij[n, k] # 5. 计算 dL/dbias = sum over N,H_out,W_out of dout self.dbias = np.sum(dout, axis=(0,2,3)) # (C_out,) return dx

关键洞察:dL/dweight的累加逻辑是window[n] * dout_ij[n, k],即每个样本的输入窗口与对应输出通道梯度的逐元素乘,再对所有样本求和。这直接对应卷积的定义,而非矩阵乘法的抽象。


4. 避坑指南:五个血泪经验总结的常见问题与排查路径

4.1 现象:训练初期 loss 不下降,甚至震荡上升

原因:学习率设置过高,导致权重更新幅度过大,越过最优解。尤其在卷积层,权重初始化若为np.random.randn()(标准差为1),而输入特征值范围在[0,1],则第一层输出可能达3*3*3*1=27量级,梯度爆炸。
解决:采用 He 初始化(np.random.randn(*shape) * np.sqrt(2.0 / fan_in)),其中fan_in = C_in * K * K。例如卷积核(64,3,3,3)的fan_in=27,则权重初始化标准差为sqrt(2/27)≈0.27。在Conv2D.__init__()中添加:

fan_in = in_channels * kernel_h * kernel_w self.weights = np.random.randn(out_channels, in_channels, kernel_h, kernel_w) * np.sqrt(2.0 / fan_in)

4.2 现象:dL/dx形状与输入x不匹配,报ValueError: operands could not be broadcast together

原因:反向传播中dx的维度未正确还原。典型错误是在池化层backward中,误将dout的(N,C,H,W)直接 reshape 为(N,C,H*stride,W*stride),而未考虑stride导致的尺寸膨胀关系。
解决:牢记池化层输入尺寸H_in = H_out * stride + (pool_h - stride)。若pool_h=2,stride=2,则H_in = H_out * 2;若pool_h=3,stride=2,则H_in = H_out * 2 + 1。dx初始化必须为np.zeros((N,C,H_in,W_in)),并在col2im或手动索引中严格按此尺寸赋值。

4.3 现象:dL/dweight梯度为全零或极小值,模型不学习

原因:Softmax 层forward中未做x - np.max(x)的数值稳定处理,导致exp(x)溢出为inf,后续log(inf)或0/0产生nan,梯度传播中断。
解决:在SoftmaxWithLoss.forward中强制加入:

x_shifted = x - np.max(x, axis=1, keepdims=True) # 关键! exp_x = np.exp(x_shifted)

并添加检查:assert not np.any(np.isnan(exp_x)) and not np.any(np.isinf(exp_x))。

4.4 现象:im2col输出col_matrix的列数与理论H_out*W_out不符

原因:out_h、out_w计算时未正确处理整除。例如H=32, K=3, stride=1, pad=0时,out_h = (32-3)//1 +1 = 30正确;但若pad=1,则out_h = (32+2-3)//1 +1 = 32,而实际应为(32+2*1-3)//1 +1 = 32,没错。真正陷阱是:当(H+2*pad-K)不能被stride整除时,向下取整会丢弃部分区域。本项目默认使用//整除,符合大多数框架行为,但需确保输入尺寸兼容。
解决:在im2col开头添加断言:

assert (H + 2*pad - kernel_h) % stride == 0, f"Height dimension mismatch: {(H+2*pad-kernel_h)} not divisible by {stride}"

或改用math.floor()并接受不完整覆盖。

4.5 现象:验证集 accuracy 为 10%,远低于随机猜测(如10分类应为10%)

原因:标签未转换为 one-hot 编码。SoftmaxWithLoss.backward要求y_true为(N,C)的 one-hot,但数据加载时可能只给了(N,)的整数标签(如[3,0,7,...])。
解决:在训练循环前添加 one-hot 转换:

def to_one_hot(labels, num_classes): return np.eye(num_classes)[labels] # labels shape (N,), output (N, num_classes) # 使用 train_labels_onehot = to_one_hot(train_labels, num_classes=10)

并验证np.sum(train_labels_onehot, axis=1)全为 1。


5. 模型组装与训练验证:构建端到端流程并用 MNIST 快速验证

5.1 网络架构组装:从层到模型的封装逻辑

本项目不提供Sequential类,而是用函数式组合明确每一层的输入输出连接。以经典 LeNet-5 结构为例(输入 1×28×28):

# 初始化各层 conv1 = Conv2D(in_channels=1, out_channels=6, kernel_h=5, kernel_w=5, stride=1, pad=0) relu1 = Relu() pool1 = MaxPooling2D(pool_h=2, pool_w=2, stride=2) conv2 = Conv2D(in_channels=6, out_channels=16, kernel_h=5, kernel_w=5, stride=1, pad=0) relu2 = Relu() pool2 = MaxPooling2D(pool_h=2, pool_w=2, stride=2) # 展平:28->24->12->8->4,故 pool2 后为 (N,16,4,4) -> (N,256) affine1 = Affine(W=np.random.randn(256, 120) * np.sqrt(2.0/256), b=np.zeros(120)) relu3 = Relu() affine2 = Affine(W=np.random.randn(120, 84) * np.sqrt(2.0/120), b=np.zeros(84)) relu4 = Relu() affine3 = Affine(W=np.random.randn(84, 10) * np.sqrt(2.0/84), b=np.zeros(10)) loss_layer = SoftmaxWithLoss() # 前向传播函数 def predict(x): out = conv1.forward(x) # (N,1,28,28) -> (N,6,24,24) out = relu1.forward(out) # inplace or copy out = pool1.forward(out) # (N,6,24,24) -> (N,6,12,12) out = conv2.forward(out) # (N,6,12,12) -> (N,16,8,8) out = relu2.forward(out) out = pool2.forward(out) # (N,16,8,8) -> (N,16,4,4) out = out.reshape(out.shape[0], -1) # (N,16,4,4) -> (N,256) out = affine1.forward(out) # (N,256) -> (N,120) out = relu3.forward(out) out = affine2.forward(out) # (N,120) -> (N,84) out = relu4.forward(out) out = affine3.forward(out) # (N,84) -> (N,10) return out # 训练循环 lr = 0.01 for epoch in range(10): for i in range(0, len(x_train), batch_size): x_batch = x_train[i:i+batch_size] t_batch = train_labels_onehot[i:i+batch_size] # 前向 y = predict(x_batch) loss = loss_layer.forward(y, t_batch) # 反向(从 loss 层开始,逐层 backward) dy = loss_layer.backward() # dL/dy, shape (N,10) dy = affine3.backward(dy) # dL/dx before affine3, shape (N,84) dy = relu4.backward(dy) dy = affine2.backward(dy) # (N,120) dy = relu3.backward(dy) dy = affine1.backward(dy) # (N,256) # 重塑为 (N,16,4,4) dy = dy.reshape(-1, 16, 4, 4) dy = pool2.backward(dy) # (N,16,8,8) dy = relu2.backward(dy) dy = conv2.backward(dy) # (N,6,12,12) dy = pool1.backward(dy) # (N,6,24,24) dy = relu1.backward(dy) dx = conv1.backward(dy) # (N,1,28,28) # 参数更新(SGD) conv1.weights -= lr * conv1.dweight conv1.bias -= lr * conv1.dbias conv2.weights -= lr * conv2.dweight conv2.bias -= lr * conv2.dbias affine1.W -= lr * affine1.dW affine1.b -= lr * affine1.db affine2.W -= lr * affine2.dW affine2.b -= lr * affine2.db affine3.W -= lr * affine3.dW affine3.b -= lr * affine3.db if i % 1000 == 0: print(f"Epoch {epoch}, Batch {i}, Loss: {loss:.4f}")

注意:dy在pool2.backward()后需reshape回四维,因为affine1的输入是展平的。这是手动组装的代价,也是理解数据流的必经之路。

5.2 MNIST 快速验证:5 分钟跑通 baseline

使用tensorflow.keras.datasets.mnist.load_data()加载数据(仅作数据源,不引入 TF 计算),预处理为(N,1,28,28)并归一化到[0,1]:

import numpy as np from tensorflow.keras.datasets import mnist # 加载并预处理 (x_train, t_train), (x_test, t_test) = mnist.load_data() 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) # (60000,1,28,28) x_test = x_test.reshape(-1, 1, 28, 28) t_train_onehot = to_one_hot(t_train, 10) t_test_onehot = to_one_hot(t_test, 10) # 训练后验证 def evaluate(x, t): acc = 0 for i in range(0, len(x), 100): batch_x = x[i:i+100] batch_t = t[i:i+100] y = predict(batch_x) pred = np.argmax(y, axis=1) true = np.argmax(batch_t, axis=1) acc += np.sum(pred == true) return acc / len(x) test_acc = evaluate(x_test, t_test_onehot) print(f"Test Accuracy: {test_acc:.4f}") # 正常应达 98%+

若test_acc < 0.9,优先检查:①to_one_hot是否正确;②conv1的in_channels=1是否匹配输入;③pool1后尺寸是否为12×12(28→24→12);④affine1的W输入维度是否为16*4*4=256。

5.3 梯度检查:用数值梯度验证反向传播正确性

最硬核的验证方式:对某个权重w[i,j,k,l],微扰±ε,计算loss(w+ε) - loss(w-ε) / (2ε),与解析梯度dw[i,j,k,l]比较。本项目提供工具函数:

def numerical_gradient(layer, param_name, x, t, eps=1e-4): """计算 layer.param_name 的数值梯度""" param = getattr(layer, param_name) grad_numerical = np.zeros_like(param) it = np.nditer(param, flags=['multi_index'], op_flags=['readwrite']) while not it.finished: idx = it.multi_index # 保存原值 tmp_val = param[idx] # 计算 f(x+eps) param[idx] = tmp_val + eps loss_plus = loss_layer.forward(predict(x), t) # 计算 f(x-eps) param[idx] = tmp_val - eps loss_minus = loss_layer.forward(predict(x), t) # 数值梯度 grad_numerical[idx] = (loss_plus - loss_minus) / (2 * eps) # 恢复原值 param[idx] = tmp_val it.iternext() return grad_numerical # 使用示例:验证 conv1.weights 的梯度 x_sample = x_train[:2] # 取2个样本 t_sample = t_train_onehot[:2] grad_analytic = conv1.dweight # 解析梯度 grad_numeric = numerical_gradient(conv1, 'weights', x_sample, t_sample) # 比较 diff = np.max(np.abs(grad_analytic - grad_numeric)) print(f"Max gradient diff: {diff:.2e}") # 应 < 1e-5

血泪经验:首次运行数值梯度检查时,diff常为1e-1,原因多是predict()中未固定随机种子,导致 dropout(若添加)或初始化波动。本项目无 dropout,故diff > 1e-5必定存在反向传播 bug。


6. 进阶技巧:如何用此代码快速适配新任务?三个实战场景与参数调整表

6.1 场景一:将输入从灰度图扩展为 RGB 图像

MNIST 是单通道,但真实数据常为三通道。只需修改第一层卷积的in_channels并

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询