很多刚接触深度学习的朋友,打开 PyTorch 官网之后最容易陷入的状态就是:教程看了一大堆,自己却不知道第一行代码该写什么。如果你也是这种状态,那这篇就是给你准备的。前馈神经网络(Feedforward Neural Network)是所有神经网络模型里最直观的一种:数据从输入层进来,经过若干隐藏层,最后从输出层出去,中间没有任何回环。PyTorch 对它的支持非常完善,几乎把每个环节都封装成了“搭积木”的模块,你不需要自己写求导,也能把模型训练起来。这篇我会从原理、环境、代码到报错排查,用我平时带新人的节奏,把“用 PyTorch 实现前馈神经网络”这件事完完整整走一遍。它适合零基础同学,也适合那些已经理解概念、但还没亲手跑通代码的人。
1. 先把前馈神经网络拆开看清楚
1.1 一个神经元到底在算什么
神经网络的基本单元是神经元。放在前馈网络里,一个神经元做的事情其实只有两步:先把所有输入做一次加权求和,再加上一个偏置,然后通过激活函数决定要不要“激活”。
写成公式就是:
z = w1 * x1 + w2 * x2 + ... + b a = activation(z)其中的w是权重,b是偏置。前者决定每个输入的重要性,后者相当于给神经元一个基础灵敏度。你可以把它想象成一条流水线上的工位:先把一堆原料按不同比例混合,再判断混合结果有没有达到某个阈值,达标就往下传,不达标就压制住。
PyTorch 里的nn.Linear(in_features, out_features)干的就是第一件事。比如nn.Linear(784, 128),就是接受一个长度为 784 的向量,输出一个长度为 128 的向量,内部自动维护一个[128, 784]的权重矩阵和一个长度为 128 的偏置向量。新手不需要手写矩阵乘法,但要清楚这个形状关系,否则后面排错会很痛苦。
1.2 隐藏层为什么这么重要
前馈网络最简单的形态是三段式:输入层、隐藏层、输出层。输入层不做计算,它只是把原始数据“喂”进来。真正干活的是隐藏层和输出层。
隐藏层的作用可以理解成特征提取。拿手写数字识别来说,原始输入是 784 个像素值,如果直接用这 784 个值预测“是哪个数字”,效果非常有限。隐藏层会先去组合这些像素,先找边缘、再找笔画、最后找结构。层数越多,理论上能提取的特征越抽象,但训练难度也会变大。菜鸟入门不需要一上来就堆几十层,先用一到两个隐藏层把流程跑通,比什么都重要。
输出层负责把隐藏层的结果映射到最终任务。比如分类 10 个数字,输出层就是 10 个神经元,每个神经元对应一个类别的“得分”。注意“得分”不是最终概率,它需要配合损失函数一起使用。这里先记着,后面代码里会讲清楚。
1.3 前向传播和反向传播的分工
前馈神经网络这个名字里的“前馈”,说的是数据从输入到输出的单向流动。我们把一次完整的数据流动过程叫前向传播。前向传播结束后会计算一个损失值,表示当前预测和真实标签的差距。
差距算出来之后,模型要更新参数以减少差距,这一步靠反向传播。反向传播从损失值出发,从输出层往回走,用链式法则算出每个权重对损失的影响程度,也就是梯度。PyTorch 的.backward()做的就是这件事。
所以一个训练回合可以浓缩成四步:
- 前向传播得到预测结果。
- 计算损失。
- 反向传播计算梯度。
- 用优化器更新参数。
这个流程会反复执行,直到损失不再明显下降。菜鸟阶段只要能背下这套流程,代码就能看懂大半。
2. 动手前先把环境和数据准备好
2.1 PyTorch 版本与安装选择
新手不需要纠结装 CPU 版还是 GPU 版。如果你只是跑 MNIST 这类小数据集,CPU 版本完全够用。我早期带新人时遇到过不少同学在驱动和 CUDA 版本上折腾了一两天,最后发现数据量太小,CPU 训练也就几十秒。
安装方式直接走官方 pip 命令。CPU 版最简单的方式是:
pip install torch torchvision torchaudio装好以后,可以用下面这行代码确认基本环境:
import torch print(torch.__version__) print(torch.cuda.is_available())如果你是 Windows 且之前没安装过 Python,建议先在官网装一个 Python 3.9 或 3.10 版本,然后打开命令行执行上面的命令。遇到网络慢就把下载源切到国内镜像,但不要混用多个镜像源,否则依赖关系容易乱。
对菜鸟来说,环境搭建的判断标准只有一个:能正常import torch就算过关。至于 GPU 加速,等真正需要时再配置,完全来得及。
2.2 数据集加载与归一化
前馈网络输入的通常是一维向量。如果是图片,需要把它拉平。手写数字数据集里每张图是 28×28 的灰度图,拉平后就是 784 个像素值。
以某个公开的手写数字数据集为例,加载和预处理代码如下:
import torch from torch.utils.data import DataLoader, TensorDataset # 用随机数据模拟一个分类任务:1000个样本,每个样本784维,共10类 features = torch.randn(1000, 784) labels = torch.randint(0, 10, (1000,)) dataset = TensorDataset(features, labels) train_loader = DataLoader(dataset, batch_size=64, shuffle=True) for x, y in train_loader: print(x.shape, y.shape) break这里batch_size=64表示每次取 64 个样本。shuffle=True表示每轮训练都打乱数据顺序,避免模型记住固定顺序,影响泛化效果。
真正处理图片时,建议把所有像素归一化到[0,1]或更小的区间。很多新手忽略这一步,直接用 0 到 255 的像素值去训练,结果损失特别大,训练半天也不收敛。原因很简单:输入值太大,权重更新时梯度会很剧烈,模型很难找到合适的参数空间。你可以把归一化理解成统一单位,让不同尺度的特征在同一个范围内参与计算。
2.3 训练循环的四件套模板
不管模型多复杂,训练循环的骨架基本不变。我建议新手把下面这段当作“前馈网络训练模板”背下来:
import torch.nn as nn import torch.optim as optim import torch.nn.functional as F model = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10), ) loss_fn = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(5): total_loss = 0.0 for x, y in train_loader: optimizer.zero_grad() out = model(x) loss = loss_fn(out, y) loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1}, avg loss: {total_loss / len(train_loader):.4f}")这段代码的核心是optimizer.zero_grad()。它负责把上一次计算留下的梯度清零。如果忘了这一步,梯度会在每次迭代中累加,更新方向就会错乱。很多新手报错或者损失曲线异常,原因不是模型有问题,而是少了这一行。
从训练循环你可以看出,PyTorch 已经把反向传播的几个核心步骤都封装好了。你要做的不是自己写导数,而是记得在合适的时机调用backward和step。
3. 用三层前馈网络完成一个手写数字分类任务
3.1 搭建网络的三种常用写法
PyTorch 里搭建网络有很多方式。菜鸟阶段我最推荐nn.Sequential,因为代码最短、最容易读。把网络定义成类则是更正式的写法,方便在forward里做一些特殊处理。
先看最直观的写法:
import torch.nn as nn class ForwardNet(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Flatten(), nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10), ) def forward(self, x): return self.net(x)nn.Flatten()会把形状为[batch_size, 1, 28, 28]的图片变成[batch_size, 784]。如果你的输入本来就是二维矩阵,已经是一维向量,不写 Flatten 也没问题。
还可以手动写出每一层:
class ForwardNet2(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 128) self.fc2 = nn.Linear(128, 64) self.fc3 = nn.Linear(64, 10) self.relu = nn.ReLU() def forward(self, x): x = x.view(x.size(0), -1) x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) return self.fc3(x)我建议两种写法都试一遍。Sequential适合快速验证,手动写法更适合你理解每一层的数据流向。等以后要用 ResNet 那种跳跃连接时,你就必须用第二种写法了。
3.2 损失函数和优化器怎么选
分类任务默认用nn.CrossEntropyLoss()。这个函数内部做了两件事:先对模型输出做softmax,再计算交叉熵损失。所以你的网络最后一层不需要手动加softmax,直接输出 10 个原始得分就行。这一点经常让新手困惑:为什么预测的时候要加softmax,训练的时候却不用?因为CrossEntropyLoss已经在内部处理了。
优化器方面,菜鸟阶段直接用Adam就好。它有自适应的学习率调整机制,比纯SGD更省心。初始学习率我一般设成0.001,这个值对大多数前馈网络够用。如果损失下降太慢,可以稍微调大;如果损失出现剧烈抖动,就先调小。
如果是回归任务,输出层神经元个数改成 1,损失函数换成nn.MSELoss()。不要在一个分类任务里用均方误差,虽然也能跑,但收敛速度和最终效果通常都不如交叉熵。
3.3 完整训练与验证代码
把数据处理、网络定义和训练循环串成一个完整脚本,大概是这个样子:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 固定随机种子,方便复现 torch.manual_seed(42) # 模拟数据 features = torch.randn(2000, 784) labels = torch.randint(0, 10, (2000,)) dataset = TensorDataset(features, labels) train_size = int(0.8 * len(dataset)) val_size = len(dataset) - train_size train_ds, val_ds = torch.utils.data.random_split(dataset, [train_size, val_size]) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) class ForwardNet(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10), ) def forward(self, x): return self.net(x) model = ForwardNet() loss_fn = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) def evaluate(model, loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for x, y in loader: out = model(x) pred = out.argmax(dim=1) correct += (pred == y).sum().item() total += y.size(0) return correct / total for epoch in range(10): model.train() total_loss = 0.0 for x, y in train_loader: optimizer.zero_grad() out = model(x) loss = loss_fn(out, y) loss.backward() optimizer.step() total_loss += loss.item() train_acc = evaluate(model, train_loader) val_acc = evaluate(model, val_loader) print(f"epoch {epoch+1}, loss {total_loss / len(train_loader):.4f}, " f"train acc {train_acc:.4f}, val acc {val_acc:.4f}")注意两点:验证阶段要写model.eval()并包裹torch.no_grad()。前者让 Dropout 和 BatchNorm 切换到推理模式,后者告诉 PyTorch 不需要计算梯度,节省内存和计算时间。如果不加这两行,程序也能跑,但结果和速度都不理想,而且容易积累奇怪的 bug。
3.4 参数量怎么算出来的
很多新人看到一堆nn.Linear不知道这模型到底有多大。参数量其实就是所有权重和偏置的总数。以上面的三层网络为例:
第一层: 784 * 128 + 128 = 100480 第二层: 128 * 64 + 64 = 8256 第三层: 64 * 10 + 10 = 650 总参数量: 109386约 10 万个参数,非常轻量。这个数字可以帮助你判断模型的复杂度和训练压力。如果模型参数量非常大,但数据集只有几百个样本,大概率会过拟合,也就是说训练集上表现很好,验证集上一塌糊涂。
4. 训练中常见问题与排查技巧实录
4.1 损失不下降或者直接卡住
这是新手遇到最多的问题。先用表格给你一个快速判断方向:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 损失一直很大,几乎不降 | 学习率太高或太低 | 把学习率调到 0.001 再试 |
| 损失很小,但准确率不高 | 分类任务用了回归损失 | 换成 CrossEntropyLoss |
| 训练开始时损失比随机猜测还差 | 数据没有归一化 | 检查输入范围,归一化到 [0,1] |
| 每轮损失反复横跳 | 学习率太大 | 降低学习率,比如 0.0001 |
| 损失为 nan | 学习率过大或数据有异常值 | 调小学习率,检查数据是否包含 inf |
如果损失长时间不降,我建议先做两个检查:第一,打印一次模型输出的形状,确认是不是[batch_size, num_classes];第二,打印损失值,确认它是不是一个有限的正数。很多时候问题出在数据格式,而不是模型结构。
还有一种很隐蔽的情况:标签从 1 开始而不是从 0 开始。比如手写数字识别里,标签如果误写成了 1 到 10,损失也可以算,但模型永远学不对。遇到准确率莫名其妙地低一个量级,优先检查标签范围。
4.2 过拟合和欠拟合怎么看
欠拟合的表现是训练集和验证集的准确率都不高。这种情况说明模型表达能力不够,或者训练不充分。处理思路包括:增加隐藏层神经元数量、增加层数、增大训练轮数。
过拟合的表现是训练集准确率很高,但验证集准确率明显偏低。菜鸟阶段最简单的缓解方法有两个:一是增加数据量或数据增强;二是在网络中加入Dropout。
用 PyTorch 加 Dropout 很简单:
self.net = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 10), )Dropout(0.3)的意思是每次训练时随机让 30% 的神经元不参与计算。这能强迫模型不依赖某几个神经元,从而提高泛化能力。注意 Dropout 只在训练时生效,model.eval()时会自动关闭,所以验证阶段不需要手动处理。
4.3 设备不一致和显存问题
前馈网络很少会爆显存,但如果你的数据量很大,或者不小心把整个数据集都放进了 GPU,还是可能遇到内存不足。解决思路是把数据拆成 mini-batch,而不是一次性把所有数据喂进去。
设备不一致是另一类典型报错。比如模型在 GPU 上,数据还在 CPU 上,运行时会报 unsupported device 之类的错误。最简单的统一处理方式是在训练前定义:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device)然后把每个 batch 的数据都搬到同一设备上:
for x, y in train_loader: x = x.to(device) y = y.to(device)只要你始终记得“模型和数据必须在同一设备”,这一类报错基本能避开。
4.4 刚写完代码最容易犯的几个低级错误
- 忘了
optimizer.zero_grad():梯度累积会导致参数更新方向异常。 CrossEntropyLoss传入的标签是 one-hot 编码:这个函数接收的是类别索引,不是 one-hot 向量。- 输入没有拉平:
nn.Linear期望二维输入,如果你的图片是四维张量,需要先view或Flatten。 - 验证时没有加
torch.no_grad():结果没错,但会占用不必要的显存。
这些错误我都见过不止一次,而且越早踩越好。踩过一次之后,后面写更复杂的模型就有了肌肉记忆。
5. 从复现到改造成自己的模型
5.1 换数据集时要注意什么
很多人练手之后想把代码用到自己的数据上。这一步的核心问题不是网络结构,而是输入输出格式。
自己的表格数据通常长这样:每一行是一个样本,每一列是一个特征。读取之后要转成浮点张量,标签单独拿出来。如果特征是数值型但尺度差异很大,一定先做标准化。如果特征里有文字,需要先编码成数值,比如用 One-Hot Encoding,而不是直接把字符串塞进张量。
如果换成彩色图片,输入通道会变成 3,前馈网络不适合直接处理这种高维结构,因为图片拉平后会丢失空间结构。这时候更合理的选择是卷积网络。但如果你想先用前馈网络跑一个基线,可以把图片缩得很小,比如 32×32,拉平后是 3072 维,照样能训练,只是效果不会太好。
5.2 菜鸟期建议固化的四个习惯
第一,固定随机种子。深度学习里有很多随机因素,不固定种子的话,每次训练结果都不一样,很难判断改动是变好还是变坏。PyTorch 里常用做法是:
torch.manual_seed(42)第二,每次只改一个变量。想调学习率就只调学习率,想加 Dropout 就只加 Dropout,不要同时改多个地方,否则出了问题根本定位不到原因。
第三,先跑过再优化,不要一开始就追求最佳效果。很多人第一版代码就想起用最先进的技巧,结果代码永远调不通。先把最简单的模型训练起来,哪怕准确率只有 80%,也说明流程是通的,后面再迭代就快很多。
第四,学会用打印而不是瞎猜。每 10 个 batch 打印一次损失,每个 epoch 结束打印一次验证准确率。这些信息能帮你快速判断训练状态。我见过太多新人只看最终结果,中间过程完全黑盒,出了问题就只能从头再来。
前馈神经网络就是深度学习里的“第一口饭”。它的代码量不大,但把数据加载、前向传播、损失计算、反向传播、优化器更新这一整套流程全部走了一遍。后续学卷积网络、循环网络甚至 Transformer,你会发现它们仍然保留着这套基本骨架。先用这篇文章里的模板跑通一个真实任务,再回去看那些复杂模型,很多东西都会变得顺理成章。