纯NumPy手写CNN:MNIST识别与反向传播原理详解
发布时间:2026/9/28 14:16:37来源:尧图网络
简介纯NumPy手写卷积神经网络未借助TensorFlow、PyTorch等任何深度学习框架从零实现CNN的前向传播与反向传播用于MNIST手写数字识别。从数据加载、卷积、池化到全连接与梯度更新每处均以NumPy原生操作呈现便于单步调试直观观察数据形状与参数变化适合希望彻底吃透CNN底层原理的Python学习者和算法研究人员。配套说明文档对网络结构、参数更新和训练流程做了梳理代码结构简洁、注释直观可边读文档边对照实现。资源共10个文件以Python脚本、Word说明文档及PyCharm工程配置文件为主另含少量XML配置与.gitignore整体大小约249KB轻量无负担。已有3428人学习浏览适合用来入门CNN底层实现也能为后续阅读深度学习框架源码打下扎实基础。1. 纯numpy实现手写数字识别为什么我劝你先别用PyTorchMNIST手写数字识别是卷积神经网络入门最常见的练手项目但大部分人的第一版代码是用PyTorch跑通的——框架里一个nn.Conv2d就把卷积层解决了参数怎么流动、梯度怎么反传全程是个黑匣子。我见过不少工程师面试被问“卷积层反向传播怎么实现”模型明明跑过几十遍却一句都说不出来。纯numpy实现卷积神经网络就是把这个黑匣子一层层拆开把卷积、池化、全连接、反向传播全部亲手写一遍。这篇笔记适合两种人准备算法面试、需要真正讲清楚原理的同学以及工作中被框架性能问题卡住、想理解每一层到底在干什么的工程师。它会告诉你这套代码怎么组织、每个算子的前向和反向怎么写、参数怎么设以及那些只在手撕代码时才会踩到的坑。2. 从零搭CNN前向传播卷积、池化、全连接的numpy实现网络结构固定成经典的一套conv1(3x3, pad1) → relu → pool(2x2) → conv2(3x3, pad1) → relu → pool(2x2) → flatten → linear(10)。这就是常说的卷积神经网络结构图的逐层展开。输入是28x28的灰度图第一层卷积保持尺寸不变输出8个通道池化后变成14x14第二层卷积输出16个通道池化后变成7x7最后展平成16x7x7784维向量接一个输出10类的全连接层。每层为什么这么设计下面跟着代码走一遍就清楚了。2.1 im2col把卷积变成矩阵乘法避免四重循环CNN的卷积操作最朴素的写法是四个嵌套循环遍历batch、输入通道、输出高度、输出宽度每个位置做一次卷积核大小的乘加。在纯numpy里这样跑MNIST一个epoch足够等到怀疑人生。常见做法是用im2col技巧把每个卷积窗口摊平成一行构造一个大矩阵然后用一次矩阵乘法完成所有窗口的乘加。def im2col(inputs, kernel_h, kernel_w, stride1, pad0): 把输入图像切成卷积窗口每个窗口展开成一行。 inputs: (N, C, H, W) 的 numpy 数组N是batch大小 返回: (N * out_h * out_w, C * kernel_h * kernel_w) 的二维矩阵 N, C, H, W inputs.shape out_h (H 2 * pad - kernel_h) // stride 1 out_w (W 2 * pad - kernel_w) // stride 1 img_pad np.pad( inputs, ((0, 0), (0, 0), (pad, pad), (pad, pad)), modeconstant ) cols np.zeros((N, C, kernel_h, kernel_w, out_h, out_w)) for h_idx in range(kernel_h): for w_idx in range(kernel_w): # 用切片步长直接取出每个窗口对应位置的像素 cols[:, :, h_idx, w_idx, :, :] img_pad[ :, :, h_idx : h_idx stride * out_h : stride, w_idx : w_idx stride * out_w : stride ] # 把窗口维度展平结果每一行就是一个卷积窗口 cols cols.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1) return cols这段代码的核心逻辑是把“滑动窗口”变成“索引切片”。种写法里没有遍历每一个输出像素而是把每个卷积核位置h_idx, w_idx上、所有输出位置的像素一次性取出来放在cols的第4、5维里。transpose之前cols的形状是(N, C, kh, kw, out_h, out_w)转置成(N, out_h, out_w, C, kh, kw)再reshape每一行才是按“输出位置”组织的完整窗口数据。这里最容易写错的就是transpose的参数顺序numpy多维数组相乘时转置顺序错了维度可能刚好对得上但数据全部串位后面怎么调都白费。有了im2col卷积前向只剩下一个矩阵乘def conv_forward(x, w, b, stride1, pad0): 卷积前向。 x: (N, C, H, W)w: (F, C, kh, kw)b: (F,) 返回 out: (N, F, H_out, W_out) 和 cols反向传播要用 N, C, H, W x.shape F, _, kh, kw w.shape cols im2col(x, kh, kw, stride, pad) # (N*out_h*out_w, C*kh*kw) w_col w.reshape(F, -1) # (F, C*kh*kw) out cols.dot(w_col.T) b # (N*out_h*out_w, F) out_h (H 2 * pad - kh) // stride 1 out_w (W 2 * pad - kw) // stride 1 out out.reshape(N, out_h, out_w, F).transpose(0, 3, 1, 2) return out, colsconv_forward要把cols原样返回原因在于反向传播算w的梯度时dw dout.T.dot(cols)没有cols就得重新做一次im2col白白浪费时间。参数里stride1, pad1是配套的28x28的输入经过这两个参数组合输出还是28x28如果pad0输出会缩成26x26后续池化和全连接层的维度全都得跟着改。2.2 池化与全连接max池化的mask记录和参数初始化池化层用max池化窗口大小2、步长2。前向过程需要记录每个窗口里最大值的位置这个索引是反向传播的关键。def pool_forward(x, pool_size2, stride2): max池化前向。x: (N, C, H, W)返回池化结果和mask索引。 N, C, H, W x.shape out_h (H - pool_size) // stride 1 out_w (W - pool_size) // stride 1 x_reshaped x.reshape(N, C, out_h, pool_size, out_w, pool_size) out x_reshaped.max(axis(3, 5)) # mask 记录最大值位置形状同 x_reshaped mask (x_reshaped x_reshaped.max(axis(3, 5), keepdimsTrue)) return out, mask这里用reshape把每个池化窗口拆到独立的维度上axis(3, 5)取的恰好是每个窗口内部的像素位置。之所以选max而不是average池化一是平移不变性更好二是实现里天然带出“谁最大谁拿梯度”的稀疏特性反向很简单。在小batch训练时average池化的梯度更平滑但max池化在这个任务里效果足够实现也更干净。全连接层的前向就是普通的矩阵乘法加上ReLU激活和参数初始化def linear_forward(x, w, b): 全连接前向。x: (N, in_dim)w: (in_dim, out_dim) return x.dot(w) b def he_init(fan_in, fan_out): He初始化适配ReLU。标准差取 sqrt(2 / fan_in)。 return np.random.randn(fan_in, fan_out) * np.sqrt(2.0 / fan_in)参数初始化这里是个大坑。全部置零会导致同一层所有神经元输出相同梯度也相同网络退化成一个线性模型用Xavier初始化在ReLU下容易梯度消失因为ReLU把一半神经元置零后输出方差减半。He初始化的sqrt(2 / fan_in)就是针对这一点的修正。MNIST这种小规模任务初始化方式对收敛速度影响明显对最终准确率影响没那么大但初始化不对确实能让你看到loss卡住不动的玄学现象。3. 反向传播的numpy写法梯度如何在三层网络里倒流反向传播的核心是链式法则在每一层上的落地。手撕CNN时最难的不是公式推导而是把“数学上的梯度”翻译成“numpy数组的shape和运算”一个维度对不上整个链就断了。这一章按从后往前的顺序从损失函数一路推到第一个卷积层。3.1 交叉熵损失的梯度为什么是 (prob - onehot) / N多分类任务用softmax加交叉熵这里有一个经典结论softmax cross entropy组合的梯度恰好是(prob - onehot) / N。推导只有五步但实现时更实用的是直接记住这个结果def softmax_cross_entropy_loss(pred, labels): 计算softmax交叉熵损失和梯度。 pred: (N, num_classes)labels: (N,)labels是整数索引 返回: (loss, dout)dout会直接作为全连接层的反传输入 # 数值稳定减去每行最大值防止 exp 溢出 shifted pred - np.max(pred, axis1, keepdimsTrue) exp_pred np.exp(shifted) prob exp_pred / np.sum(exp_pred, axis1, keepdimsTrue) N pred.shape[0] loss -np.mean(np.log(prob[np.arange(N), labels] 1e-8)) onehot np.zeros_like(prob) onehot[np.arange(N), labels] 1.0 dout (prob - onehot) / N return loss, dout代码里做了两件容易被忽略的事。第一是数值稳定pred里的值如果到几十np.exp(pred)直接溢出成inf减去每行最大值后最大项变0指数最大为1彻底避免溢出。第二是dout要除以N因为loss用的是np.mean对batch取了平均反向梯度也必须做对应的缩放否则学习率的名义值和实际效果会对不上调参时你会觉得lr特别“虚”。全连接层的反传是全篇最标准的矩阵运算def linear_backward(dout, x, w): 全连接反传。 dout: (N, out_dim) 从上一层传来 x: 本层前向的输入缓存w: 本层权重 返回: dx, dw, db dx dout.dot(w.T) dw x.T.dot(dout) db np.sum(dout, axis0) return dx, dw, db三个梯度的shape要对着前向检查dx要和输入x同shapedw要和w同shapedb和b同shape。db用np.sum(axis0)是因为偏置对每个样本都广播了反向时要把所有样本的梯度收回来。写完之后可以用一个笨办法验证随机初始化一个输入和权重用数值差分(f(xh)-f(x-h))/2h对比解析梯度差在1e-5以内才算对这个习惯能省下后面几天的排查时间。3.2 池化层的反传mask拾取与col2img还原max池化的反传逻辑很直接前向把梯度给了窗口里最大的那个元素反传时也只把梯度回传给那个元素其余位置补零。实现上不用for循环用前向保存的mask做布尔索引def pool_backward(dout, mask, x_shape, pool_size2, stride2): max池化反传。dout: (N, C, out_h, out_w)mask从前向传入。 N, C, H, W x_shape out_h (H - pool_size) // stride 1 out_w (W - pool_size) // stride 1 dx np.zeros(x_shape) dout_reshaped dout.reshape(N, C, out_h, 1, out_w, 1) dx_reshaped dx.reshape(N, C, out_h, pool_size, out_w, pool_size) # 把 dout 广播到每个池化窗口只保留 mask 为 True 的位置 dx_reshaped[mask] np.broadcast_to(dout_reshaped, mask.shape)[mask] return dx这段代码里dout_reshaped通过reshape在池化窗口对应的两个维度上插入了大小为1的轴np.broadcast_to把它广播成和mask完全相同的形状。布尔索引dx_reshaped[mask] ...把梯度精确写回最大值位置。一个需要注意的行为如果池化窗口里出现两个相等的最大值mask会把两个位置都标记为True梯度被均分到两个位置。这种实现方式是常见做法行为等同于平均分配通常不引发问题如果想让行为严格唯一可以用argmax记录索引但代码会绕一些MNIST场景下没必要。卷积层的反传是整篇代码里最绕的部分核心是col2img即im2col的逆运算。前向把每个窗口摊平成行反传要把梯度行还原回输入平面而且同一个输入像素会被多个窗口覆盖梯度必须累加def col2img(cols, input_shape, kernel_h, kernel_w, stride1, pad0): im2col 的逆过程。cols: (N*out_h*out_w, C*kh*kw) N, C, H, W input_shape out_h (H 2 * pad - kernel_h) // stride 1 out_w (W 2 * pad - kernel_w) // stride 1 cols cols.reshape(N, out_h, out_w, C, kernel_h, kernel_w) cols cols.transpose(0, 3, 4, 5, 1, 2) # 多预留出 stride - 1 的空间保证切片索引不越界 img np.zeros((N, C, H 2 * pad stride - 1, W 2 * pad stride - 1)) for h_idx in range(kernel_h): for w_idx in range(kernel_w): img[:, :, h_idx : h_idx stride * out_h : stride, w_idx : w_idx stride * out_w : stride] cols[:, :, h_idx, w_idx] # 裁掉padding区域 return img[:, :, pad:pad H, pad:pad W] def conv_backward(dout, x, w, cols, stride1, pad0): 卷积反传。dout: (N, F, out_h, out_w)cols 是前向缓存 N, C, H, W x.shape F, _, kh, kw w.shape dout_reshaped dout.transpose(0, 2, 3, 1).reshape(-1, F) db np.sum(dout_reshaped, axis0) dw dout_reshaped.T.dot(cols).reshape(F, C, kh, kw) dcol dout_reshaped.dot(w.reshape(F, -1)) dx col2img(dcol, x.shape, kh, kw, stride, pad) return dx, dw, db提示反向传播里col2img必须用累加不能直接赋值。原因很简单——stride小于卷积核尺寸时同一个输入像素会出现在多个卷积窗口里每个窗口反传回来的梯度都该算到它头上。用会把前一个窗口的梯度覆盖掉训练时表现为loss忽大忽小运气差的时候直接梯度爆炸。conv_backward里dw的计算是dout_reshaped.T.dot(cols)理解这个矩阵乘的维度也就理解了整个人——dout_reshaped.T是(F, N*out_h*out_w)cols是(N*out_h*out_w, C*kh*kw)乘出来的(F, C*kh*kw)正好是卷积核梯度。这也是前向必须缓存cols的原因没有它反传这一步就要把窗口重新展开一遍。4. 训练主循环与超参数让手写数字识别真正收敛前向和反向的算子都齐了接下来要把它们串成一个可训练的系统。这一章解决两个问题MNIST的原始数据怎么读进来、训练循环和超参怎么设才能让loss顺利降下去。4.1 用struct解包MNIST28x28灰度图怎么变成模型输入MNIST官网下载的是四个gz压缩文件内部是IDX格式的二进制。直接np.loadtxt是读不了的标准做法是用gzip解压后按字节解析import gzip import struct import numpy as np def load_mnist(images_path, labels_path): 从MNIST原始gz文件读取。 路径指向形如 train-images-idx3-ubyte.gz 的实际文件位置。 返回 (images, labels)images形状是 (num, 28, 28) with gzip.open(labels_path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) with gzip.open(images_path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8) images images.reshape(num, rows, cols) return images, labelsIDX格式不复杂前4字节是magic number用来校验文件类型之后标签文件跟一个uint32的样本数量图像文件加三个uint32数量、行数、列数。struct.unpack(II, ...)里的表示大端字节序MNIST所有整数都是大端存储这个写漏了读出来的样本总数会错得离谱而且报错不容易发现。读进来之后必须做归一化def preprocess(images): 把 uint8 灰度图转成 float32 并归一化到 [0, 1]。 return images.reshape(-1, 1, 28, 28).astype(np.float32) / 255.0归一化这步看起来无关紧要实际是训练能不能收敛的分水岭。输入不除以255像素值范围0-255第一层卷积输出直接变成几百的量级softmax输入过大导致梯度极小loss基本纹丝不动。顺便处理成(N, 1, 28, 28)的四维结构直接对接前面写的conv_forward。astype(np.float32)务必加上uint8图像在后续矩阵乘法里会以整数运算溢出到完全不可控。4.2 训练循环和超参数epoch、batch_size、学习率怎么定把前向和反向后拼起来之前先初始化参数。这里用8个卷积核起步MNIST简单16个就够用核数太多徒增计算量def init_params(): params {} # 第一层卷积8个3x3核输入1通道 params[w1] np.random.randn(8, 1, 3, 3) * np.sqrt(2.0 / (1 * 9)) params[b1] np.zeros(8) # 第二层卷积16个3x3核输入8通道 params[w2] np.random.randn(16, 8, 3, 3) * np.sqrt(2.0 / (8 * 9)) params[b2] np.zeros(16) # 经过两次池化特征图是7x716通道 - 展平784维 - 10类 params[w3] np.random.randn(16 * 7 * 7, 10) * np.sqrt(2.0 / (16 * 7 * 7)) params[b3] np.zeros(10) return params全连接层的输入维度要算清楚28 - conv(pad1) - 28 - pool - 14 - conv(pad1) - 14 - pool - 7所以是16*7*7784。如果是自己换网络结构每次改动后先把forward打一层print(shape)确认这个数字对不对。完整训练循环长这样def train(X_train, y_train, X_val, y_val, params, epochs10, batch_size64, lr0.03): 纯numpy的SGD训练主循环。 for epoch in range(epochs): # 每个epoch打乱一次训练数据 perm np.random.permutation(len(X_train)) num_batches len(X_train) // batch_size epoch_loss 0.0 for i in range(num_batches): idx perm[i * batch_size:(i 1) * batch_size] x X_train[idx] y y_train[idx] # 前向conv1relupool - conv2relupool out1, cols1 conv_forward(x, params[w1], params[b1], stride1, pad1) a1 np.maximum(out1, 0) pool1, mask1 pool_forward(a1, 2, 2) out2, cols2 conv_forward(pool1, params[w2], params[b2], stride1, pad1) a2 np.maximum(out2, 0) pool2, mask2 pool_forward(a2, 2, 2) fc_in pool2.reshape(pool2.shape[0], -1) fc_out linear_forward(fc_in, params[w3], params[b3]) # 损失与初始梯度 loss, dout softmax_cross_entropy_loss(fc_out, y) # 反向 dfc_in dout.dot(params[w3].T) dw3 fc_in.T.dot(dout) db3 np.sum(dout, axis0) dpool2 dfc_in.reshape(pool2.shape) da2 pool_backward(dpool2, mask2, a2.shape, 2, 2) dout2 da2 * (out2 0) dx2, dw2, db2 conv_backward(dout2, pool1, params[w2], cols2, stride1, pad1) da1 pool_backward(dx2, mask1, a1.shape, 2, 2) dout1 da1 * (out1 0) dx1, dw1, db1 conv_backward(dout1, x, params[w1], cols1, stride1, pad1) # SGD更新 params[w1] - lr * dw1 params[b1] - lr * db1 params[w2] - lr * dw2 params[b2] - lr * db2 params[w3] - lr * dw3 params[b3] - lr * db3 epoch_loss loss # 每个epoch结束做验证 val_acc evaluate(X_val, y_val, params) print(fepoch {epoch 1}/{epochs} loss {epoch_loss / num_batches:.4f} val_acc {val_acc:.4f}) return paramsReLU反传那行dout2 da2 * (out2 0)是向量化写法前向输出大于0的位置梯度原样通过小于等于0的位置梯度直接置零。验证函数按batch切分避免大矩阵一次性占用太多内存def evaluate(X, y, params, batch_size128): correct 0 for i in range(0, len(X), batch_size): x X[i:ibatch_size] out1, cols1 conv_forward(x, params[w1], params[b1], stride1, pad1) a1 np.maximum(out1, 0) pool1, _ pool_forward(a1, 2, 2) out2, cols2 conv_forward(pool1, params[w2], params[b2], stride1, pad1) a2 np.maximum(out2, 0) pool2, _ pool_forward(a2, 2, 2) fc_in pool2.reshape(pool2.shape[0], -1) fc_out linear_forward(fc_in, params[w3], params[b3]) pred np.argmax(fc_out, axis1) correct np.sum(pred y[i:ibatch_size]) return correct / len(X)超参数推荐值和原因直接看表超参数推荐值说明学习率 lr0.01 ~ 0.05太大会震荡甚至NaN太小收敛慢到怀疑人生batch_size64 ~ 128太小梯度噪声大太大每次更新的方向太钝epochs10 ~ 20这个规模10轮基本到平台继续跑收益很低卷积核大小3x3 或 5x53x3参数少、堆叠感受野够用初始化Hesqrt(2/fan_in)ReLU下比Xavier稳loss更容易降判断收敛有个实用标准MNIST有10类随机初始化时softmax输出接近均匀分布-ln(0.1) ≈ 2.3026。所以训练开始时loss应该在2.3附近大概两三个epoch之后明显下降。如果loss从某个epoch开始不降反升大概率是lr太大或过拟合把lr调小一档试试。验证集的准确率每个epoch打印一次训练集准确率涨、验证集不涨甚至跌就是过拟合信号这时候该加dropout第六章写或者提前停止。5. 纯numpy CNN避坑清单维度错乱、loss不降、慢到怀疑人生手撕代码最耗时间的不是写是调。这一章把我踩过的、读者反复问的问题整理成五条每条都是“现象 - 原因 - 解决”的结构照着排查能省下好几个下午。5.1 维度报错im2col算出来的out_h是负数或0现象运行im2col时reshape报错提示“cannot reshape array of size 0”或者conv_forward里out_h算出来是0。原因(H 2 * pad - kernel_h) // stride 1小于1说明pad没配够、卷积核比输入还大或者stride设置和预期不符。最常见的是改了卷积核尺寸忘了同步改pad3x3核配pad028x28变成26x26后续全连接层的维度全部失效。解决在conv_forward入口直接加断言把错误暴露在最前面而不是让它在reshape处炸out_h (H 2 * pad - kh) // stride 1 out_w (W 2 * pad - kw) // stride 1 assert out_h 0 and out_w 0, f输出尺寸非法: out_h{out_h}, out_w{out_w}检查 kernel/stride/pad5.2 loss不降一直卡在2.3026附近现象训练了三五个epochloss稳在2.3026附近掉不下去验证集准确率10%左右——和瞎猜一样。原因2.3026就是-ln(0.1)说明softmax输出始终是均匀分布模型根本没学到东西。按顺序排查输入没归一化像素还在0-255、学习率太小梯度更新量级远小于参数量级、参数初始化方式不对。解决第一步确认preprocess里的/ 255.0做了打印一下x.min(), x.max()第二步确认参数用He初始化而不是np.zeros第三步把lr从0.01往上调观察loss是否松动。三条检查完之后理论上loss不可能不动。5.3 池化反传梯度爆炸loss跳到几百上千现象训练过程中loss突然飙到几百甚至上千之后彻底不收敛。原因pool_backward里mask标记了多个位置梯度被放大了更常见的是col2img里用了而不是导致卷积层反传的梯度丢失大半参数更新混乱。前一种情况出现概率低窗口内恰好有两个相等最大值后一种基本是手误。解决col2img的累加代码前面章节已经强调过用。排查时在conv_backward返回后打印dw1的均值和最大绝对值如果数量级和dout差了几十倍检查是不是写丢了。5.4 训练慢到像死循环一个epoch半小时现象代码能跑通但每轮epoch耗时以半小时计完全没法调参。原因卷积实现用了for循环遍历每个输出位置的像素而不是im2col一次性取窗口。这也是总有人问“numpy和list比快在哪”的根源——快在向量化。纯for循环在纯numpy里不会自动加速numpy真正的优势是一次C级别循环完成整批数据的运算。解决换用im2col让卷积变成一次矩阵乘。如果已经用了im2col还慢检查是不是在im2col内部的循环里写了大量Python层的append操作正确做法是预分配np.zeros然后切片赋值。5.5 numpy环境问题版本不匹配、import报错现象import numpy直接报ModuleNotFoundError: No module named numpy或者代码在别人机器上跑通自己机器上报“numpy版本不匹配”还有在安装步骤时卡在installing backend dependencies很久不动。原因全局Python环境被多个项目污染版本冲突安装时网络源不通畅导致后台依赖编译卡住。解决用venv建独立环境不要往系统Python里乱装包。安装用国内镜像源加速能绕开backend dependencies卡住的超时问题。装完立刻检查版本python -m venv cnn_env source cnn_env/bin/activate pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numpy python -c import numpy; print(numpy.__version__)vscode用户额外注意右下角解释器必须选到cnn_env这个虚拟环境否则装好了也import不到。6. 验证与进阶加dropout的纯numpy写法以及BN该不该加模型在主循环里跑通之后下一步是做验证和加正则。验证方法很简单训练集和验证集准确率分开打印如果两者差距持续拉大就是过拟合信号。MNIST的测试集只有一万张最好再把训练集里切出一部分当验证集别拿测试集反复调参否则最后评估的结果就不干净了。过拟合最便宜的解药是dropout纯numpy加在 flatten 之后、全连接层之前三行代码# 训练时inverted dropout除以keep_prob保持期望不变 keep_prob 0.5 drop_mask (np.random.rand(*fc_in.shape) keep_prob) / keep_prob fc_in_drop fc_in * drop_mask # 推理时直接使用原输入不再做任何缩放 fc_out linear_forward(fc_in, params[w3], params[b3])注意反向传播里梯度要穿过drop_maskdfc_in dout.dot(params[w3].T) * drop_mask。乘以drop_mask是让被置零的神经元对应梯度也归零保持训练和推理行为一致。这个细节忘了写dropout几乎不起作用。批归一化BN是另一个常被问要不要加的模块。我的建议是纯numpy教学版本先别加。BN在训练时要维护running_mean和running_var反向传播要通过归一化链算回每个输入的梯度代码量比卷积反传还大而且小batch下行为不稳定。如果确实要加位置放在卷积之后、ReLU之前按通道维度做归一化这属于第二个迭代版本的事第一版先把dropout和早停用明白准确率就足够说明问题了。最后说一个陪我很多年的调试习惯每写一层forward在返回前打印一次输出shape每写完一个反传打印一次梯度范数。我第一次手撕这个网络时卡在一个transpose参数顺序上整整一个下午后来养成这个习惯再也没为维度问题熬过夜。麻烦是麻烦一点但比对着理论推导猜省时间得多。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网