ARTICLE DETAIL

资讯详情

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

从PyTorch入门到实战:手写第一个前馈神经网络与训练踩坑指南

从PyTorch入门到实战:手写第一个前馈神经网络与训练踩坑指南 搞深度学习绕不开一个框架而 PyTorch 几乎是我见过的所有人写第一个神经网络时最顺手的选择。它的动态计算图、Python 风格的调试体验以及庞大的社区生态让新手不必在框架的语法和模型的思想之间两头分心。这篇文章我就想完整地带你走一遍从环境搭建、数据准备到动手写出一个真正能训练的前馈神经网络再到把训练过程中最容易踩的坑全部摆出来。我会用 MNIST 手写数字识别这个经典任务作为贯穿全文的例子——它足够简单又能把神经网络的核心机制讲清楚。无论你是刚接触机器学习的本科生、准备转行做算法工程师的开发者还是搞了几年工程想回头夯实基础的软件工程师只要你想把神经网络四个字从概念变成一行行能跑的代码这篇文章都适合。1. 为什么第一支神经网络要选 PyTorch1.1 PyTorch不是另一个框架而是一种思维方式很多人第一次接触深度学习会被框架之争搞糊涂。TensorFlow、PyTorch、PaddlePaddle、JAX……名字多得吓人。但在我看来2024 年之后的现实情况是PyTorch 已经成为学术论文和工业落地之间衔接最顺畅的桥梁。热门热搜词里也能看出端倪pytorch 转 onnx、pytorch 实战这类搜索热度非常集中——大家都默认选了 PyTorch然后在它的基础上继续探索部署、加速、跨框架转换。PyTorch 的核心优势用一句话说就是动态计算图。也就是说你的网络在每次前向传播时都是现算现搭的这让打印中间结果、打断点调试、修改网络结构变得像写普通 Python 代码一样简单。对比之下静态图的框架相当于先画一张完整的施工图纸再动工中间想改设计就得重画整张图。对于学习阶段和科研阶段的频繁试错来说动态图就是降维打击。这种设计哲学的差异直接决定了 PyTorch 的学习曲线更平缓。你不需要先理解什么是计算图才能跑通第一个模型你只需要按照直觉写代码框架在背后帮你完成自动求导——也就是把所有参数的梯度都自动算好等着你拿去更新。提示不要在框架选型上花太多时间。选择一个生态足够大、能让你最快跑通端到端实验的框架比纠结哪个更优雅重要得多。对绝大多数人这个答案就是 PyTorch。1.2 一个神经网络项目的完整管线长什么样在动手写代码之前脑子里必须有一张全景图。神经网络项目不是写一堆网络层然后点运行那么简单它是一条环环相扣的流水线数据准备拿到原始数据做清洗、归一化、划分训练集和测试集。数据加载通过 DataLoader 把数据打包成一个个 batch供训练时批量喂给模型。模型定义设计神经网络的结构也就是有多少层、每层多少个神经元、用什么激活函数。损失函数定义一个衡量模型预测得有多差的指标比如分类任务最常用的交叉熵损失。优化器选择一种参数更新的算法比如 SGD 或 Adam它负责根据梯度调整模型的权重。训练循环反复执行前向传播 → 算损失 → 反向传播 → 更新参数这个过程。评估与验证用没训练过的数据检验模型的泛化能力避免模型只是背下了训练集。这篇博文要做的就是把这七个环节逐一拆开用代码和图文把它讲透。很多人卡住不是因为某个环节有多难而是不知道这些环节之间的接缝在哪里。2. 环境搭建比想象中容易踩坑的环节2.1 CPU 版还是 GPU 版第一次该怎么选这是新手问我最多的问题。我的答案很直接如果你只是想跑通第一个神经网络CPU 版本完全够用。MNIST 这种 28×28 的小图一个四层的全连接网络用 CPU 训练一个 epoch 也就几十秒钟完全在可接受的范围内。但如果你下载了 GPU 版却又装不上 CUDA反而会陷入环境地狱。GPU 版不是装上就能用它要求你的显卡支持 CUDA、驱动版本匹配、CUDA 工具包安装正确还得注意 PyTorch 版本与 CUDA 版本的对应关系。这个链路里任何一个环节不匹配都会抛出莫名其妙的错误。我的建议是分两步走先装 CPU 版 PyTorch把模型流程跑通建立信心。确认自己认真要做深度学习之后再花一个下午专门配置 GPU 环境。注意如果未来真的要配 GPU优先去 PyTorch 官网用它的配置向导选择你的系统、包管理器、CUDA 版本复制它生成的那条命令来安装。不要自己在搜索引擎里找零散的教程帖版本不匹配的问题八成出在这里。2.2 用 conda 快速搭一个干净的 Python 环境我强烈建议所有 Python 项目都用虚拟环境隔离开深度学习的依赖尤其复杂相互污染的话简直是一场灾难。这里我推荐 Anaconda 或者 Miniconda——Anaconda 适合新手自带一堆常用包Miniconda 更轻量适合已经知道自己要装什么的熟手。创建一个专门的环境尽量指定 Python 版本conda create -n pytorch-env python3.10 -y conda activate pytorch-env激活环境后安装 CPU 版 PyTorch 最稳妥的方式是走官方源但国内网络环境有时候会非常慢甚至超时失败。这种情况下建议先给 conda 配好国内镜像源再执行安装命令# 配置镜像源Anaconda 为例 conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ conda config --set show_channel_urls yes # 安装 CPU 版 PyTorch conda install pytorch torchvision torchaudio cpuonly -c pytorch如果说 conda 用起来能给你兜底那 pip 装 PyTorch 则更像变数摇奖往往需要借助国内 PyPI 镜像pip install torch torchvision torchaudio --index-url https://mirrors.aliyun.com/pypi/simple/具体用 conda 还是 pip取决于你最终想进哪个生态。conda 对底层依赖的管理更省心pip 则是 Python 世界的通用惯例。混用没问题但最好在一个环境内尽量保持一致。2.3 验证安装是否成功环境装好后的第一件事不是直接写模型而是验证它是否真的能跑。打开终端进入刚才创建的环境执行import torch print(torch.__version__)如果顺利输出版本号说明基础安装没问题。再跑一个张量运算和一个自动求导的小实验来确认核心机制正常x torch.tensor([1.0, 2.0, 3.0], requires_gradTrue) y x.pow(2).sum() y.backward() print(x.grad)输出的x.grad应该是tensor([2., 4., 6.])——这正是平方和函数对 ([1,2,3]) 的导数。看到这个结果恭喜你PyTorch 最核心的自动求导机制已经在你机器上工作了。3. 网络定义动手实现一个前馈神经网络3.1 先理解输入输出的维度关系很多人第一次写神经网络代码抄下来了但一改输入数据就报维度错误。根本原因是没有理解**张量的形状shape**是如何在层与层之间流动的。拿 MNIST 数据集来说每张图片是 (28 \times 28) 像素。一个全连接层nn.Linear接受的是二维矩阵形状为(batch_size, input_features)。所以图片在进入网络之前必须先被拉平成向量——28×28 展平就是 784 个特征。这个过程在 PyTorch 里用view(-1, 784)完成。一旦理解了这一点定义网络就变成纯粹的搭积木输入 784 维 → 第一层 128 个神经元 → 激活函数 → 第二层 64 个神经元 → 激活函数 → 输出 10 个类别得分。输出层为什么是 10因为 MNIST 有 0~9 共 10 个数字类别。每个类别一个输出节点输出的值表示模型认为该图片属于该类别的信心分数。3.2 用 nn.Module 封装你的网络结构PyTorch 中定义网络的标准方式是继承nn.Module。这不仅仅是约定俗成它背后有切实的好处nn.Module会自动跟踪你注册的所有子模块和参数在调用.cuda()或.to(device)时它能递归地把所有参数迁移到指定设备在调用.parameters()时它也能收集全部需要更新的权重。如果不用它这些基础设施你都得自己手写。import torch import torch.nn as nn import torch.nn.functional as F class FirstNetwork(nn.Module): def __init__(self): super(FirstNetwork, self).__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 64) self.fc3 nn.Linear(64, 10) def forward(self, x): x x.view(x.size(0), -1) # (batch_size, 784) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) # 输出层不加激活后面交给 CrossEntropyLoss return x这里有个细节值得展开__init__中定义了三个nn.Linear层它们的权重参数会在首次调用forward之前被自动初始化。forward函数则描述了数据流动的过程展平 → 全连接 → ReLU → 全连接 → ReLU → 全连接。而最后一个全连接层没有接激活函数——这不是疏忽。因为后面我们要用nn.CrossEntropyLoss它本身就内置了 softmax 操作。如果你在输出层提前做了 softmax反而会造成数值上的重复计算甚至影响训练稳定性。3.3 激活函数和参数初始化小细节大影响为什么网络层与层之间必须夹一个激活函数我用一个生活化的类比解释如果没有激活函数多层全连接算到最后本质上还是一个线性变换那再深的网络也和一层没什么区别。激活函数的作用是给网络引入非线性让它有能力拟合出各种复杂的决策边界。ReLU 是最常用的选择公式就是max(0, x)。它计算极快导数不是 0 就是 1很好地缓解了梯度消失问题。实际中替换成 LeakyReLU、GELU 等都是在这个基础上的微调。参数初始化则是一个经常被忽略但影响很大的环节。PyTorch 在创建nn.Linear时会自动按照默认策略初始化参数常见的是均匀分布初始化大多数情况下够用。但如果你发现模型训练初期 loss 下降极其缓慢甚至完全不动可以检查一下是否该用 Xavier 或 He 初始化。对于 ReLU 家族的网络He 初始化通常比默认初始化收敛更快因为它在设计时考虑了 ReLU 会让大约一半神经元输出为 0 的特点。def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_uniform_(m.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(m.bias)把这段函数在你的模型上执行一次即可model FirstNetwork() model.apply(init_weights)不过对于第一个网络先不用追求太多技巧。记住有这回事等后面发现收敛问题时再回头看会有更深的体会。4. 训练循环从 loss 到 backward 的完整链路4.1 数据加载器DataLoader 不只是简单地切数据写完网络之后接下来要准备数据。MNIST 在 torchvision 里有现成的接口非常友好。加载并做归一化import torchvision import torchvision.transforms as transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, shuffleTrue ) test_loader torch.utils.data.DataLoader( test_dataset, batch_size64, shuffleFalse )transforms.ToTensor()会把 PIL 图片转成 ([0,1]) 区间的张量同时自动把(H, W, C)调整为 PyTorch 约定的(C, H, W)排列。Normalize((0.1307,), (0.3081,))是 MNIST 数据集的全局均值和标准差做了标准化之后不同特征的值域就会处于相近的尺度训练会更稳定。提示千万不要小看归一化这一步。如果把像素值直接以 0~255 的整数喂给网络第一个全连接层的输出会被拉得很大进入激活函数的饱和区梯度不是消失就是爆炸训练效果会差出好几个数量级。DataLoader 则是把数据集变幻成了批量投喂的形态。batch_size64表示每次取出 64 张图作为一批shuffleTrue表示每个 epoch 都重新打乱顺序防止模型只学到数据的固定排列顺序。num_workers这个参数我会在踩坑部分专门讲。4.2 训练三步曲前向传播、计算损失、反向传播模型定义好、数据准备好之后训练的核心就是一个循环。逻辑上只有三个动作model FirstNetwork() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) epochs 5 for epoch in range(epochs): running_loss 0.0 for images, labels in train_loader: # 1. 前向传播把输入数据喂给模型得到预测输出 outputs model(images) # 2. 计算损失比较预测值和真实标签得到差距 loss criterion(outputs, labels) # 3. 反向传播清空旧梯度 → 计算新梯度 → 更新参数 optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fEpoch [{epoch 1}/{epochs}], Loss: {avg_loss:.4f})这里每一行背后都有值得深挖的逻辑。optimizer.zero_grad()为什么必须存在因为 PyTorch 的机制是梯度累积的——每次调用backward()时计算出的梯度会被加到已有梯度上而不是覆盖。如果不清零上一个 batch 的梯度会和当前 batch 的梯度叠加导致参数更新方向完全错乱。这个坑几乎每一个 PyTorch 新手都会踩。loss.item()则是把只含一个数值的标量张量转成 Python 浮点数方便我们打印 loss。这里要注意loss.item()不会破坏计算图它只是取了一个值出来。4.3 optimizer 和 criterion 的选择逻辑损失函数我直接选了CrossEntropyLoss它对多分类任务几乎是默认选择。它的内部流程是先对模型输出的原始分数做 softmax 转成概率分布再计算预测分布与真实标签分布之间的交叉熵。从数值稳定性上来讲PyTorch 把 softmax 和交叉熵融合实现避免了对数零值和指数溢出的问题所以你在输出层千万不要多此一举再加 softmax。优化器我建议第一次就用 Adam。SGD 虽然理论基础扎实但学习率和动量调起来麻烦新手很容易卡在loss 不降的困境里。Adam 带有自适应学习率的机制对学习率的敏感度低很多默认lr0.001就能稳定训练绝大多数模型。等以后熟练了可以再回去感受一下带冲量的 SGD 在泛化性能上可能带来的收益——不过那都是后话了。4.4 在测试集上评估光看训练 loss 远远不够训练过程中 loss 下降只说明模型在见过的数据上表现变好但这不一定意味着它具有泛化能力。所以每个 epoch 结束之后都应该在测试集上验证一次准确率def evaluate(model, test_loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() return 100.0 * correct / total这段代码里有两个值得注意的地方。model.eval()会切换模型的运行模式。对于 Dropout 层和 BatchNorm 层训练模式和评估模式的行为是完全不同的。训练时 Dropout 以一定概率随机丢弃神经元防止过拟合评估时则必须保持所有神经元活跃才能给出稳定的预测结果。torch.no_grad()则是告诉 PyTorch 不必为这段代码构建计算图。评估过程不需要梯度关闭梯度记录既能提速也能省内存。这是 PyTorch 的一个小巧但重要的最佳实践。5. 实测结果与新手最容易踩的四个坑5.1 坑一num_workers 参数导致程序在 Windows 上卡死处理 DataLoader 的时候许多人会看到教程里写num_workers4来加快数据加载速度。但是在 Windows 系统上如果你把这段代码直接放在 Python 脚本里而不是if __name__ __main__:保护块中程序会在数据加载环节反复报错甚至直接卡死。原因是 Windows 下 Python 多进程是通过重新导入主模块来实现的如果主模块在导入时就开始跑训练循环子进程会跟着再跑一遍形成递归调用。规避方式很简单训练循环代码务必放进if __name__ __main__:块里。第一次跑通时直接用num_workers0也就是让主进程做数据加载避免多进程的复杂性。等代码能在num_workers0下稳定运行再去逐步提升这个参数。5.2 坑二训练/评估模式没切换结果忽高忽低我见过不少同学的模型在训练时 loss 表现不错一到测试集上准确率就剧烈波动有时候比训练集低了十几二十个百分点。排查半天才发现模型在评估之前忘了调用model.eval()。如果你的网络里有 Dropout 层或者 BatchNorm 层训练时的随机性和统计特性会在测试时造成极大的不一致。训练阶段 Dropout 会让一部分神经元失活而测试阶段如果不关闭 Dropout模型的输出带有随机性评估结果自然不稳定。同理BatchNorm 在训练时会使用当前 batch 的均值和方差在测试时应该使用全体数据的滑动统计值——只有eval()模式才会切换到这个行为。反过来也是坑训练的时候忘了从eval()模式切回train()模式会导致训练异常。5.3 坑三归一化和学习率对不上号loss 直接起飞如果你发现训练时 loss 不仅不降反而越升越高甚至变成 NaN通常有两个原因第一个原因是输入数据没有归一化。原始图像的像素值 0~255 在进入网络后经过加权求和会产生很大的数值。这时候交叉熵输出层的 softmax 会进入数值不稳定的区间梯度很容易溢出。归一化之后数据集中在 0 附近模型的权重更新才能稳定进行。第二个原因是学习率过大。Adam 的默认学习率 0.001 对大多数情况是个好起点但如果你自行调大到了 0.1 甚至 1.0参数每次更新的步长会大到直接跳过最优点loss 会呈现剧烈震荡甚至发散。遇到这种情况第一反应应该是把学习率降到 0.0001 试试而不是去改网络结构。5.4 坑四把训练 loss 低误当成模型好第一次跑通神经网络时如果训练集准确率达到了 99%很多人就觉得大功告成了。这时候我会建议你多做一个动作看一眼测试集准确率。如果两者差距太大——比如训练 99%、测试 90%——这说明模型出现了过拟合。过拟合的本质原因是模型把训练数据中的噪声也一并记住了而不是提炼出数字本身的规律。缓解手段有很多加大数据量、做数据增强、加入 Dropout 层、降低模型容量、提前停止训练等。新手阶段不需要全部掌握但至少要知道这个问题的存在。一个非常直观的检验方法随机抽取一些测试图片把模型预测的标签和真实标签并排打出来看看错的是哪些样本。你往往能从中发现数据的某些特性比如7和1之间的混淆、4和9之间的混淆这是模型真正没学会的地方。6. 从全连接出发你下一步该学什么6.1 全连接网络的天花板在哪里全连接网络在 MNIST 上能轻松达到 97% 以上的准确率但要继续往上走就会撞到它的天花板。原因在于全连接网络把每个像素都当成平等的输入没有充分利用图像局部相关的结构。比如手写数字7的横线和竖线它们彼此靠得近的像素之间是有强相关性的全连接网络完全捕捉不到这种空间上的邻近关系。这就是卷积神经网络CNN出场的时机。CNN 的核心操作是卷积核在图片上滑动每一次只观察局部区域然后通过多层堆叠把局部特征逐步组合成全局特征。热搜词里卷积神经网络结构图、cnn 卷积神经网络的高频出现也说明这是每个人都会遇到的下一步。6.2 从图像走向序列RNN 与 Transformer 的视角切换解决了图像任务之后很多人会转向文本、语音、时间序列等序列型数据。这时候全连接网络同样不适用因为序列数据的核心特征是顺序和上下文依赖。RNN 的设计思想是按时间步逐步处理输入把前面的信息通过隐含状态传递到后面LSTM 则是 RNN 的升级版增加门控机制解决长距离依赖时的梯度消失问题。近两年更受关注的是 Transformer 架构它完全抛弃了循环结构改用注意力机制直接计算序列中任意两个位置之间的关系。热搜词里pytorch 转 onnx、td3 代码 pytorch等技术的出现说明很多人在沿着基础网络 → 应用模型 → 推理部署的路径前进。技术栈会更新但自动求导、损失函数、优化器、训练循环这些核心概念始终不变。6.3 一个可以立即实践的方向CIFAR-10 实战如果你不知道该选哪个项目作为下一个目标我建议用 CIFAR-10 练手32×32 的彩色图片共 10 个类别比 MNIST 更接近真实世界的识别任务。你需要把网络从三层全连接更换成简单的卷积层加池化层再把数据加载部分从灰度图改成三通道彩色图。把第一个神经网络的经验迁移过去你会自然地体会到模型结构如何适配数据特征这句话的含金量。同样的训练循环、同样的评估逻辑、同样的优化器只需要替换网络定义和数据处理部分就能完成从图像理解入门到深度模型实战的跨越。7. 写在最后我第一次跑通 PyTorch 网络的真实感受最后聊点个人体会。我记得自己第一次手写神经网络代码、看着终端里 loss 从 2.3 一路下降到接近 0 的时候那种一个机械的过程居然能从数据里学到规律的震撼感至今难忘。但我也记得为了处理维度不匹配的错误我在view函数上反复打印形状的枯燥时光。这些细节看起来琐碎却恰恰是构建工程直觉的必经之路。对于刚起步的朋友我的建议是不要贪多先把 MNIST 这个例子从头到尾手敲一遍确保每一行代码你都能解释为什么这么写再往后走。遇到报错不要急着复制粘贴到搜索引擎先自己读一读 traceback定位到是哪一行抛的异常再思考这个异常在告诉你什么。错一两次胜过全对十次。最初optimizer.zero_grad()那行代码会是你日后职业生涯里敲下无数遍的肌肉记忆而每一次敲下它的瞬间都值得你回想起第一次搞清楚为什么梯度必须先清零时的恍然大悟。技术会过时框架会迭代但这种一步步追问到底的习惯会永远跟着你。
返回列表