☰
PyTorch入门实战:手写前馈神经网络训练FashionMNIST
2026/10/14 11:31:00 网站建设 项目流程

1. 为什么我用PyTorch带新手写第一个神经网络

先交代一下背景。最近一个朋友问我,说想入门深度学习,但看了一堆教程,要么直接上卷积神经网络、Transformer这种大块头,要么就是公式推导铺满屏幕,看半小时就劝退。我跟他说,别整那些花活,就用PyTorch写一个前馈神经网络,跑一个真实的小数据集,把"数据进、网络算、损失出、梯度回、参数更新"这条链路走通,比什么都有用。

之所以选PyTorch而不是其他框架,几个理由很实在:第一,它的调试体验对新手极度友好,因为图是动态构建的,你可以随时打印中间张量的形状和数值,出错能立刻定位,不用对着静态图脑补执行流程;第二,写网络的方式非常接近 Python 直觉——你定义一个继承自nn.Module的类,在__init__里堆层,在forward里写数据怎么流过去,这跟写普通 Python 类的思路几乎没有差别;第三,生态里从数据加载到预训练模型都是现成的,以后做任何方向都能平滑过渡。

这篇文章的目标读者很明确:会写基础 Python、能装第三方库、但完全没碰过 PyTorch 的人。我会用一个二分类问题把完整流程走一遍,从环境准备到训练完成,每一步都解释为什么这样做。你可以直接照着敲代码,也可以先读思路再自己复现。文章里所有代码都在 CPU 环境下跑通过,没有 GPU 一样能做实验,这点对新手的意义很大——很多人一听到"深度学习"就以为必须要有昂贵显卡,其实前馈网络这种规模的模型,CPU 完全够用,先把逻辑学会比什么都重要。

2. 前馈神经网络到底在算什么:先建立心智模型

2.1 从单个神经元到多个层

如果纯粹照着代码敲,敲完你可能还是不知道网络内部发生了什么。所以我先花点篇幅讲清楚前馈神经网络在数学上到底做了什么。

最基本的单元是神经元,做的事情可以理解为两步:先把输入做加权求和,再经过一个非线性函数。写成公式就是y = activation(Wx + b)。一个神经元只有一组权重W和偏置b,表达能力非常有限,只能画一条直线分类。但把很多个神经元并列排成一层,再把好几层串联起来,事情就不一样了——每一层都在把输入数据做一次"变换",网络越深,能拟合的函数越复杂。

这里有一个关键点经常被新手忽略:层与层之间如果不加非线性激活函数,那么堆多少层都等价于一层。因为线性变换的复合仍然是线性变换,你的网络再深也只是在学一条直线。这就是为什么每个隐藏层后面都要接ReLU这类激活函数——它们负责给网络引入非线性能力,让网络真正能逼近复杂的决策边界。

2.2 为什么叫"前馈"

"前馈"这个词的意思很直白:数据从输入层进入,经过每一层向输出方向单向流动,没有循环也没有反馈。对比一下循环神经网络,它的隐藏层之间有跨时间的连接,信息会反复回传;而前馈网络的数据流向是纯粹的从前往后,所以计算顺序非常简单清晰。这也是新手入门的最佳起点,理解了一层层向前传播的过程,后面再学卷积网络的平移不变性、循环网络的时序依赖,就有对照的基础了。

2.3 训练的本质:找到一组让损失最小的参数

前馈网络的"学习"过程可以概括为四步循环:

  1. 前向传播:把一批数据送进网络,得到预测输出
  2. 计算损失:把预测值和真实标签做对比,得到一个衡量差距的数值
  3. 反向传播:从损失出发,用链式法则计算出每个参数对损失的贡献(梯度)
  4. 参数更新:沿梯度反方向调整权重,让损失变小一点

反复执行这个循环,损失曲线逐渐下降,网络的预测能力就越来越强。用生活化的比方来说,就像你在一个完全黑暗的山谷里想走到最低点,看不清全貌,只能靠"脚底哪边更往下"的感觉迈步——这个感觉就是梯度,每迈一步的幅度就是学习率。

理解了这层逻辑,再看PyTorch代码就会非常清晰:loss.backward()就是算梯度,optimizer.step()就是迈一步,optimizer.zero_grad()是迈完一步把脚底的旧感觉清掉,避免下一次判断被残留干扰。

3. 环境准备与数据加载:别在这一步卡太久

3.1 安装与验证

环境准备没什么黑科技,我直接给出我习惯的安装方式。如果你用的是 Anaconda,建议先建一个干净的虚拟环境,避免不同项目的依赖互相打架:

conda create -n ffn_demo python=3.10 conda activate ffn_demo pip install torch torchvision matplotlib

装完第一件事不是直接开写,而是验证安装是否可用:

import torch print(torch.__version__) print(torch.zeros(2, 3))

能正常输出张量就说明基础环境没问题。这里提醒一句:如果你手头有 N 卡且装了 CUDA,pip install torch通常默认带上对应版本的 CUDA 支持;没有 GPU 也完全没关系,代码里不要写.cuda()之类的设备调用,让张量默认在 CPU 上计算即可,省去一堆设备判断的麻烦。

3.2 选数据集:正规军而不是手造数据

很多人写入门 Demo 喜欢自己用random生成几组数据来验证网络,我的建议是不要。用真实数据集的好处在于:第一,数据分布是自然的,不是你能"猜出来"的;第二,最后跑出来的准确率有公开参考值,你能判断自己实现得到不到位;第三,真实数据集往往需要做预处理,这个过程本身就是深度学习流程的重要一环。

torchvision自带了不少经典数据集,新手首选MNIST(手写数字识别)或者FashionMNIST。我这次选FashionMNIST,原因是它比 MNIST 更有区分难度,十类衣物图片包含纹理、轮廓、颜色等多维特征,训练出来不会像手写数字那么容易"满分",对理解训练过程的收敛曲线更有帮助。

from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data = datasets.FashionMNIST( root="./data", train=True, download=True, transform=transform ) test_data = datasets.FashionMNIST( root="./data", train=False, download=True, transform=transform )

3.3 transform 里的两个关键操作

刚接触transforms.Compose的人容易把它当成"魔法盒子",其实里面每个变换都值得弄懂。

ToTensor()把 PIL 图片转成 PyTorch 张量,同时把像素值从 0 到 255 缩放到 0 到 1。这一步表面看只是类型转换,实际上是让数据范围适配网络初始化——如果像素值保持 0 到 255,初始权重下加权求和很容易溢出到很大的数,经过激活函数后梯度就可能变得极小。

Normalize((0.5,), (0.5,))做的操作是(x - 0.5) / 0.5,把数据从 [0, 1] 映射到 [-1, 1]。靠近 0 的数据能让梯度在反向传播时更稳定,这是实践中被反复验证的经验。对于灰度图只有一个通道,所以两个参数都只有一个值;如果是 RGB 三通道图片,就需要传三个值。这个细节很多教程一笔带过,但理解之后你会发现,任何数据集拿来做训练,第一步考虑的都应该是"数据范围对网络是否友好"。

3.4 DataLoader 的意义

DataLoader的作用是给训练循环提供数据。它做了三件重要的事:分批(batch_size)、打乱(shuffle)、并行加载(num_workers)。

from torch.utils.data import DataLoader train_loader = DataLoader(train_data, batch_size=64, shuffle=True) test_loader = DataLoader(test_data, batch_size=64, shuffle=False)

为什么要分批而不是一次把所有数据都喂进去?以 FashionMNIST 为例,6 万张 28x28 的灰度图,如果一次性全部转成张量计算,内存和单次梯度计算的压力都很大。分批之后,每次只对 64 张图片计算梯度并更新参数,这叫作小批量梯度下降,它在效率和稳定性之间取了平衡。shuffle=True在训练时很重要——如果不打乱,网络会持续按固定顺序看到样本,模型可能记住数据的顺序特征而不是真实的分类特征。

4. 手写网络:从继承 nn.Module 到 forward

4.1 网络结构设计

前馈神经网络处理 FashionMNIST 的思路是这样的:每张图片是 28×28 像素的灰度图,把它拉平成一个长度为 784 的向量作为输入;中间经过两个隐藏层,每层若干神经元,用ReLU激活;最后一层是 10 个输出神经元,对应 10 个类别。

隐藏层到底设多少、每层多少个神经元,这个问题新手总想找标准答案。说实话没有标准答案,但有合理经验区间:输入是 784 维,第一层 128 或 256 都是常见选择;如果第一层直接压缩到 10,信息损失太剧烈,拟合能力不够。我用了 256 → 128 → 10 的结构,参数总量不多,训练速度快,效果也足够说明问题。

4.2 定义模型类

import torch.nn as nn import torch.nn.functional as F class FeedforwardNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 256) self.fc2 = nn.Linear(256, 128) self.fc3 = nn.Linear(128, 10) def forward(self, x): x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = self.fc3(x) return x

这个类看起来简单,但每一行都有讲究。nn.Linear(784, 256)做的事情就是我在第 2 节描述的加权求和加偏置——PyTorch 替我们管理了权重张量和初始化,你不需要手动建矩阵。forward函数定义了数据流向:x.view(x.size(0), -1)把形状为(batch_size, 28, 28)的张量拉平成(batch_size, 784),因为Linear层只接受二维输入。

有一个细节值得注意:最后一层我没有接激活函数。这是因为对于多分类问题,接下来要用交叉熵损失函数,PyTorch 的CrossEntropyLoss内部已经包含了softmax操作。如果在输出层手动加了ReLU或softmax,要么破坏训练稳定性,要么造成重复计算、数值上可能出问题。记住这个原则:输出层到底该不该加激活,取决于你选的损失函数,不要想当然。

4.3 为什么必须继承 nn.Module

新手可能觉得nn.Module是个可有可无的装饰,实际上它承担了大量幕后工作。当你的模型有多个Linear层时,nn.Module会自动注册这些子模块,让 PyTorch 知道需要为哪些参数计算梯度。在训练循环里调用model.parameters(),拿到的就是所有Linear层的权重和偏置。如果模型只是一个普通 Python 类,optimizer压根找不到要更新哪些参数。

forward方法还有一个隐含约定:调用model(x)时,PyTorch 内部会先去执行__call__中定义的前向钩子逻辑,再调用你写的forward。所以日常使用中应该写model(x)而不是model.forward(x),前者会正确注册计算图,后者在某些场景下会导致梯度计算链路残缺。这个坑比较隐蔽,新手阶段养成"用model(x)调用"的习惯,可以避免后面深入时出怪问题。

4.4 验证一下前向传播

模型定义完,不要急着训练,先拿一个批次的数据看看输出形状对不对:

model = FeedforwardNet() data, labels = next(iter(train_loader)) print(data.shape) # torch.Size([64, 1, 28, 28]) output = model(data) print(output.shape) # torch.Size([64, 10])

看到输出是(64, 10),说明网络结构基本正确。很多人跳过了这一步直接开训,等到backward报维度错误才回来排查,反而浪费更多时间。先验证前向传播是成本最低的调试手段,这个习惯值得从一开始就养成。

5. 训练循环与测试评估:核心代码抄作业指南

5.1 一个标准的训练步骤

训练循环的代码在 PyTorch 项目里高度相似,但每一步都必须理解,否则改一个地方就可能导致整个训练失效。下面是完整实现:

import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) def train_one_epoch(model, train_loader, criterion, optimizer): model.train() total_loss = 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() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy

代码里最容易被忽略也最关键的是optimizer.zero_grad()的位置。PyTorch 的梯度是累加的,也就是说如果上一个批次计算出的梯度不清零,这个批次的梯度会和旧梯度叠加在一起,参数更新的方向就被污染了。zero_grad()必须放在每次参数更新前,而不是训练开始前——放在训练开始前等于只清了第一个批次之前的状态,后面每个批次都会累加。

损失值的统计也有讲究。我用了loss.item() * images.size(0),先乘以批次大小,最后除以总样本数,得到的是整个 epoch 的平均损失。如果直接累加loss.item(),由于最后一个批次可能不足 64 条,平均损失会被轻微拉偏。虽然这个例子中影响不大,但养成正确的统计习惯以后能少踩很多坑。

5.2 优化器选择与学习率

我用了Adam而不是最基础的随机梯度下降,原因是 Adam 对学习率的敏感度低,它会根据梯度的一阶矩和二阶矩自适应调整每个参数的更新步长。对新手来说,用 Adam 配合lr=0.001是最省心的组合——这个配置在大量入门项目中表现稳定,基本不需要反复调参。

如果想真正理解优化算法的差异,可以在同一套数据上对比一下:把优化器换成optim.SGD(model.parameters(), lr=0.01),你会发现收敛明显变慢,而且对学习率更敏感。这种对比实验花不了几分钟,但对建立"优化器选择影响训练效果"的直觉很有帮助。

5.3 完整训练闭环

接下来的主循环会跑多个 epoch,每个 epoch 结束后在测试集上评估一次:

def evaluate(model, test_loader, criterion): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total epochs = 10 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer) test_loss, test_acc = evaluate(model, test_loader, criterion) print(f"Epoch {epoch+1:2d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}")

注意evaluate函数里有两处关键差异:一是调用model.eval(),二是使用with torch.no_grad():。model.eval()会把模型切换为评估模式,这在本例中没有立刻发挥作用(它主要影响Dropout和BatchNorm的行为),但这是必须养成的习惯——以后你的模型里一旦加入这些层,忘记eval()会导致测试结果异常。torch.no_grad()则是明确告诉 PyTorch 不需要构建计算图,评估阶段只需要前向结果,不需要反向传播。省掉计算图的构建能显著降低内存占用和计算时间,更重要的是,如果没有这个上下文,测试阶段会因为中间变量被保存而产生不必要的累积。

跑完 10 个 epoch,你大概率会看到测试准确率落在 87% 到 90% 之间。这个数字本身不是重点,重点是观察训练损失的下降曲线:前两三个 epoch 下降明显,之后逐渐变缓,这说明模型在正常收敛。如果你的曲线出现剧烈震荡或者干脆不降,那就进入下一节要讲的问题排查环节了。

6. 新手最容易踩的坑:附完整排查思路

6.1 维度对不上:八成在 view 或者输入形状

我见过太多新手报错,十有八九是维度问题。典型报错长这样:

RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x28x28 and 784x256)

这个报错的意思是,Linear层期望输入最后一维是 784,但实际收到的是三维张量(64, 28, 28)。根因就是忘了在forward里做view拉平操作。排查思路很简单:在异常发生处前一行打印x.shape,看看实际形状是否和网络第一层预期的输入维度匹配。

另一个容易犯的错误是在view里写死batch_size,比如x.view(64, -1)。一旦换测试集时最后一个批次数量不是 64,程序直接崩溃。正确做法是x.view(x.size(0), -1),动态获取批次维度,这种写法适配任何 batch size。

6.2 训练 Loss 变成 nan:学习率过大的典型症状

Loss 变成nan(非数字)是新手训练中最吓人的问题之一。最常见原因是学习率设置过大,导致参数更新幅度过大,权重数值爆炸,后续前向传播计算出的损失无限大。我在最初试验时把学习率设成 0.1,结果第三个 epoch Loss 直接变nan,后面再怎么训练都救不回来,只能重新初始化模型。

排查顺序建议这样来:先检查数据是否包含异常值(比如NaN或无穷大);然后下调学习率一两个数量级(0.001 改成 0.0001)重新训练;如果还不行,检查损失函数和标签类型是否匹配——比如标签是浮点张量但CrossEntropyLoss期望整型张量。对于本例的数据集和模型,正常配置下不会出现nan,出现了就优先怀疑超参数设置。

6.3 测试准确率高的假象:shuffle 忘关

test_loader的shuffle=False必须写。如果你在评估数据上也开启了shuffle=True,每次评估看到的样本顺序不同没关系,准确率本身影响不大,但真正的问题是另一个方向——如果你错误地在训练和测试数据混用,或者数据预处理不一致(比如训练时归一化了,测试时忘了),就会出现训练准确率很高但测试准确率异常低的情况。这种"假象"最迷惑人,因为代码不报错,看起来一切正常。

排查预处理不一致的方法是:分别打印训练集和测试集第一张图片的像素统计值,对比范围是否一致。如果训练数据是 [-1, 1] 而测试数据是 [0, 1],模型在测试时面对的输入分布和训练时完全不同,表现自然差得离谱。这个坑我见过太多人踩,特别是从网上复制代码改数据集的时候,很容易漏掉 transform 的一部分。

6.4 模型在训练集上就学不动:检查激活和初始化

如果训练损失几乎不下降,问题往往不在训练循环,而在网络设计。常见原因有三个:一是隐藏层忘记加激活函数,网络退化成线性模型,拟合能力有限;二是数据归一化没做,输入范围过大导致梯度异常;三是层数太深但没有任何正则或归一化手段,梯度在反向传播中逐层衰减。

这里给一个排查路径:直接用一个极小的数据集(比如 100 张图片)过拟合给网络看。如果网络连小数据集都拟合不了,问题在网络结构或数据预处理;如果小数据集能拟合到接近 100% 准确率,但大数据集效果差,那才需要怀疑泛化能力、正则化策略这类更深层的问题。这个"先小后大"的策略能帮你快速缩小问题范围,比盲目调参高效得多。

7. 跑通之后还能往哪里扩展

第一个前馈网络跑通只是起点,接下来我建议按下面的顺序逐步加东西,每一步都能巩固一部分关键知识:

  • 在隐藏层之间插入nn.Dropout(p=0.3),观察它对训练准确率和测试准确率的影响——你会看到训练准确率下降但测试准确率可能上升,这就是正则化在起作用
  • 把ReLU换成Sigmoid或Tanh,对比收敛速度和最终准确率,直观感受激活函数选择的意义
  • 把Adam换回SGD并手动设置动量,体验不同优化器的行为差异
  • 加入BatchNorm1d层,观察训练稳定性的变化

我自己在实际操作中的体会是,入门阶段最大的收获不是准确率数字,而是建立"数据 → 模型 → 损失 → 优化"这条链路的直觉。当你看到一个报错,能根据报错位置快速判断是数据问题、模型结构问题还是训练配置问题,这才是真正入门的标志。上面代码如果照抄跑通,你已经有能力在此基础上做上面这些扩展实验了。量变引发质变,多写几个不同类型的网络,很多概念自然就通了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询