尧图精选

从零手写两层全连接神经网络:NumPy实现MNIST手写数字识别

🕒 发布时间:2026/10/1 3:34:05 📁 来源:尧图网络
我一直觉得学习深度学习最忌讳的事就是还没弄明白网络是怎么算的就一上来调PyTorch、TensorFlow的接口。标题里的“人工智能--深度学习两层全连接神经网络搭建”看起来像是一个课程大作业或者期末项目但在我看来这恰恰是整个深度学习入门阶段最值得手写一遍的内容。两层全连接网络虽然结构简单却把前向传播、反向传播、梯度下降、激活函数、损失函数这些核心机制全部串起来了。这篇文章我会用纯NumPy从零实现一个两层全连接神经网络不借助任何深度学习框架用它完成手写数字分类任务并且把每一步的数学原理、实现细节、调参经验和踩坑记录都讲清楚。适合人工智能初学者、正在准备深度学习相关作业或毕业设计的同学也适合想真正理解“框架背后发生了什么”的开发者。1. 项目背景与整体设计思路1.1 为什么我推荐手写一次两层网络很多人在入门深度学习时会直接看CNN、Transformer这类看起来很“高级”的结构。但说实话如果连全连接层的反向传播都推导不清楚后面看再复杂的网络也容易变成“名词背诵”。两层全连接神经网络是复杂度最低、但五脏俱全的模型它有一层隐藏层有非线性激活函数有Softmax输出有交叉熵损失有完整的反向传播链。把这些东西逐个吃透之后再去学卷积、循环、注意力机制会发现大家底层都是同一套东西只是数据的组织方式和层的组合方式变了。我见过不少朋友用框架时一行model.add(Dense(...))就能搭出网络但遇到Loss不下降、梯度爆炸、过拟合这些问题时完全不知道从哪里排查。原因就在于他们不清楚每一层的梯度是怎么流动的也不清楚某个超参数到底在影响什么。手写一遍两层网络不是为了造轮子而是为了建立“直觉”。有了这种直觉以后用任何框架都不会心虚。另外这个项目很适合作为一个人工智能方向的大作业或结课项目。工作量适中代码量不大但可以讲清楚的地方非常多从数学推导到代码实现再到实验分析都能写出厚度。1.2 项目选型和网络结构确定我开始时的目标很明确不用深度学习框架只用NumPy搭建一个能训练、能预测的两层全连接网络。数据集我选择MNIST手写数字数据集这是深度学习领域的“Hello World”28×28的灰度图共10个类别训练集6万张测试集1万张。如果本地下载不方便也可以用sklearn.datasets.load_digits()替代它是简化版的手写数字8×8像素但流程完全一致。网络结构就按标题说的“两层”来定这里的“两层”指的是两层带权重的连接也就是输入层784个神经元对应28×28784个像素值隐藏层我设置了128个神经元激活函数用ReLU输出层10个神经元对应0-9十个数字激活函数用Softmax隐藏层神经元数量为什么选128这其实是一个经验值。MNIST的输入有784维隐藏层太少会导致模型表达能力不足太多则会明显增加计算量而且在小数据集上容易过拟合。128是一个中间值训练速度和准确率都比较均衡。你也可以改成64或256对比一下效果这本身就是很好的实验。权重矩阵的维度是这样确定的W1的形状是(784, 128)偏置b1的形状是(128,)W2的形状是(128, 10)偏置b2的形状是(10,)输入x经过第一层线性变换得到z1 xW1 b1再经过ReLU激活得到a1然后经过第二层线性变换得到z2 a1W2 b2最后经过Softmax得到每个类别的预测概率。整个过程就是“线性变换 - 非线性激活 - 线性变换 - 概率输出”。1.3 数据准备与评估指标数据处理是深度学习项目里最容易忽略、但影响最大的部分。MNIST原始像素值是0到255的整数如果直接喂给网络数值范围太大会导致梯度更新不稳定。所以第一步要做归一化把像素值除以255缩放到0到1之间。标签要做One-Hot编码比如数字“3”变成[0,0,0,1,0,0,0,0,0,0]这样输出的10个神经元就分别对应10个类别的预测概率。评估指标主要看准确率也就是预测正确的样本数占总样本数的比例。但在训练过程中我会同时关注交叉熵损失。准确率是离散的可能好几个Epoch不变而损失是连续的可以更细腻地反映模型是否还在改进。数据划分上我习惯从训练集里留出一部分作为验证集比如6万张里拿出5000张做验证集。验证集不参与梯度更新只用来观察模型是否过拟合以及帮你决定什么时候停止训练、怎么调整超参数。测试集则留到最后用来评估模型在完全没见过的数据上的真实表现。2. 核心原理前向传播与反向传播2.1 前向传播数据是如何从输入走到输出的前向传播是整个神经网络做预测的过程。对于单个输入样本x第一步是z1 x W1 b1这里的是矩阵乘法。x是(784,)的向量W1是(784,128)的矩阵得到z1是(128,)的向量。这个线性变换做的事情其实是对输入的784个像素做加权求和然后加上偏置。如果只有这一步那不管叠加多少层本质上还是线性变换所以必须引入非线性激活函数。隐藏层我用ReLU激活函数a1 max(0, z1)ReLU会把所有负数变成0正数保留。它的好处是计算简单而且能缓解梯度消失问题。负半轴的梯度是0这会让部分神经元“失活”某种意义上也算一种稀疏性能降低过拟合风险。接着是第二层z2 a1 W2 b2得到10个实数称为logits。最后用Softmax把这些实数转换成和为1的概率分布softmax(z2)_i exp(z2_i) / sum_j exp(z2_j)在实际计算时我通常会给z2减去它的最大值再做指数运算比如exp(z2 - max(z2))避免数值溢出。这个细节在训练到后期时尤其重要因为logits可能会变得比较大。2.2 交叉熵损失与Softmax分类任务的标配分类任务最常用的损失函数是交叉熵L -sum_i t_i * log(y_i)其中t是真实标签的One-Hot向量y是模型输出的概率向量。因为t中只有一个位置是1其他都是0所以这个式子其实可以简化为L -log(y_true_class)也就是模型给正确类别分配的概率越低损失越大。为什么分类任务不用均方误差因为Softmax输出的概率分布和One-Hot标签之间用交叉熵衡量的是两个分布的差异梯度更合理。如果配合均方误差输出层的梯度会多乘一个Softmax的导数容易导致梯度消失训练速度非常慢。交叉熵和Softmax组合在一起时有一个非常漂亮的数学性质输出层误差dz2刚好等于预测概率减去真实标签也就是y - t。这个结论在做反向传播时非常有用我下面会细说。2.3 反向传播手把手拆解每一层梯度反向传播是训练神经网络的灵魂本质就是链式法则。我们要计算损失对每个参数的偏导数然后沿着负梯度方向更新参数。先看输出层。我们定义dz2 ∂L / ∂z2由于交叉熵配合Softmax的简化性质直接得到dz2 y - t这个结论看起来简单但值得自己推一遍。推导的关键是分两类讨论当i等于真实类别时∂L/∂z2_i y_i - 1当i不等于真实类别时∂L/∂z2_i y_i。合起来就是y - t。有了dz2可以求W2和b2的梯度grad_W2 a1.T dz2 / batch_sizegrad_b2 sum(dz2, axis0) / batch_size注意这里除以batch_size是为了求平均梯度让梯度大小不随批量大小变化。如果你发现改变batch_size后训练效果波动很大大概率就是忘了除以batch_size。接下来往上一层传播误差。先算da1 dz2 W2.T这是从输出层传回隐藏层输出的梯度。然后因为a1 ReLU(z1)我们需要把梯度通过ReLU的导数dz1 da1 * (z1 0)ReLU导数在z1大于0时为1小于等于0时为0。(z1 0)会生成一个布尔矩阵乘上da1后负数区域的梯度就变0了。最后求W1和b1的梯度grad_W1 x.T dz1 / batch_sizegrad_b1 sum(dz1, axis0) / batch_size在实际写代码时我习惯把x、z1、a1、z2保存下来因为这些中间变量在前向传播后还要在反向传播里用到。这就是很多框架里所谓“计算图”的雏形。2.4 参数初始化与学习率影响训练成败的两个细节参数初始化经常被新手忽略但它的影响非常大。如果所有权重初始化为0那么隐藏层所有神经元接收到完全相同的梯度无论训练多久每个神经元学到的都是同样的东西网络实际退化成一个线性模型。所以必须用随机初始化。我推荐使用He初始化专门配合ReLU激活函数。做法是让权重服从均值为0、标准差为sqrt(2 / fan_in)的正态分布。其中fan_in是输入神经元数量。对W1来说就是sqrt(2/784)对W2来说就是sqrt(2/128)。为什么用2/fan_in而不是传统的1/fan_in因为ReLU会把一半的梯度置零相当于输出的方差会缩小所以需要放大初始权重来补偿。偏置b可以初始化为0因为权重已经打破了对称性偏置不需要再承担打破对称的任务。学习率的选择同样关键。学习率太大loss会震荡甚至爆炸学习率太小训练速度会慢到让人怀疑人生。对于MNIST加两层网络这个组合我常用的学习率是0.1到0.5之间。注意如果后面换用更深的网络或更复杂的损失函数学习率通常要调小。3. 实操用NumPy从零搭建两层全连接网络3.1 准备数据归一化、One-Hot与训练/验证划分这里我给出一个可以直接跑的代码片段。假设你已经把MNIST数据加载成了X_train、y_train、X_test、y_test。如果是用TensorFlow/Keras下载的数据可以这样处理import numpy as np # 假设X_train形状为(60000, 784)y_train形状为(60000,) # 归一化到[0,1] X_train X_train.astype(np.float32) / 255.0 X_test X_test.astype(np.float32) / 255.0 # One-Hot编码 def one_hot(y, num_classes10): T np.zeros((y.shape[0], num_classes)) T[np.arange(y.shape[0]), y] 1 return T y_train_oh one_hot(y_train) y_test_oh one_hot(y_test) # 从训练集中切出5000个样本做验证集 val_size 5000 X_val X_train[:val_size] y_val_oh y_train_oh[:val_size] # 剩下的作为真正训练集 X_train_part X_train[val_size:] y_train_part_oh y_train_oh[val_size:]注意在切分之前最好先对训练集做一次随机打乱防止原始数据里相同类别的样本连续排列带来偏差。可以这样perm np.random.permutation(X_train.shape[0]) X_train X_train[perm] y_train_oh y_train_oh[perm]然后再切分验证集和训练集。3.2 核心代码TwoLayerNet类的完整实现下面是我写的两层全连接网络核心类。代码不复杂但每一步都对应前面的数学推导。class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size): # He初始化 self.params {} self.params[W1] np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.params[b1] np.zeros(hidden_size) self.params[W2] np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size) self.params[b2] np.zeros(output_size) def forward(self, x): W1, b1 self.params[W1], self.params[b1] W2, b2 self.params[W2], self.params[b2] self.x x self.z1 x.dot(W1) b1 self.a1 np.maximum(0, self.z1) self.z2 self.a1.dot(W2) b2 # Softmax减去最大值防止溢出 exp_z2 np.exp(self.z2 - np.max(self.z2, axis1, keepdimsTrue)) self.y exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) return self.y def backward(self, t): batch_size self.x.shape[0] grads {} # 输出层梯度 dz2 self.y - t grads[W2] self.a1.T.dot(dz2) / batch_size grads[b2] np.sum(dz2, axis0) / batch_size # 隐藏层梯度 da1 dz2.dot(self.params[W2].T) dz1 da1 * (self.z1 0) grads[W1] self.x.T.dot(dz1) / batch_size grads[b1] np.sum(dz1, axis0) / batch_size return grads def predict(self, x): y self.forward(x) return np.argmax(y, axis1)这个类里没有定义损失函数因为交叉熵损失配合Softmax的反向传播已经简化到self.y - t这一步了。如果你想在训练时打印loss可以单独写一个函数def cross_entropy_loss(y, t): # y和t都是形状为(batch_size, num_classes)的矩阵 batch_size y.shape[0] return -np.sum(t * np.log(y 1e-7)) / batch_size这里加1e-7是为了防止log(0)出现。实际训练中如果模型对某个样本的预测完全正确对应的概率也可能因为数值精度变成0这个防御性处理很有必要。3.3 训练循环小批量随机梯度下降落地网络类写好后训练循环很简单。我采用小批量随机梯度下降每批取64个样本这样比全量梯度下降快而且梯度噪声能帮助模型跳出局部最优。model TwoLayerNet(input_size784, hidden_size128, output_size10) batch_size 64 learning_rate 0.2 epochs 10 train_size X_train_part.shape[0] loss_list [] acc_list [] for epoch in range(epochs): # 每个epoch重新打乱数据 perm np.random.permutation(train_size) X_shuffled X_train_part[perm] y_shuffled y_train_part_oh[perm] epoch_loss 0.0 steps train_size // batch_size for i in range(steps): x_batch X_shuffled[i * batch_size:(i 1) * batch_size] t_batch y_shuffled[i * batch_size:(i 1) * batch_size] # 前向 y_pred model.forward(x_batch) loss cross_entropy_loss(y_pred, t_batch) epoch_loss loss # 反向 grads model.backward(t_batch) # 更新参数 for key in model.params: model.params[key] - learning_rate * grads[key] avg_loss epoch_loss / steps train_acc np.mean(model.predict(X_train_part) np.argmax(y_train_part_oh, axis1)) val_acc np.mean(model.predict(X_val) np.argmax(y_val_oh, axis1)) loss_list.append(avg_loss) acc_list.append(val_acc) print(fEpoch {epoch1}, Loss: {avg_loss:.4f}, Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f})如果你用我推荐的超参数一般3到5个epoch后验证集准确率就能到95%以上10个epoch后基本能到97%以上。这个成绩放在深度学习模型里算不上惊艳但它是完全从零实现出来的每一步都能解释清楚。3.4 保存模型与预测把训练结果用起来训练完成后可以把模型参数保存成文件方便以后预测np.savez(two_layer_model.npz, W1model.params[W1], b1model.params[b1], W2model.params[W2], b2model.params[b2])加载模型时新建一个TwoLayerNet实例然后把保存的参数覆盖进去loaded np.load(two_layer_model.npz) model.params[W1] loaded[W1] model.params[b1] loaded[b1] model.params[W2] loaded[W2] model.params[b2] loaded[b2]之后对任意一张28×28图像先把它展平成784维向量并除以255再调用model.predict就能得到预测数字。这一步对于写大作业的同学特别实用因为可以做一个简单的可视化界面让用户画数字、识别数字。4. 训练调参与评估4.1 如何观察训练曲线并判断状态训练过程中我会把loss、训练集准确率、验证集准确率都记录下来然后画成曲线。画完之后你会明显看到几种状态loss持续下降train acc 和 val acc 同步上升这是最健康的状态。train acc 很高但 val acc 不再提升甚至下降过拟合信号。train acc 和 val acc 都很低loss 下降缓慢可能是学习率太小、初始化不好或数据有问题。loss 出现剧烈震荡学习率偏大或者batch_size太小。这里要特别强调一下验证集和测试集千万不要混用。验证集是用来调超参数的测试集只能最后用一次。如果你反复拿测试集调参测试集的评估结果就不再客观相当于你把测试集的信息泄漏进了训练过程。4.2 超参数选择的实验思路我调试超参数的经验是“一次只动一个”。比如你想判断学习率的影响就在其他参数不变的情况下分别试0.05、0.1、0.2、0.5观察loss下降曲线。注意每次训练都保持随机种子一致否则初始化不同对比结果就不公平。可以这样固定种子np.random.seed(42)隐藏层神经元数量也是一个可调量。我建议做一个表格对比隐藏层大小训练集准确率验证集准确率训练耗时32~0.95~0.94较快128~0.98~0.97中等256~0.99~0.97较慢可以看到神经元增加到一定程度后验证集准确率提升有限但训练时间涨得很明显。这个现象能帮你理解“模型容量”和“泛化能力”之间的权衡。如果你想做正则化可以在损失函数中加L2正则项L cross_entropy lambda * (sum(W1^2) sum(W2^2))反向传播时W1和W2的梯度要分别加上2 * lambda * W1和2 * lambda * W2。我试过把lambda设为0.001到0.01之间可以稍微缓解过拟合但lambda太大会让模型欠拟合。4.3 测试集上的最终评估模型训练完成后在测试集上评估一次test_pred model.predict(X_test) test_acc np.mean(test_pred np.argmax(y_test_oh, axis1)) print(fTest Accuracy: {test_acc:.4f})按照上面的代码和超参数测试集准确率应该能达到97%左右。我建议再输出一下混淆矩阵看看哪些数字容易被混淆。MNIST里常见的混淆对是“4”和“9”、“7”和“2”、“3”和“8”。你可以统计一下这些错误样本的像素分布往往能发现是某些手写风格太过潦草或者图像的某些局部特征很像另一个数字。5. 常见问题与排查技巧实录5.1 损失不下降先查数据再查超参数这是最常遇到的问题。我见过有人代码没问题但loss一直在2.3附近不下降最后发现是标签没有做One-Hot导致网络输出和标签维度对不上或者虽然对上了但标签全为0。检查顺序应该是数据X是否归一化y是否正确One-Hot训练集是否打乱前向Softmax输出是否每行加起来等于1反向梯度量级是否正常可以用梯度检查来验证也就是用数值差分逼近梯度和反向传播算出来的梯度做比较。误差在1e-7到1e-5之间基本就算正确。超参数学习率是否太小隐藏层神经元数量是否太少epoch是否不够5.2 Loss变成NaN梯度爆炸的常见处理办法NaN大概率是梯度爆炸导致的。我遇到最多的情况是学习率太大权重更新后数值溢出。处理办法把学习率调小比如从0.2降到0.05。检查输入数据里是否有异常值MNIST一般没有但如果你换了数据集注意归一化。在梯度更新前做一个梯度裁剪如果梯度的L2范数超过某个阈值就按比例缩回。代码很简单grad_norm np.sqrt(sum(np.sum(g ** 2) for g in grads.values())) if grad_norm 1.0: for key in grads: grads[key] / grad_norm我自己很少在MNIST这种简单数据集上遇到NaN但换到更复杂的任务时梯度裁剪几乎是标配。5.3 过拟合验证集上暴露出来的问题如果训练集准确率稳定上升验证集准确率却上不去或者训练集的loss越来越低而验证集loss回升就是标准的过拟合。MNIST数据量比较大两层网络参数也不多过拟合不会很明显。但如果你只用了几千张图或者把隐藏层加到500、1000个神经元过拟合就会出现。解决思路有几条增加训练数据做简单的数据增强比如平移一两个像素、轻微旋转手写数字识别对平移其实很敏感这个增强很有效。缩小模型减隐藏层神经元数量。加正则L2正则、Dropout都可以。Dropout在两层网络里可以临时加在隐藏层输出上训练时按概率丢弃一部分神经元预测时乘回保留概率。早停每训练一个epoch观察验证集准确率。如果连续多个epoch没有提升就停止训练并回滚到验证集最好的那一个epoch的参数。注意保存历史最优参数。5.4 常见错误速查表症状可能原因解决方案Loss一直是2.3左右Softmax输出接近均匀分布网络没训练起来检查标签One-Hot、学习率、初始化Loss很小但准确率不高可能用了均方误差或模型坍塌换交叉熵检查输出层梯度训练集准确率100%验证集只有90%过拟合增加数据/正则/早停减小模型Loss震荡剧烈学习率过大或batch_size过小降低学习率增大batch_size所有预测结果都是同一个类别数据顺序未打乱或梯度更新有问题打乱数据检查反向传播梯度反向传播数值和梯度检查差异很大ReLU在0处不可导或公式写错分段验证逐层计算梯度5.5 一个容易踩的数据顺序坑我在写代码时曾经犯过一个很低级的错误加载MNIST后没有打乱训练集就直接切分导致验证集全是前几个类别的样本模型在验证集上的准确率一直偏低后来才发现是数据顺序的问题。所以这里要强调在切分验证集之前一定先随机打乱整个训练集。如果你是在线下载的原始数据顺序通常按标签排列不打乱的话后果很明显。另外每个epoch重新打乱一次数据也值得做。小批量SGD的随机性不仅来自batch的抽样也来自数据顺序的变化。如果每个epoch都按照同一个顺序喂给模型模型会“记住”数据顺序带来的虚假规律影响泛化。6. 扩展思考从两层网络到更深的网络6.1 如果加第三层、第四层会发生什么两层网络能解决MNIST问题但表达能力有限。如果你想在这个项目基础上做扩展最自然的方向是增加隐藏层的层数。比如改成三层隐藏层每层128个神经元结构就是784-128-128-10。这时候反向传播链会变长理论上需要多做两步链式法则。但实现上你会发现只要把两层网络中的“前向保存中间变量”和“反向逐层回传梯度”这套逻辑抽象成更通用的add_layer函数三层的代码和两层的差别很小。不过层数加深后训练难度会陡增。最明显的问题是梯度消失。ReLU虽然比Sigmoid好很多但层数过多时误差信号经过多次矩阵乘法后数值还是会变小。这也是为什么现代深度学习网络中会有Batch Normalization、残差连接等机制——它们本质上都是为了解决“深度带来的训练问题”。6.2 从NumPy到PyTorch原理在手工具随便换当你完整写完这个两层网络再去看PyTorch或TensorFlow你会发现自己能看懂文档里每一个参数的含义了。比如torch.nn.Linear(in_features, out_features)你知道它内部就是一组权重矩阵和偏置torch.nn.CrossEntropyLoss()你知道它内部自动把Softmax和交叉熵的梯度合并了optimizer.step()你知道它就是在做参数减去学习率乘以梯度这件事。我自己的习惯是每学一个新模型先手写一个极简版本再用框架实现一遍然后对比两者的预测结果。这个习惯帮我避免了很多“调包侠困境”。这个项目做完后你可以继续尝试把隐藏层换成Sigmoid或Tanh观察训练速度变化加一个L2正则项看验证集准确率变化把MNIST换成Fashion-MNIST感受不同数据集的难度差异把批量大小从64改成1或512观察训练曲线和稳定性的变化。从我实际踩过的坑来看手写两层全连接网络最折磨人的地方不是代码而是“明明是同一个网络为什么我的结果就是比别人的低2%”这类玄学问题。但解决这些问题的过程恰恰是把深度学习的隐性知识内化成自己能力的过程。如果你正在做类似的项目我建议你把每一步实验都记录下来尤其是超参数、损失值、准确率变化曲线这些记录产出的价值会远超那几行代码本身。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →