深度学习入门实战:从零搭建多层感知机训练MNIST手写数字识别
我翻了翻自己前两篇《从零开始的深度学习》的留言区发现一个很有意思的现象环境配置那篇评论全是“终于装好了”“conda又炸了”第二篇线性回归那篇评论变成了“懂了但好像又没完全懂”“然后呢这能干啥”。说实话这个反馈特别真实。我写三的时候就想好了这篇必须解决“然后呢”这个问题。前两篇我们把Python、PyTorch、张量这些地基打完了也亲手从零实现了一个线性模型知道了什么叫梯度下降。但线性模型能干的事太有限了它连一个简单的异或XOR逻辑都学不会。这一篇我们从零搭建一个真正的多层感知机把隐藏层、激活函数、反向传播这些核心概念全部过一遍最后用PyTorch训练一个手写数字识别模型跟着loss曲线和准确率把整个训练流程跑通。学完这一篇你基本就能看懂社区里绝大多数入门级深度学习项目了。1. 从线性到非线性为什么单层网络会撞上天花板1.1 一个连小学生都能秒答的题目模型却学不会先做个思维实验。坐标平面上有四个点(0,0)、(1,1)属于A类(0,1)、(1,0)属于B类。这不是什么困难任务人眼一眼就能看出规律——A类是“两个坐标相等”B类是“两个坐标不等”。这个任务就是经典的XOR问题。但如果你手里只有一个单层感知机它的输出永远是输入的线性组合加偏置你画出来的分界线只能是一条直线。不管你怎么调整权重w1、w2和偏置b这条直线在平面坐标系里扫来扫去永远无法把两个对角位置的点归为一类、另外两个对角位置的点归为另一类。这不是训练技巧的问题是模型结构的能力上限问题。1969年Minsky和Papert在《Perceptrons》这本书里用这个例子搞了个“精准打击”直接把第一代神经网络的研究打入冷宫。当时的人们发现我们引以为傲的感知机连一个最基础的逻辑运算都做不了研究经费自然就撤了。这段历史告诉我们一个核心结论线性模型的表达能力是有数学天花板的突破它必须引入非线性。1.2 深度学习的“深度”到底深在哪那是不是多加几层就能解决XOR理论上思路是对的但有一个隐藏的坑必须跳过去。假设我在输入层和输出层之间堆了三层线性变换公式长这样z W3(W2(W1·x b1) b2) b3你把括号一层层展开会发现这本质上任然是另一个线性变换能合并成一个等效的W和b。多层线性层堆叠在一起和单层线性层没有任何本质区别——这就像你写了三行“放大10倍”的代码最后效果还是放大1000倍并没有产生质变。所以真正让“多层”变成“深度学习”的不是层数而是夹在层与层之间的非线性映射也就是激活函数。只有每一层输出经过一个非线性函数比如ReLU之后再送入下一层模型才具备了拟合曲线、曲面乃至各种复杂分布的能力。你甚至可以说深度学习表达力的来源就是“线性变换非线性激活”的反复复合。弄清楚这一点你再看任何神经网络结构底层逻辑都会变得非常清晰。2. 多层感知机隐藏层带给模型质的改变2.1 从维度视角看懂信息流动多层感知机MLP这个名字听着唬人结构其实非常直白一个输入层、若干个隐藏层、一个输出层。咱们以最经典的“输入层-单隐藏层-输出层”为例把张量维度走一遍。假设输入是一个28×28像素的手写数字图片我们把它展平成一维向量长度784。那么输入X的形状是[batch_size, 784]。第一层线性变换把784维压缩到隐藏层神经元数量h权重W1形状是[784, h]偏置b1形状是[h]于是得到隐藏层的净输入Z1 X W1 b1形状[batch_size, h]。接着过激活函数得到A1 relu(Z1)A1形状不变。第二层把隐藏层输出映射到分类数qMNIST是10个数字q10权重W2形状是[h, 10]得到网络原始输出logits形状[batch_size, 10]。后续加一个softmax把它变成概率分布或者直接拿logits和交叉熵损失函数配合用。这里最关键的变化发生在隐藏层。隐藏层神经元数量h是一个超参数h太小模型学不动h太大容易过拟合而且训练慢。我在自己的项目里一般遵循一个经验第一版先用输入维度到128到256的隐藏层跑通了再根据结果上下调整而不是一上来就堆一个上千宽度的大网络。2.2 激活函数的选择三大派系对比激活函数是让网络“活”起来的开关。目前最常用的主要有三个流派我直接给出它们的函数形式、取值范围和典型问题方便你对照着选。激活函数公式输出范围优点经典问题Sigmoid1/(1e^(-x))(0, 1)处处可导能把结果压成概率语义梯度消失严重输出均值非0收敛慢Tanh(e^x-e^(-x))/(e^xe^(-x))(-1, 1)输出均值为0比sigmoid略好两侧饱和区域导数趋近0深了照样梯度消失ReLUmax(0, x)[0, ∞)计算极快正区间梯度恒为1收敛快神经元可能“死掉”学习率太大时更明显隐藏层我强烈建议直接用ReLU没有特殊理由不要用Sigmoid。输出层如果是二分类用Sigmoid多分类直接输出logits配合交叉熵损失不需要手动再加SoftmaxPyTorch的CrossEntropyLoss内部已经帮你做了。看到这里你可能会问为什么说多层线性堆叠没用但换个非线性激活就有效果从数学上理解ReLU这样的分段线性函数可以通过足够多的线性片段去近似任意复杂函数。神经网络的宽度和深度共同决定了它能近似多少种“拐弯”这就是为什么“非线性×多层拟合一切”的真正数学依据。2.3 我的第一个MLP前向代码理解了维度以后写前向传播就是顺水推舟的事。下面这个例子我故意没用nn.Module先用最原始的nn.Parameter写让你看清每一层的矩阵乘法是怎么衔接的import torch import torch.nn as nn class TwoLayerMLP(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.W1 nn.Parameter(torch.randn(in_dim, hidden_dim) * 0.1) self.b1 nn.Parameter(torch.zeros(hidden_dim)) self.W2 nn.Parameter(torch.randn(hidden_dim, out_dim) * 0.1) self.b2 nn.Parameter(torch.zeros(out_dim)) def forward(self, x): # x: [batch, in_dim] z1 x self.W1 self.b1 # [batch, hidden_dim] a1 torch.relu(z1) # [batch, hidden_dim] z2 a1 self.W2 self.b2 # [batch, out_dim] return z2这段代码里的* 0.1是初始化缩放。为什么非要乘个0.1因为如果初始权重太大输入进ReLU之前的值就会过大线性变换的结果会两极分化不利于训练。实际工程里一般直接用nn.Linear层它会用Kaiming初始化自动处理这个问题但你作为初学者手动写一遍这个流程对“参数是怎么存活的”会有特别直观的感受。3. 反向传播让多层网络可训练的数学引擎3.1 链式法则和“责任分摊”机制前向传播算出了预测值和真实标签一对比我们有了loss。现在的问题是loss这么大到底是因为W1里哪个元素错了还是因为W2里哪个权重错了这本质上是一个“责任分摊”问题。反向传播的本质就是高等数学里的链式法则。假设模型的损失记为L某一个权重w的变化会通过影响它所在层的输出z再影响下一层的激活a最后影响L。链式法则告诉我们∂L/∂w (∂L/∂a) × (∂a/∂z) × (∂z/∂w)如果把网络看成一个复杂的复合函数那么从loss出发沿着数学依赖关系一层层往回求偏导就能算出每一个参数的梯度。难点在于网络层数很深以后手动推导这些偏导极其痛苦。计算图的概念就是为此而生的把前向运算拆成一个有向无环图每个节点保存自己的运算规则和前向计算的结果反向时从顶层节点出发依次调用每个节点的局部导数把梯度逐层传回去。3.2 手算一个最简网络的梯度建立直觉空谈公式容易飘我带你手算一个超级简化的例子。假设网络只有两层输入是单个标量x 1W1是标量参数且初始值W1 2没有偏置中间的隐藏层输出经过ReLU第二层权重W2初始值是3输出直接作为loss即L z2。我们来算一算梯度应该怎么流动前向过程z1 W1 * x 2 * 1 2 a1 relu(z1) max(0, 2) 2 L z2 W2 * a1 3 * 2 6反向过程∂L/∂W2 a1 2 ∂L/∂a1 W2 3 ∂a1/∂z1 1 因为z1 0ReLU导数是1 ∂z1/∂W1 x 1 ∂L/∂W1 (∂L/∂a1) * (∂a1/∂z1) * (∂z1/∂W1) 3 * 1 * 1 3所以W1的梯度是3W2的梯度是2。如果学习率是0.1那么更新后W1会变成1.7W2变成2.8。这个例子里有一个非常重要但很容易忽略的细节W1的梯度里包含了“上游”的W2信息。越是靠前的层它的梯度越依赖于下游层的所有参数。这就是为什么网络一深靠近输入层的梯度往往容易越传越小或者越传越大前者叫梯度消失后者叫梯度爆炸。3.3 手动实现还是依赖自动微分看到这里你是不是已经在盘算要不要趁热写一个反向传播的代码我的建议是为了理解手推一次为了效率用框架的自动微分。PyTorch里每个张量都有requires_grad属性前向过程会把每一步计算都记录在计算图中。你只要调用loss.backward()框架就会自动帮每个需要梯度的参数累积出param.grad然后执行optimizer.step()完成更新。我在教学里反复强调一点不要觉得用了autograd就不用理解反向传播了。恰恰相反你在调bug的时候会无数次遇到“为什么这个参数的梯度是None”“为什么loss炸掉了”不懂反向传播的计算逻辑你连排查的方向都没有。至少要把上面那个手算例子算到滚瓜烂熟你说不会再有玄学。4. 一次完整的MNIST多层模型训练实录4.1 数据准备认数字之前先处理像素MNIST是一个70,000张28×28手写数字图片的数据集堪称深度学习界的“hello world”。我第一次跑通它的时候内心的成就感不亚于第一次用print打出“Hello World”。但这之前必须先处理两件事归一化和展平。归一化原始图片像素值在0到255之间直接丢给网络会让激活值分布非常不均匀训练起来像老牛拉破车。最简单的做法是除以255让像素值落在0到1之间。展平PyTorch的卷积层后面如果要接全连接层得先把它从[28, 28]变成一个784维的向量。MNIST数据集本身下载以后是PIL格式我们直接用transforms.ToTensor()它会自动把HWC转换成CHW并归一化到[0,1]省去手动操作from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), # 转张量并归一化到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 用数据集的全局均值和标准差标准化 ])这里的均值0.1307和标准差0.3081是MNIST官方统计值。归一化以后数据分布近似标准正态模型训练会更快更稳。对读入图像数据而言这一步几乎是标配。4.2 定义模型我用nn.Sequential写了一版最简洁的MLP前文用nn.Parameter手动实现是为了让你理解矩阵乘法但真实项目里没人会这么写。我们用nn.Sequential把层堆起来代码清爽而且和PyTorch生态完美配合import torch.nn as nn class MnistMLP(nn.Module): def __init__(self): super().__init__() self.net nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): x x.view(x.size(0), -1) # 展平成 [batch, 784] return self.net(x)我用了两个隐藏层256和128个神经元。为什么是这两个数没有玄学就是经验选择256对CPU训练友好128继续降一半维度让信息逐渐压缩抽象。你也可以改成512和256试试大概率准确率会略高但训练时间变长这就是超参数权衡的直观体验。4.3 训练循环四行固定套路段段拆开讲训练循环是深度学习项目里最固定、也最容易出错的环节。先上完整代码我再逐行拆解import torch.optim as optim model MnistMLP() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01) def train_one_epoch(epoch): model.train() total_loss 0.0 correct 0 total 0 for images, labels in train_loader: optimizer.zero_grad() # 第一步清空梯度 outputs model(images) # 第二步前向传播 loss criterion(outputs, labels) # 第三步计算损失 loss.backward() # 第四步反向传播计算梯度 optimizer.step() # 第五步更新参数 total_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() avg_loss total_loss / len(train_loader) acc correct / total print(fEpoch {epoch}: loss {avg_loss:.4f}, acc {acc:.4f})这五步是训练循环的“铁律”顺序一个字都不能乱。我想特别强调第一步optimizer.zero_grad()。PyTorch的梯度默认是累加的如果你不清空那上一批数据的梯度就会和当前批的梯度加在一起导致参数更新方向完全错乱。很多新人跑出来的loss忽高忽低、完全没规律八成就是忘了这一行。第二步前向传输出来的logits是形状[batch, 10]的原始数值不需要手动过softmax因为nn.CrossEntropyLoss内部已经把softmax和负对数似然合在一起了数值稳定性更好。4.4 我的实测结果从菜鸡到收敛的过程我在自己的笔记本CPU上跑了3个epoch测试准确率从86%一路爬到96%以上。下面是其中一次运行的输出Epoch 0: loss 0.5678, acc 0.8582 Epoch 1: loss 0.2271, acc 0.9365 Epoch 2: loss 0.1668, acc 0.9560注意一个关键信号epoch 0到epoch 1是准确率提升最猛的一段从85%跳到93%而epoch 1到epoch 2只提升了2个百分点。这种“先陡后平”的曲线是深度学习训练的常态说明模型先把最容易学的粗粒度特征抓到了后面是在做精雕细琢。如果你发现自己的曲线从头到尾都是平的那就不正常了说明有bug而不是“模型不好”。同样重要的是测试环节训练集准确率再高也不能说明问题必须看测试集model.eval() test_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) loss criterion(outputs, labels) test_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() print(fTest acc: {correct / total:.4f}) # 大约0.97左右4.5 我在这个项目里踩过的那些坑第一个坑是维度不匹配。nn.Linear(784, 256)要求输入最后一位是784但如果你忘了view(x.size(0), -1)而直接把[64, 1, 28, 28]的四维张量丢进去PyTorch会报一个长得让人头皮发麻的维度错误。解决办法是养成把模型的使用说明写在注释里的习惯前向函数第一行就是x.shape的调试打印。第二个坑是CPU训练太慢让我一度怀疑人生。如果你跑完整的一天只能跑一个epoch请检查是不是数据加载环节出了问题比如num_workers设得太大导致频繁启动进程或者误把整个数据集to(cuda)来回折腾。对于MNIST这种小任务CPU上跑几个epoch也就几分钟不会慢到离谱。第三个坑是测试时忘了model.eval()和torch.no_grad()。model.eval()会切换BatchNorm和Dropout的工作模式torch.no_grad()可以关掉自动求导的内存占用。我在入门阶段经常看到有人测试准确率比训练准确率高一大截以为捡了个大便宜其实就是忘了切换模式导致的假象。5. 训练曲线不会说谎新手最容易忽略的四个信号5.1 学习率一失足成千古恨的最常见根源学习率可能是你在整个深度学习生涯里最先需要反复调的超参数。我见过太多新手把lr设成1.0或者0.5然后看着loss曲线从正常值直接飙到几百甚至NaN一脸惊恐。学习率过大参数更新步长太大会直接越过最优点甚至发散学习率过小loss曲线像蜗牛一样爬训练几百个epoch也没效果。判断学习率是否合适的标准很简单如果loss在第一二个batch就变成NaN先降10倍试试如果loss下降速度慢得像静止先升10倍试试。我的经验值是从0.01开始用SGD时如果数据量很大或者模型很深调成0.001的Adam是比较稳妥的起点。5.2 初始化陷阱死亡ReLU是如何诞生的ReLU有个致命缺点是“死亡神经元”。如果一个神经元的净输入z长期小于0ReLU的输出恒为0梯度也恒为0这个神经元就再也活不过来了。我遇到过一整个隐藏层全死掉的情况具体表现是loss完全不变像被冻住了一样。死亡ReLU最常见的触发场景就是权重初始化过大学习率过大。前面我手动实现时写的torch.randn(...) * 0.1就是在防这个。后来用nn.Linear默认的Kaiming初始化基本不用操心。但如果你自己玩花活比如在网上看到某篇文章教你把权重初始化为全0请立刻关掉那个页面——全0初始化会让所有神经元做完全相同的更新网络在数学上退化成只有一个有效神经元不管你怎么训练都不可能学出好结果。5.3 过拟合训练loss降了测试集为什么反而变差还有一个常见现象训练集准确率一路飙升到99%测试集准确率却停在85%上下徘徊。恭喜你过拟合了。模型就像一个只会背答案的学生训练集里的噪声和细节都背下来了一到没见过的题就抓瞎。缓解过拟合至少有四种常见手段增加数据数据增强、降低模型容量减少隐藏层宽度、增加正则化L2权重衰减、添加Dropout。入门阶段最推荐先试Dropout因为在PyTorch里加一行nn.Dropout(0.5)几乎不费任何力气self.net nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Dropout(0.2), # 训练时随机丢弃20%的隐藏层神经元 nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) )5.4 从loss曲线到根因一份实用的排查清单最后分享一个我自己调试模型时固定走的排查顺序强烈建议直接抄作业如果loss是NaN先降学习率再检查数据里有没有无穷值或异常标签最后检查梯度是否爆炸。如果loss完全不下降先跑一个batch的数据在CPU上调通确保前向和反向没有报错再检查是不是所有样本标签都相同导致模型学到了一个常量输出。如果loss忽高忽低多半是batch size太小或者学习率太大梯度更新噪声太大。如果训练集和测试集准确率差距超过5个百分点优先看看测试时有没有忘记model.eval()其次再考虑过拟合。如果准确率卡在某个低水平上不去不是模型结构问题就是数据处理问题先用PCA看看数据能否线性分类如果线性可分这个线性层就能很容易得分你的MLP不应该比它更差。我记得自己第一次带着这个排查清单去小伙伴的代码仓库里找问题十分钟就定位到他把学习率设成了0.5然后看着他把学习率改成0.005之后loss丝滑下降的表情那种感觉特别有成就感。深度学习跑起来其实没那么玄乎只要看懂反向传播和训练循环剩下的问题大多数都能从训练曲线里读出答案而这一篇恰好就是你跨过这道门槛的地方。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →