ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

三层全连接神经网络实现MNIST图像分类:从入门到调参避坑

三层全连接神经网络实现MNIST图像分类:从入门到调参避坑 简介这份资源面向希望深入理解神经网络底层原理的Python学习者与图像识别入门者用纯numpy从零搭建三层全连接网络完成MNIST手写数字分类不依赖TensorFlow、PyTorch等框架适合想摆脱调包、亲手推导前向传播与反向传播的读者。压缩包共16个文件约9.51MB以txt数据文件、py源码、xml配置为主另含iml与pyc等工程辅助文件其中txt保存了训练与测试数据及权重偏置参数py脚本负责网络构建、训练与识别还附带将MNIST图片转为txt的代码方便直接查看与调试数据。目前已有3672人学习下载。读者可据此获得一套可运行、可修改的完整实现理解参数初始化、损失计算与梯度更新流程并借助数据转换脚本自由替换样本是入门深度学习底层机制的实用练手项目。1. 三层全连接神经网络实现 MNIST 图像分类为什么它至今仍是入门首选如果你刚接触深度学习想找一个能跑通、能看懂、能改参数的第一个项目三层全连接神经网络实现 MNIST 图像分类几乎是绕不开的起点。MNIST 是 7 万张 28×28 的手写数字灰度图10 个类别训练集 6 万、测试集 1 万。它小到能在笔记本 CPU 上几分钟跑完一轮又足够真实到能暴露过拟合、学习率、初始化这些核心问题。很多人觉得它太简单直接跳到 CNN结果连反向传播的梯度形状都对不上。我的建议是先用三层全连接把整条链路走通再换卷积。这个方案适合两类人——刚学完反向传播想动手验证的以及需要一套干净基线做对比实验的。下面从网络结构、数据加载、训练循环到调参排错一步步拆开讲。2. 三层全连接的网络结构怎么定输入层、隐藏层、输出层的尺寸推导2.1 为什么是“三层”而不是“三个隐藏层”标题里的“三层”指的是输入层、一个隐藏层、输出层这是全连接网络最经典的叫法。输入层不是真正的计算层它只负责接收 28×28784 维的展平向量。隐藏层通常取 128 或 256 个神经元输出层固定 10 个节点对应数字 0 到 9。很多人误以为层数越多越好但在 MNIST 上两个隐藏层相比一个隐藏层提升非常有限反而更容易过拟合。我一般先用 784→256→10 这个配置跑基线确认流程无误后再加宽或加深。2.2 前向传播的矩阵形状与激活函数选择前向传播本质是两次线性变换加两次激活。第一层权重 W1 形状是 784×256偏置 b1 是 256 维第二层 W2 是 256×10b2 是 10 维。隐藏层用 ReLU 是默认选择因为它缓解梯度消失且计算快。输出层必须用 Softmax 把 10 个实数变成概率分布配合交叉熵损失。这里有个容易翻车的点如果输出层忘了 Softmax直接拿 logits 算交叉熵数值会不稳定。常见做法是用框架自带的 CrossEntropyLoss它内部已经融合了 LogSoftmax不需要你手动加 Softmax。import torch import torch.nn as nn class ThreeLayerNet(nn.Module): def __init__(self, hidden256): super().__init__() # 输入 784 - 隐藏层 self.fc1 nn.Linear(28 * 28, hidden) self.relu nn.ReLU() # 隐藏层 - 输出 10 类 self.fc2 nn.Linear(hidden, 10) def forward(self, x): # x: [batch, 1, 28, 28] 展平为 [batch, 784] x x.view(x.size(0), -1) x self.fc1(x) x self.relu(x) x self.fc2(x) # 输出 logits不手动 Softmax return x上面代码里hidden是隐藏层宽度默认 256。view那行是关键把图像展平成一维向量顺序必须和训练数据一致。fc2输出的是 logits后面接nn.CrossEntropyLoss即可。如果你自己写 Softmax 再取 log容易遇到 log(0) 的数值问题这是新手最常见的翻车点之一。2.3 参数量估算与显存占用784×256 的权重约 20 万参数256×10 约 2560 个加上偏置总共约 20.3 万。这个量级在 CPU 上训练也毫无压力batch size 取 64 时显存占用不到 100MB。如果你把隐藏层加到 1024参数量会涨到约 80 万训练时间明显增加但测试准确率通常只提升 0.2% 左右。所以我的经验是隐藏层 256 是性价比最高的起点除非你有明确理由再加宽。3. 用 PyTorch 在本地跑通 MNIST 训练数据加载、训练循环与验证3.1 数据加载与标准化参数MNIST 的像素值是 0 到 255直接送进网络会导致梯度爆炸。必须做归一化常见做法是减均值 0.1307、除标准差 0.3081这两个数字是 MNIST 训练集的全局统计量。用torchvision.datasets.MNIST时ToTensor()已经把像素缩放到 0 到 1再套Normalize即可。batch size 我一般设 64太小则梯度噪声大太大则泛化略差。from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), # 像素缩放到 [0,1] transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值方差 ]) train_set datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_set, batch_size64, shuffleTrue) test_loader DataLoader(test_set, batch_size1000, shuffleFalse)root是数据存放目录第一次运行会自动下载。shuffleTrue只在训练集开测试集不要打乱否则评估指标会乱。Normalize的两个参数不要随手改换成别的数据集要重新统计。3.2 训练循环的五个关键步骤训练循环固定套路前向传播、算损失、清梯度、反向传播、更新参数。优化器选 Adam 还是 SGD 有讲究。Adam 默认学习率 1e-3 通常能直接收敛SGD 需要手动调学习率到 0.01 并加动量。我一般先用 Adam 跑通再换 SGD 对比。下面是一个完整 epoch 的代码。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model ThreeLayerNet(hidden256).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(5): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() # 清空上一轮梯度 outputs model(images) # 前向 loss criterion(outputs, labels) loss.backward() # 反向 optimizer.step() # 更新 print(fepoch {epoch1}, loss {loss.item():.4f})zero_grad必须放在反向传播之前否则梯度会累加。model.train()和后面的model.eval()要成对出现虽然全连接层没有 Dropout 和 BatchNorm 时影响不大但养成习惯能避免以后加层时出错。5 个 epoch 后训练损失通常能降到 0.05 以下。3.3 测试集评估与准确率计算评估时一定要切到eval()并关闭梯度计算否则显存会白白增长。准确率统计用argmax取最大 logit 对应的类别再和标签比对。model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) print(ftest acc: {correct / total:.4f})正常配置下测试准确率在 97.5% 到 98.2% 之间。如果低于 96%先检查归一化参数和view展平顺序。argmax(dim1)里的 1 是类别维度别写成 0。4. 三层全连接在 MNIST 上的调参经验学习率、隐藏层宽度与正则化4.1 学习率与优化器的组合效果学习率是影响最大的超参数。Adam 在 1e-3 时通常 3 个 epoch 就能到 97%调到 1e-2 会震荡1e-4 则收敛太慢。SGD 配动量 0.9 时学习率 0.01 比较稳0.1 容易发散。我做过一组对比同样 5 个 epochAdam 1e-3 测试准确率 97.9%SGD 0.01 加动量 97.6%差距不大但 Adam 对初始值更宽容。如果你要写论文做对比建议固定优化器再调其他参数。4.2 隐藏层宽度与过拟合的权衡隐藏层从 64 加到 512训练准确率会从 97% 升到 99.5%但测试准确率往往在 256 附近达到峰值。继续加宽后测试集反而下降这就是过拟合。判断方法是看训练损失和测试损失的差距如果训练损失 0.01 而测试损失 0.15说明模型在背训练集。缓解手段有两个加 Dropout 或加 L2 正则。Dropout 概率设 0.2 到 0.5 之间我一般从 0.3 开始试。class ThreeLayerNetDropout(nn.Module): def __init__(self, hidden256, p0.3): super().__init__() self.fc1 nn.Linear(28 * 28, hidden) self.relu nn.ReLU() self.drop nn.Dropout(p) # 训练时随机置零 self.fc2 nn.Linear(hidden, 10) def forward(self, x): x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.drop(x) return self.fc2(x)Dropout 只在model.train()时生效eval()时会自动关闭。如果你发现加了 Dropout 后训练准确率下降但测试准确率上升说明正则化起作用了。4.3 权重初始化与批大小的选择PyTorch 的nn.Linear默认用 Kaiming 均匀初始化对 ReLU 已经够用。如果你手动改成全零初始化网络会完全学不动因为所有神经元梯度相同。批大小 64 是常见默认值显存不够就降到 32但太小会让 BatchNorm 不稳定虽然这个网络没有 BN。我一般不动批大小优先调学习率和隐藏层宽度。5. 避坑与排查三层全连接跑 MNIST 最常见的五个翻车点5.1 损失不下降准确率停在 10%现象训练几个 epoch 后损失几乎不变测试准确率约 10%相当于随机猜。原因通常是标签和输出维度对不上或者忘了做view展平导致输入维度错误。解决打印images.shape确认是[batch, 1, 28, 28]再确认fc1输入是 784。如果输入维度写成 28矩阵乘法会直接报错如果标签是 one-hot 而损失函数用 CrossEntropyLoss也会出问题。5.2 训练损失正常但测试准确率极低现象训练损失降到 0.02测试准确率只有 60% 多。原因一般是训练集和测试集用了不同的归一化参数或者测试时忘了model.eval()。解决检查两个数据集的transform是否一致评估前加model.eval()。另外如果测试集shuffleTrue准确率计算本身不会错但会让你难以复现建议关掉。5.3 显存溢出或训练速度异常慢现象batch size 64 就报显存不足或者一个 epoch 要跑十几分钟。原因可能是忘了torch.no_grad()导致评估时构建计算图或者数据加载num_workers设成 0 且磁盘慢。解决评估循环包with torch.no_grad()DataLoader加num_workers2或 4。如果用的是 CPU确认没有把模型意外放到 GPU 又来回拷贝。5.4 准确率波动大每次运行结果差很多现象同样代码跑两次测试准确率一次 97.8% 一次 96.5%。原因是没有固定随机种子权重初始化和数据打乱顺序不同。解决在开头设torch.manual_seed(42)和random.seed(42)如果要完全可复现还要设torch.backends.cudnn.deterministic True。不过完全确定性会牺牲一点速度看需求取舍。5.5 加载预训练权重或保存模型后无法复现现象保存模型再加载准确率掉了一大截。原因通常是保存时只存了state_dict但加载时网络结构不一致或者忘了把模型切到eval()。解决保存用torch.save(model.state_dict(), net.pth)加载时先实例化同样结构的模型再load_state_dict。如果改了隐藏层宽度旧权重形状对不上会直接报错这是好事能帮你发现结构不一致。6. 从全连接到卷积的进阶验证用混淆矩阵和错误样本定位瓶颈三层全连接在 MNIST 上到 98% 左右就基本到顶了再调参收益很小。想知道瓶颈在哪最直接的方法是画混淆矩阵和看错误样本。下面这段代码统计测试集里每个数字被误判成什么。import numpy as np from sklearn.metrics import confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) all_preds.extend(outputs.argmax(dim1).cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) print(cm) # 找出错误最多的类别对 for i in range(10): for j in range(10): if i ! j and cm[i][j] 5: print(f数字 {i} 被误判为 {j}: {cm[i][j]} 次)跑完你会发现错误主要集中在 4 和 9、3 和 8、7 和 1 这几对。原因很直观全连接层把图像展平后丢失了空间结构笔画位置稍有偏移就认不出来。这正是卷积网络的主场——卷积核能利用局部相关性。我的习惯是先用全连接基线拿到 98%再换两层卷积通常能到 99% 以上这时候你就能清楚说出提升来自哪里而不是盲目堆层。另外把错误样本的可视化图打印出来看几眼比盯着准确率数字有用得多。有次我调了半天学习率没效果结果发现是某几张标注本身就有歧义这种问题只能靠看样本发现。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进