ARTICLE DETAIL

资讯详情

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

手写两层全连接神经网络:从反向传播到MNIST实战

手写两层全连接神经网络:从反向传播到MNIST实战 这两年身边自学AI的人越来越多有人从公开课切入有人拿现成模型逆向拆解可真正到了自己想动手做点东西时往往卡在从“看得懂代码”到“造得出网络”这条鸿沟上。今天要聊的就是一道被很多人低估的基础关用深度学习搭建一个两层全连接神经网络。不追CNN、不碰Transformer就老老实实把一个“输入层–隐藏层–输出层”的结构吃透手推前向传播和反向传播再把训练闭环完整跑通。这个项目能做的事很具体比如手写数字识别、小规模分类任务它适合刚学完机器学习基础、准备进入深度学习的人也适合在代码里调参调得一头雾水、想回头补根基的选手。下面我把从结构设计、参数选择到代码实现和调试避坑的全过程拆开讲。1. 为什么先做两层全连接网络整体设计与方案选型1.1 全连接网络的本质是什么全连接神经网络学术点叫多层感知机MLP它的核心逻辑其实是把一连串线性变换和非线性激活串起来。每一层的每个神经元都会和上一层的所有神经元相连所以叫“全连接”。用生活里的话说它就像一条流水线原料从入口进来每一道工序都重新组合所有零件的特征最后在出口给出一个分类或者数值结果。具体到数学一个全连接层做的操作就两件事z Wx b然后通过激活函数a f(z)。这里的W是权重矩阵b是偏置f是激活函数。深度学习说的“学习”本质上就是在不断调整W和b。两层全连接网络就是中间只夹一个隐藏层即“输入层–隐藏层–输出层”三段式结构。我在带实训项目时发现很多学员对“网络变深”有莫名的迷恋恨不得一上来就叠十个八层。但实际动手就会明白如果连单隐藏层的梯度推导都没亲手算过后面的BatchNorm、残差连接、注意力机制全都是空中楼阁。把两层网络吃透整个深度学习的地基就算打得差不多了。1.2 为什么是“两层”而不是一层或一堆层先说一层网络也就是Softmax回归或者逻辑回归。它只能学出线性决策边界拿它分MNIST里的手写数字准确率天花板大概在92%左右。这个数字听起来不差但它根本看不清数据里的非线性结构——比如一条斜着的曲线边界就分不出来。而两层网络引入了隐藏层加激活函数就能逼近任意连续函数这就是大名鼎鼎的万能逼近定理。换句话说两个全连接层在理论上已经具备拟合复杂模式的能力。那为什么不直接堆很多层因为深度增加会带来梯度消失、优化困难、训练时间暴涨等一系列问题。初学者如果连两层都调不稳直接上深层网络基本就是把“调参玄学”当成主修课很容易放弃。从工程角度来说“两层”还意味着计算量可控。我在普通笔记本CPU上跑MNIST纯Python实现一个epoch也就几秒钟PyTorch加GPU更是眨眼的事。这个体量最适合反复实验可以快速验证学习率、初始化、批大小等每个因素对训练的影响。所以两层这个选择不是图省事而是用最小的复杂度换取最大的学习收益。1.3 开发路线选择从零手写还是直接上框架这个问题我几乎每期带人都要回答一次。我的建议很明确先手写一遍再上框架。手写不是为了造轮子而是为了理解框架背后发生的事。当你用PyTorch调用一行nn.Linear(784, 128)时它背后自动完成了权重初始化、前向计算、反向传播的梯度计算。如果心里没有手推过一遍图出了梯度爆炸、Loss不降这类问题你会连排查方向都找不到。我建议的路线是用NumPy手写两层网络实现前向传播、反向传播和梯度下降。在玩具数据集上验证梯度计算是否正确可以用数值梯度对照。跑通后再切到PyTorch用自动求导简化流程。比较手写版本和框架版本的输出差异理解框架帮我们做了什么。我自己当年踩过一个典型坑手写反向传播时把dz2 a2 - y漏了一个负号结果梯度方向反了Loss不降反升。这种错误如果在框架里几乎不会暴露因为你根本接触不到中间梯度。但恰恰是这样的坑才让人真正理解什么是“梯度下降”。2. 网络结构与关键参数怎么定2.1 输入输出维度先定数据再看网络搭建网络前第一件事是确认输入输出维度。这里拿经典的MNIST手写数字数据集举例每张图片是28×28像素的灰度图展平之后就是一个长度为784的向量。输出是10个类别也就是数字0到9所以输出维度是10。输入和输出维度通常由数据本身决定不用我们费心设计。真正需要斟酌的是隐藏层的维度。隐藏层神经元数量代表了网络对特征组合的表达能力数量太少模型学不到足够复杂的模式数量太多参数量爆炸容易过拟合训练也变慢。以784–128–10这个结构为例各层参数数量如下层权重形状权重参数量偏置参数量本层总参数量输入→隐藏(784, 128)100352128100480隐藏→输出(128, 10)1280101290合计—101632138101770算一下就能看出来10万参数里绝大部分来自第一层因为输入维度784太高。这也是为什么图像任务后来普遍引入卷积核——卷积通过局部连接大幅减少了参数量。但在全连接网络里这种参数开销是结构性的选择隐藏层大小必须考虑这个代价。2.2 隐藏层大小和激活函数怎么搭配隐藏层神经元数量一般从数据规模和任务复杂度出发。对于MNIST这种任务128或256都是合理的起点再大收益会边际递减。我实测过128和512在最终准确率上差距通常不到0.5个百分点但训练时间差了好几倍。先小后大用交叉验证或验证集表现来定别一上来就追求“豪华配置”。激活函数的选择同样关键。隐藏层我现在基本固定用ReLU也就是max(0, z)。为什么不用Sigmoid因为Sigmoid在输入绝对值较大时梯度接近0多层一叠反向传播时梯度连乘几轮就消失了这也是“梯度消失”问题的核心来源。ReLU在正区间梯度恒为1能缓解这个问题计算也快。输出层的激活函数要根据任务类型来选。做多分类就用Softmax它把网络输出变成一组和为1的概率分布做二分类用Sigmoid做回归直接不加激活。很多初学者在输出层也套ReLU结果产生一堆负概率训练直接乱套。2.3 损失函数、优化器与学习率训练的三驾马车损失函数衡量网络预测和真实标签之间的差距。多分类任务配合Softmax最常用的就是交叉熵损失Cross Entropy Loss。为什么不选均方误差MSE因为交叉熵配合Softmax的梯度形式更干净——dz a - y这个形式计算简单、收敛更快。而MSE配合Sigmoid容易出现梯度饱和收敛速度慢。优化器的作用是根据梯度更新参数。最基础的是SGD公式是W W - lr * gradient。它的缺点是收敛慢且容易震荡。Adam是更实用的选择它结合了动量与自适应学习率不容易被卡在局部极小值新手拿它做默认优化器基本不会翻车。学习率是全局最敏感的参数。我踩过的坑包括学习率设0.1Loss直接NaN设0.0001训练半天Loss纹丝不动。MNIST两层网络的经验区间是0.001到0.01Adam可以取0.001SGD建议先试0.01。判断学习率是否合适有个笨办法打印前几个batch的Loss如果第一个batch的Loss就在下降后面稳步变小说明量级合适如果Loss跳来跳去甚至变成NaN说明偏大如果一路平缓说明偏小。2.4 批大小与训练轮数怎么选批大小batch size是每次更新梯度用多少样本。批大小太小梯度噪声大收敛不稳批大小太大计算开销高泛化能力有时反而变差。两层网络这种轻量级任务64或128是平衡点。我推荐新手从128开始显存或内存不够再降到64。训练轮数epoch就是整个数据集被完整过几遍。MNIST上10到20个epoch足够看到收敛趋势。关键不是追求“更大”而是观察验证集准确率的变化曲线。如果验证集准确率不再提升甚至下降就该停止训练而不是傻傻跑完预设轮数。还有一个容易忽略的细节每个epoch之前要把训练数据shuffle一遍。因为神经网络学的是样本分布如果不打乱模型会记下批次的顺序信息影响泛化。我见过有人不shuffle训练Loss曲线呈周期性的锯齿状排查半天才发现是这个原因。3. 完整实操从Numpy手写反向传播到PyTorch训练3.1 数据准备与预处理细节数据是一切的基础。MNIST在深度学习中的地位相当于编程界的“Hello World”。原始数据格式是二进制文件直接用有点麻烦我习惯用PyTorch的torchvision或者Keras自带的数据集接口几行代码就能加载。处理流程大致是这样加载数据后先转成浮点型再归一化到0到1区间。注意归一化不是简单的“除以255”就完事更标准的做法是计算训练集的均值和标准差做标准化x (x - mean) / std。这里有个容易踩的坑mean和std只能用训练集计算不能用测试集的否则相当于测试信息泄漏评估结果会虚高。数据还要做标签编码。MNIST的标签本来就是0到9的整数如果用交叉熵损失PyTorch会直接在内部做One-hot映射不用我们手动处理。但如果自己用NumPy手写交叉熵就需要先转成One-hot矩阵形式也就是把标签3变成向量[0,0,0,1,0,0,0,0,0,0]。3.2 纯NumPy实现两层全连接网络这一段是整篇的精华之一。我给出一个可以跑通的最小实现代码里每一步都和前面的公式对应。import numpy as np def softmax(x): # 减去最大值是为了数值稳定防止exp溢出 x x - np.max(x, axis1, keepdimsTrue) e np.exp(x) return e / np.sum(e, axis1, keepdimsTrue) def cross_entropy_loss(y_pred, y_true_onehot): m y_true_onehot.shape[0] # 取每个样本正确类别的预测概率加1e-8防止log(0) correct_log_probs -np.log(np.sum(y_pred * y_true_onehot, axis1) 1e-8) return np.mean(correct_log_probs) class TwoLayerNet: def __init__(self, input_dim, hidden_dim, output_dim, lr0.01): # 初始化权重用均值为0、标准差0.01的正态分布 # 不要全初始化为0否则所有神经元对称无法学习 self.W1 np.random.randn(input_dim, hidden_dim) * 0.01 self.b1 np.zeros(hidden_dim) self.W2 np.random.randn(hidden_dim, output_dim) * 0.01 self.b2 np.zeros(output_dim) self.lr lr def forward(self, X): # 前向传播记录中间结果供反向传播使用 self.X X self.z1 np.dot(X, self.W1) self.b1 self.a1 np.maximum(0, self.z1) # ReLU self.z2 np.dot(self.a1, self.W2) self.b2 self.a2 softmax(self.z2) return self.a2 def backward(self, y_true_onehot): m y_true_onehot.shape[0] # 输出层梯度dz2 a2 - y这个形式来自交叉熵Softmax的推导结果 dz2 self.a2 - y_true_onehot # 第二层权重梯度除以m是求平均梯度 dW2 np.dot(self.a1.T, dz2) / m db2 np.sum(dz2, axis0) / m # 传播到隐藏层 da1 np.dot(dz2, self.W2.T) # ReLU的导数输入大于0的位置导数为1否则为0 dz1 da1 * (self.z1 0) # 第一层权重梯度 dW1 np.dot(self.X.T, dz1) / m db1 np.sum(dz1, axis0) / m # 梯度下降更新参数 self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 def train_step(self, X, y_true_onehot): y_pred self.forward(X) loss cross_entropy_loss(y_pred, y_true_onehot) self.backward(y_true_onehot) return loss def predict(self, X): y_pred self.forward(X) return np.argmax(y_pred, axis1) def accuracy(self, X, y_true): preds self.predict(X) return np.mean(preds y_true)这份代码最核心的推导就是dz2 self.a2 - y_true_onehot。它看起来简单实际上是交叉熵损失函数对Softmax输入求导后的简化结果。很多教材花一整页推这个公式落到代码里就这一行。你要是能自己把这个式子推导出来对反向传播的理解就真到位了。有了这个类之后训练过程就是循环# 假设train_images是归一化后的(60000, 784)数组 # train_labels_onehot是(60000, 10)的One-hot数组 net TwoLayerNet(input_dim784, hidden_dim128, output_dim10, lr0.01) for epoch in range(10): # 一个epoch内可以分batch迭代这里为简洁展示全量梯度 loss net.train_step(train_images, train_labels_onehot) acc net.accuracy(valid_images, valid_labels) print(fEpoch {epoch1}, loss: {loss:.4f}, val acc: {acc:.4f})注意这个简化版用的是全量梯度下降也就是每个batch等于全部训练集。实际工程里我们会分小batch但手写全量版本更容易理解和调试。3.3 用PyTorch搭同款网络十行代码的路线手写版本跑通后再用PyTorch实现会有一个非常直观的对比。我强烈建议你同时把两个版本都写了感受一下框架到底帮我们省掉了什么。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 定义模型 model nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10) ) # 损失函数和优化器 criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) # 把NumPy数组转成PyTorch张量 X_tensor torch.tensor(train_images, dtypetorch.float32) y_tensor torch.tensor(train_labels, dtypetorch.long) # 整数标签CrossEntropyLoss会自动one-hot dataset TensorDataset(X_tensor, y_tensor) dataloader DataLoader(dataset, batch_size128, shuffleTrue) # 训练循环 for epoch in range(10): for xb, yb in dataloader: outputs model(xb) loss criterion(outputs, yb) optimizer.zero_grad() # 清空上一次梯度 loss.backward() # 自动反向传播 optimizer.step() # 更新参数 print(fEpoch {epoch1}, loss: {loss.item():.4f})看到没有nn.Linear把W和b的初始化、前向计算都封装好了loss.backward()把整个反向传播自动化了optimizer.step()自动更新参数。手写版本里几十行的backward部分到这里就只剩一行调用。但这里有个关键认知框架让代码变短却不让理解变浅。当你用PyTorch训练遇到Loss不收敛时心里想的应该是手写版本里的dz2 a2 - y哪里可能出问题而不是盲目调学习率。这就是我反复强调手写一遍的深层价值。3.4 训练、评估与模型保存训练过程中的监控指标我一般看两个训练Loss和验证集准确率。Loss下降说明模型在拟合训练数据验证准确率上升说明模型学到了可泛化的模式。两者缺一不可。训练完成后要做一个独立的测试集评估。我见过有人用验证集反复调参后拿着验证集准确率当最终结果这其实是有信息泄漏的——参数已经根据验证集做过调整验证集的表现会偏乐观。真正的最终指标必须来自模型从未见过的测试集。模型保存方面PyTorch里我推荐保存整个模型状态字典torch.save(model.state_dict(), two_layer_net.pth)载入时先创建相同结构的模型再加载参数model nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10) ) model.load_state_dict(torch.load(two_layer_net.pth)) model.eval()保存整个模型文件的方式虽然简单但结构一旦改动容易报版本兼容问题还是state_dict更干净。如果用NumPy手写版本可以用np.save把W1、b1、W2、b2分别存盘效果一样。4. 训练中容易踩的坑问题排查与调试实录4.1 Loss卡住不降先查这四件事我见过太多人一上来就问“我的Loss为什么不降”但拿到代码后基本都能归到四类原因。第一数据没有归一化。原始像素值0到255直接喂进网络梯度会忽大忽小优化器很难找到稳定的下降方向。归一化不是可选项而是必选项。第二权重初始化不合理。如果权重全部初始化为0所有神经元的梯度相同网络相当于只有一个神经元表达能力直接废掉。固定用np.random.randn(...) * 0.01或框架默认的Kaiming初始化就好。第三学习率不合适。0.01起步观察前几个batch的Loss变化。如果完全不降试着把学习率放大10倍再看如果直接NaN缩小10倍。这个二分搜索法很粗糙但很有效。第四梯度方向算错。手写代码时最常犯的错误是梯度更新方向反了或者少了负号。可以用数值梯度做对照检查对每个参数加一个微小扰动epsilon计算(loss(Weps) - loss(W-eps)) / (2*eps)再和反向传播的梯度对比。4.2 训练Loss下降但验证集准确率停滞这是典型的过拟合信号。网络把训练数据的特点背得太熟没有真正泛化到新数据上。解决办法按优先级排列第一增加训练数据量或做数据增强全连接网络对图像做随机平移之类的变换要小心效果有限第二降低模型容量也就是减小隐藏层神经元数量第三加入Dropout层随机丢弃一部分神经元强制网络学冗余特征第四加L2正则化也就是权重衰减限制权重幅度。我在MNIST上实测过784–256–10的网络不加Dropout验证集准确率大概能到97.5%加Dropout后能到98%以上。提升不大但趋势非常明显。4.3 Loss突然变成NaN梯度爆炸的经典现场Loss变NaN基本就两个原因梯度爆炸或数值溢出。最典型的是学习率设置过大参数更新幅度太大权重直接飞掉。解决方案很直接降低学习率比如从0.1降到0.01或0.001。另一个隐蔽的坑是Softmax的数值稳定性。如果网络输出z的值非常大比如100exp(100)直接溢出为无穷大Softmax计算出NaN。所以我的手写实现里第一行就做了x x - np.max(x, axis1, keepdimsTrue)。这是标准做法PyTorch的交叉熵损失内部也做了类似处理。还有一个新手容易忽略的训练到一半数据里出现NaN。检查一下数据集是否完整有没有缺失值。虽然MNIST这类标准数据集不会出问题但换成自己采集的数据就很可能踩坑。4.4 维度报错用Debugger盯着shape看维度报错是全连接网络最容易遇到的运行时问题。它的好处是错误信息明确但前提是你知道怎么快速定位。我的习惯是在forward的每个关键步骤打印中间张量的shapeprint(X.shape) # 期望(batch_size, 784) print(self.W1.shape) # 期望(784, 128) print(self.z1.shape) # 期望(batch_size, 128)直观对照每层的矩阵乘法要求(m, k)乘以(k, n)中间维度必须相等。如果你输入是一维的(784,)没有变成(1, 784)全连接层会直接报警。这时需要用X.reshape(-1, 784)或X.unsqueeze(0)补上batch维度。还有个小细节PyTorch的nn.Linear期望输入是二维张量(batch, input_dim)。如果你手写了一个函数接收单样本向量训练时也要记得统一加batch维度否则代码可能不报错但计算逻辑已经错了。4.5 一个常见问题速查表我在下面整理了一个速查表基本涵盖了初学者在两层全连接网络里碰到的所有典型问题。现象可能原因排查与解决Loss不下降学习率太小、数据未归一化、梯度方向错打印前几个batch梯度核对数值梯度归一化数据Loss出现NaN学习率太大、权重初始化过大、Softmax溢出降低学习率缩小初始化方差检查输入是否含NaN准确率停滞模型容量不够、数据量不足增大隐藏层增加Epoch检查数据分布训练准率高于验证准率过拟合加Dropout、L2正则化、降低模型容量Loss曲线周期性波动忘记shuffle数据每个epoch前打乱训练集顺序模型输出全是同一个类别类别不均衡、输出层没有Softmax、初始化偏置过大检查类别分布确认输出层设置重置模型参数测试集准率低于验证集准率验证集被反复使用导致信息泄漏用独立测试集评估调参只参考验证集这张表不是讲完就完事的建议你在实操过程中自己往里补充。每个人遇到的坑细节都不一样记录下来才是最珍贵的调试资产。最后再说几句操作体会做这个小项目我最强烈的感受是深度学习入门的阻碍从来不是数学公式有多难而是“好像懂了”和“真正能跑”之间的距离。手写一遍两层全连接网络等于亲手把这段距离走了一遍之后再学卷积、RNN、Transformer很多概念都能联系回这条基本链路里。最后还有一个小技巧送给你。别急着在大型数据集上做实验先造一个几十个样本的微型数据集如果模型能在这个小样本上把Loss降到接近0说明模型本身能学习再换到真实数据集如果Loss降不下去问题基本出在数据或超参数而不是网络结构。这个“小样本冒烟测试”的办法我在后来的复杂项目里也一直在用每次都能快速揪出低级错误。
返回列表