拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

用NumPy从零实现三层BP神经网络:MNIST手写数字识别完整复盘

用NumPy从零实现三层BP神经网络:MNIST手写数字识别完整复盘 说到BP神经网络和手写数字识别这大概是机器学习入门路上最经典的组合了。MNIST手写数字识别作为图像分类的“hello world”训练集6万张28×28的灰度图测试集1万张数据量刚够你折腾但又不至于等半天才能看到结果。我最初也是直接上PyTorch定义好Linear层、激活函数、交叉熵损失一个epoch下去准确率就很好看可是当朋友问我“反向传播里每个矩阵到底在干嘛”的时候我突然发现自己只能背出公式没法从直觉上解释清楚。那之后我给自己立了个规矩不用深度学习框架只借助numpy从零实现一个三层BP神经网络让这个网络自己去识别手写数字甚至不调用sklearn的MLPClassifier。这篇博客就是整个过程的完整复盘包括数学推导、代码实现、数据预处理、调参踩坑特别适合已经会调库但还没真正吃透BP原理的人。花了几个晚上把它跑通之后最大的感受是框架把最核心的链式法则藏得太好了手动实现一次你才会真正理解什么叫误差反向传播。1. 为什么还要手搓一个三层BP直接上框架不香吗1.1 手搓带来的第一个能力可调试的直觉我刚学PyTorch的时候照着教程做手写数字识别LinearReLUCrossEntropyLoss训练几十个epoch准确率98%。看起来一切正常但换个数据集就露馅了——网络loss变成NaN训练loss一直不降这些情况一旦出现我完全是慌的只能去网上搜“loss nan pytorch”搜到一堆答案也不知道该信哪个。后来手写numpy版BP同样遇到loss不降的问题我却能一步步定位。打印出每一层的z、a、delta我就能看到梯度是爆炸了、消失了还是卡在某个位置。比如我碰到过训练loss稳定在0.15就不再下降打印输出层的delta之后发现数值普遍接近0——不是梯度写错而是sigmoid输出单元还没被拉开MSE在输出接近0.5的时候梯度比较温和不容易继续推。这种“亲手拆开看零件”的能力是框架很难给你的。1.2 三层到底是怎么数的先把这个说清楚网上搜“bp神经网络结构图”会看到各种画法。有人把输入层、隐藏层、输出层叫三层网络也有人把两个隐藏层输出层叫三层。我得先说清楚本文的“三层BP”指的是输入层一层隐藏层输出层也就是只有两组权重矩阵W1和W2。如果你按权重数量来数它其实是两层全连接但因为输入层也算一层所以习惯上叫三层BP。对MNIST来说输入层是784个神经元因为每张图是28×28784维的向量输出层是10个神经元分别代表数字0到9隐藏层放在中间负责把784维抽象成128维或256维的特征再映射到10个类别。这个结构简单到可以当面手画出来恰好适合用来理解BP的全部细节。1.3 为什么选MNIST而不选更大的数据集MNIST规模很克制单张图片784维训练集60000张测试集10000张。跑一个三层BP在笔记本CPU上也就是几分钟的事每epoch大概两三秒。这让你可以反复调整学习率、隐藏层大小、正则化系数每轮实验等待成本低。如果换成ImageNet那种规模的分类别说手搓BP哪怕用框架都不一定能快速迭代。另一个原因是MNIST本身很干净背景简单数字形态清晰模型效果好坏能直接反映网络实现的正确性而不需要先处理各种数据脏问题。对于验证BP实现没有比MNIST更合适的试验场了。1.4 选型的基本原则用numpy而不是我熟悉的框架选numpy的原因很直接numpy的矩阵乘法和BP公式长得一模一样。前向传播写x W1反向传播写x.T delta1公式和代码一一对应想歪都难。如果引入PyTorch即使不用autogradtorch的张量操作和广播逻辑也已经替你做了一部分事情反而不利于理解。所以全程只用numpy连MNIST的数据解析也自己写不调现成的加载库。这个过程可能少了一点“工业味”但在学习阶段绝对是值得的。2. 三层BP的数学引擎前向传播与反向传播的完整推导2.1 前向传播一批图片如何变成预测向量网络结构定下来以后我最喜欢用shape来理解每一步。假设一个batch有m张图输入矩阵X的shape是(m, 784)。W1的shape是(784, H)H是隐藏层神经元个数比如128。b1的shape是(1, H)。前向传播的第一步是线性变换加激活Z1 X W1 b1 A1 sigmoid(Z1)这里sigmoid对每个元素逐位计算把线性输出压缩到(0,1)之间。Z1的shape是(m, H)A1也一样。接着进入输出层Z2 A1 W2 b2 A2 sigmoid(Z2)W2的shape是(H, 10)A2的shape就是(m, 10)每一行表示该样本对10个类别的预测值。对sigmoid版本的网络来说预测值并不保证加起来等于1所以通常用argmax取最大下标作为最终分类结果。为什么这里敢用矩阵一次性处理整个batch而不是用for循环遍历样本因为矩阵乘法本身已经把“每个样本都做一次加权和激活”这件事批量完成了。理解这一点很重要很多人背得出公式却不明白X W1 b1其实是在同时做m次独立的线性变换。2.2 损失函数怎么衡量预测差距我最初的实现用的是经典的均方误差MSEL (1 / (2m)) * sum(||A2 - Y||^2)Y是one-hot编码后的标签矩阵shape同样是(m, 10)。例如数字3对应的Y行是[0,0,0,1,0,0,0,0,0,0]。之所以用MSE是因为经典三层BP推导里最常配的就是它配合sigmoid的导数形式比较顺。但说句实话MSE不是分类问题的最佳选择后面我会讲到换成Softmax交叉熵之后准确率明显提升。这里先按经典的来因为MSE版本最能展示BP的每一步。2.3 反向传播误差如何从输出层传回隐藏层反向传播的本质是链式法则。我可以先用一句话描述损失L对第l层权重W的梯度等于“该层输入转置”乘上“该层误差δ”。有了这个直觉推导就清晰了。输出层的误差δ2定义为delta2 (A2 - Y) * A2 * (1 - A2)这里的*是逐元素相乘也就是Hadamard积。为什么长这样(A2 - Y)是损失对输出层线性输出Z2的敏感度的前半部分而A2 * (1 - A2)是sigmoid在Z2处的导数。两者相乘就是标准化后的误差信号。输出层权重梯度就是dW2 (1/m) * A1.T delta2 db2 (1/m) * sum(delta2, axis0)这个公式很好解释delta2是输出层每个神经元的误差A1是隐藏层的输出两者相乘就得到W2每个权重的梯度方向。隐藏层的误差信号则要复杂一点因为它是通过W2反向传回来的delta1 (delta2 W2.T) * A1 * (1 - A1)为什么是delta2 W2.T因为输出层的误差要通过W2的每一列“回溯”到隐藏层的每个神经元。可以说输出层每个神经元把错误按权重分摊回上一层上一层把来自所有输出神经元的错误汇总起来再经过自己激活函数的导数缩放就得到了隐藏层的误差delta1。隐藏层权重梯度同理dW1 (1/m) * X.T delta1 db1 (1/m) * sum(delta1, axis0)最后更新参数的方式是朝负梯度方向走一步W2 - lr * dW2 b2 - lr * db2 W1 - lr * dW1 b1 - lr * db1这里lr是学习率。整个推导做完之后我才真正理解了“反向传播”四个字的含义误差在输出层生成然后通过权重矩阵一层一层往回传递每一层都用链式法则算出自己对损失函数贡献了多少“责任”并据此调整权重。2.4 参数初始化与梯度检查如果W1和W2全初始化为0所有隐藏神经元的前向输出都一样反向传播的梯度也完全相同网络就退化成了一个神经元在重复训练。所以必须随机初始化。对sigmoid网络我习惯用Xavier初始化例如self.W1 np.random.randn(784, 128) * np.sqrt(1.0 / 784) self.W2 np.random.randn(128, 10) * np.sqrt(1.0 / 128)偏置b1和b2初始化为0是没问题的因为随机权重已经打破了对称性。写完反向传播后强烈建议做一次梯度检查随便抽一个batch对某个权重参数加一个很小的epsilon比如1e-6用数值方法估计梯度再和解析梯度对比。数值梯度的公式是(L(W eps) - L(W - eps)) / (2 * eps)如果相对误差在1e-5以内说明反向传播的实现基本对了。我曾经因为漏了一个转置导致梯度形状对不上就是靠梯度检查查出来的。3. 纯numpy实现代码骨架与最容易搞反的矩阵方向3.1 类设计与参数初始化下面的代码是我跑通的第一版结构非常简单。类的属性和上面的数学公式一一对应import numpy as np class ThreeLayerBP: def __init__(self, input_size, hidden_size, output_size, lr0.1): self.input_size input_size self.hidden_size hidden_size self.output_size output_size self.lr lr self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(1.0 / input_size) self.b1 np.zeros((1, hidden_size)) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) self.b2 np.zeros((1, output_size)) def sigmoid(self, x): return 1.0 / (1.0 np.exp(-np.clip(x, -500, 500)))sigmoid里的np.clip(x, -500, 500)是我踩坑之后加上的。早期没有clip当权重初始化稍微大一点前向传播的z就可能到几百np.exp直接溢出变成无穷大loss变成NaN。加上clip之后数值稳定了很多。3.2 前向与反向的具体实现前向传播要保留中间变量因为反向传播需要用到它们def forward(self, x): self.z1 x self.W1 self.b1 self.a1 self.sigmoid(self.z1) self.z2 self.a1 self.W2 self.b2 self.a2 self.sigmoid(self.z2) return self.a2 def backward(self, x, y): m x.shape[0] delta2 (self.a2 - y) * self.a2 * (1 - self.a2) dW2 self.a1.T delta2 / m db2 np.sum(delta2, axis0, keepdimsTrue) / m delta1 (delta2 self.W2.T) * self.a1 * (1 - self.a1) dW1 x.T delta1 / m db1 np.sum(delta1, axis0, keepdimsTrue) / m self.W2 - self.lr * dW2 self.b2 - self.lr * db2 self.W1 - self.lr * dW1 self.b1 - self.lr * db1这段代码里最容易写错的是矩阵转置的方向。比如dW2应该是(128, 10)只有self.a1.T delta2能给出这个shape如果是delta2.T self.a1shape就会变成(10, 128)然后报错。所以我写代码时习惯在每一步后面加assert检查assert W2.shape (128, 10) assert dW1.shape (784, 128)这种防御式写法的好处是一旦公式抄错跑起来立刻报错而不是训练到一半才莫名奇妙性能很差。虽然多写了几个assert但它在开发阶段的收益远大于成本。3.3 训练循环与准确率评估训练循环就是反复做mini-batch的梯度下降def train(model, X_train, Y_train, X_val, Y_val, epochs30, batch_size64): n X_train.shape[0] for epoch in range(epochs): perm np.random.permutation(n) total_loss 0.0 for i in range(0, n, batch_size): idx perm[i:i batch_size] xb X_train[idx] yb Y_train[idx] out model.forward(xb) loss np.mean(0.5 * (out - yb) ** 2) model.backward(xb, yb) total_loss loss * len(idx) train_acc accuracy(model, X_train, Y_train) val_acc accuracy(model, X_val, Y_val) print(fepoch {epoch 1}, loss{total_loss / n:.4f}, ftrain_acc{train_acc:.4f}, val_acc{val_acc:.4f}) def accuracy(model, X, Y): out model.forward(X) pred out.argmax(axis1) true Y.argmax(axis1) return (pred true).mean()accuracy的计算用了argmax对sigmoid输出的网络来说没有问题对Softmax版本也同样适用。batch_size64是我测试下来比较稳的选择比128更快收敛比32更平滑。4. MNIST数据解析与预处理两个容易被忽略的细节4.1 手动解析IDX文件格式MNIST官网给的是四个gzip文件分别是训练图像、训练标签、测试图像、测试标签。如果不想用别人的dataset类就自己写解析函数。IDX格式有magic number、维度信息、然后就是raw bytes图像文件每个样本是28×28784字节标签文件每个样本是1字节。import gzip def load_idx(path): with gzip.open(path, rb) as f: magic int.from_bytes(f.read(4), big) ndim magic 0xff dims [] for _ in range(ndim): dims.append(int.from_bytes(f.read(4), big)) data np.frombuffer(f.read(), dtypenp.uint8) return data.reshape(dims)然后按顺序加载四个文件X_train load_idx(train-images-idx3-ubyte.gz) y_train load_idx(train-labels-idx1-ubyte.gz) X_test load_idx(t10k-images-idx3-ubyte.gz) y_test load_idx(t10k-labels-idx1-ubyte.gz)注意这里X_train拿到的原始shape是(60000, 28, 28)训练BP前必须reshape(60000, 784)。我当时忘了reshape结果矩阵乘法的shape直接报错这也是新手常见问题。4.2 归一化到底怎么做才对MNIST像素值范围是0到255我直接把每个像素除以255让输入落在[0,1]区间。这样做的原因是sigmoid的输出也是(0,1)区间输入和隐层饱和度比较匹配。很多人喜欢再减去均值除以方差做标准归一化但MNIST这种简单图像场景只做/255已经能让BP收敛得很好。有一个细节很关键如果做标准化均值和方差必须只从训练集计算不能把测试集一起算进去否则相当于把测试集的信息泄露给了模型。虽然MNIST上影响不大但这是一个必须养成的习惯换到更严格的数据集就可能是巨大隐患。4.3 One-Hot编码与训练/验证集划分标签是0到9的整数但输出层是10个神经元所以要把标签转成one-hot向量def one_hot(y, num_classes10): return np.eye(num_classes)[y]np.eye(10)[3]会得到[0,0,0,1,0,0,0,0,0,0]正好对应数字3。我从训练集里抽5000张作为验证集剩下的55000张用来训练。测试集只留到最后评估一次绝不在调参过程中反复用它验证否则就失去了测试集的意义。shuffle也非常重要——MNIST官方文件里的顺序是按标签排列的前面是0的样本后面是1、2……如果不打乱每个batch里全是同一类梯度更新会来回震荡训练效率很差。5. 训练实测记录从loss震荡到98%准确率5.1 学习率越大越好我第一次就被打脸我一开始想当然地把学习率设成0.5想着反正网络简单走得快一点多跑几轮就收敛。结果loss在0.15附近来回震荡训练集准确率只有86%。我立刻去看loss曲线——它不是平滑下降而是呈锯齿状这基本就是学习率过大的典型症状。参数每步更新都跨过头在最优区域旁边反复横跳。把学习率降到0.1之后loss立刻稳了训练集准确率冲到了95%以上。所以经验是如果loss震荡优先降一个量级试试如果loss下降极慢再考虑适当调大。5.2 隐藏层宽度、batch size、L2正则化的实测组合我记录了几组典型配置的结果方便大家有个直观参考配置测试集准确率sigmoidMSElr0.5hidden128约86%sigmoidMSElr0.1hidden128约92%sigmoidMSElr0.1hidden128加学习率衰减约94%sigmoidMSElr0.1hidden128加L2λ0.001约96%ReLUSoftmax交叉熵lr0.1hidden128约98%隐藏层从128加到256准确率能再涨一点但训练时间几乎翻倍而且256的隐层更容易在MNIST上产生过拟合训练集能冲到99%以上测试集会掉下来。我的建议是在MNIST这种数据量下128或256都可以接受但从性价比看128已经很够。L2正则化加入方式很简单损失函数后面加(λ/2)(||W1||²||W2||²)梯度也就顺带加上λ*W1和λ*W2self.W2 - self.lr * (dW2 self.lambda_reg * self.W2) self.W1 - self.lr * (dW1 self.lambda_reg * self.W1)这里lambda_reg0.001是我试了0.0001、0.001、0.01之后最稳的选择。加入L2后验证集准确率从94%提到96%过拟合被明显缓解。5.3 升级变体ReLU Softmax 交叉熵跑通sigmoidMSE版本以后我忍不住改了一版更现代的隐藏层激活用ReLU输出层用Softmax损失用交叉熵。反向传播的输出层误差变得特别简洁delta2 A2 - Y隐藏层误差delta1 (delta2 W2.T) * (Z1 0)ReLU的导数在正区间是1所以直接用一个(Z1 0)的布尔矩阵当导数。Softmax交叉熵之所以效果更好一方面是因为Softmax输出的概率分布和one-hot标签更匹配另一方面交叉熵的梯度在预测错误时幅度更大能更快把输出层拉开。ReLU则解决了sigmoid在深处容易梯度消失的问题。这个版本在MNIST测试集上很轻松跑到98%。同样的网络结构只改激活函数和损失函数准确率提升非常明显。所以如果你做分类任务建议优先考虑Softmax交叉熵而不是经典的MSE。6. 手搓BP与PyTorch路线差距其实没想象中大6.1 框架版本的代码量等我自己用numpy把BP从头到尾写完再回头看PyTorch版本突然觉得框架给出的代码量优势没有想象中夸张。同样的三层网络PyTorch核心模型代码确实短很多class Net(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x torch.relu(self.fc1(x)) return self.fc2(x)但训练循环、数据加载、损失函数、优化器设置这些骨架代码还是要自己写。而且如果不打开autograd的开关你真要在PyTorch里手写梯度反而被它的张量语义绊住手脚。框架真正省心的地方在于你可以用loss.backward()和optimizer.step()无缝搞定反向传播和参数更新但那已经是“封装好的结果”而不是“理解”。6.2 手搓代码给我带来的迁移价值写完纯numpy版本之后我再看任何深度学习框架的源码心里都有了底。PyTorch里的nn.Linear无非就是封装了两组参数和一个线性运算CrossEntropyLoss就是Softmax负对数似然optimizer.step()就是在自动算完梯度后用update rule更新参数。这些抽象背后的原理我已经亲手写过了用起来就不会再有“黑盒焦虑”。对还在学习阶段的朋友我的建议很明确与其一直调侃“调包侠”不如抽一个周末关闭框架用numpy写一个三层BP跑通MNIST。你会体验到那种从数学到代码完全打通的感觉之后再去看CNN、RNN甚至Transformer对梯度流动的直觉会始终在线。最后分享一个我自己的小技巧每次修改网络结构或者损失函数之后我都会把随机种子固定用一个小数据集比如2000张图跑几个epoch然后手动打印每一层的z、a、delta跟纸上手推的结果对比。一旦某个数值对不上能立刻知道是公式问题还是shape问题。这种“单步调试神经网络”的习惯比训练完看准确率再猜原因高效得多。等你能亲手把一个三层BP的每一个数字都算明白MNIST手写数字识别对你来说就不再是“跑个demo”而是真正掌握了一个模型从精到肉的完整脉络。
返回列表