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 训练的本质找到一组让损失最小的参数前馈网络的学习过程可以概括为四步循环前向传播把一批数据送进网络得到预测输出计算损失把预测值和真实标签做对比得到一个衡量差距的数值反向传播从损失出发用链式法则计算出每个参数对损失的贡献梯度参数更新沿梯度反方向调整权重让损失变小一点反复执行这个循环损失曲线逐渐下降网络的预测能力就越来越强。用生活化的比方来说就像你在一个完全黑暗的山谷里想走到最低点看不清全貌只能靠脚底哪边更往下的感觉迈步——这个感觉就是梯度每迈一步的幅度就是学习率。理解了这层逻辑再看PyTorch代码就会非常清晰loss.backward()就是算梯度optimizer.step()就是迈一步optimizer.zero_grad()是迈完一步把脚底的旧感觉清掉避免下一次判断被残留干扰。3. 环境准备与数据加载别在这一步卡太久3.1 安装与验证环境准备没什么黑科技我直接给出我习惯的安装方式。如果你用的是 Anaconda建议先建一个干净的虚拟环境避免不同项目的依赖互相打架conda create -n ffn_demo python3.10 conda activate ffn_demo pip install torch torchvision matplotlib装完第一件事不是直接开写而是验证安装是否可用import torch print(torch.__version__) print(torch.zeros(2, 3))能正常输出张量就说明基础环境没问题。这里提醒一句如果你手头有 N 卡且装了 CUDApip 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, trainTrue, downloadTrue, transformtransform ) test_data datasets.FashionMNIST( root./data, trainFalse, downloadTrue, transformtransform )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_size64, shuffleTrue) test_loader DataLoader(test_data, batch_size64, shuffleFalse)为什么要分批而不是一次把所有数据都喂进去以 FashionMNIST 为例6 万张 28x28 的灰度图如果一次性全部转成张量计算内存和单次梯度计算的压力都很大。分批之后每次只对 64 张图片计算梯度并更新参数这叫作小批量梯度下降它在效率和稳定性之间取了平衡。shuffleTrue在训练时很重要——如果不打乱网络会持续按固定顺序看到样本模型可能记住数据的顺序特征而不是真实的分类特征。4. 手写网络从继承 nn.Module 到 forward4.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(), lr0.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(dim1) 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 配合lr0.001是最省心的组合——这个配置在大量入门项目中表现稳定基本不需要反复调参。如果想真正理解优化算法的差异可以在同一套数据上对比一下把优化器换成optim.SGD(model.parameters(), lr0.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(dim1) 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(fEpoch {epoch1: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的shuffleFalse必须写。如果你在评估数据上也开启了shuffleTrue每次评估看到的样本顺序不同没关系准确率本身影响不大但真正的问题是另一个方向——如果你错误地在训练和测试数据混用或者数据预处理不一致比如训练时归一化了测试时忘了就会出现训练准确率很高但测试准确率异常低的情况。这种假象最迷惑人因为代码不报错看起来一切正常。排查预处理不一致的方法是分别打印训练集和测试集第一张图片的像素统计值对比范围是否一致。如果训练数据是 [-1, 1] 而测试数据是 [0, 1]模型在测试时面对的输入分布和训练时完全不同表现自然差得离谱。这个坑我见过太多人踩特别是从网上复制代码改数据集的时候很容易漏掉 transform 的一部分。6.4 模型在训练集上就学不动检查激活和初始化如果训练损失几乎不下降问题往往不在训练循环而在网络设计。常见原因有三个一是隐藏层忘记加激活函数网络退化成线性模型拟合能力有限二是数据归一化没做输入范围过大导致梯度异常三是层数太深但没有任何正则或归一化手段梯度在反向传播中逐层衰减。这里给一个排查路径直接用一个极小的数据集比如 100 张图片过拟合给网络看。如果网络连小数据集都拟合不了问题在网络结构或数据预处理如果小数据集能拟合到接近 100% 准确率但大数据集效果差那才需要怀疑泛化能力、正则化策略这类更深层的问题。这个先小后大的策略能帮你快速缩小问题范围比盲目调参高效得多。7. 跑通之后还能往哪里扩展第一个前馈网络跑通只是起点接下来我建议按下面的顺序逐步加东西每一步都能巩固一部分关键知识在隐藏层之间插入nn.Dropout(p0.3)观察它对训练准确率和测试准确率的影响——你会看到训练准确率下降但测试准确率可能上升这就是正则化在起作用把ReLU换成Sigmoid或Tanh对比收敛速度和最终准确率直观感受激活函数选择的意义把Adam换回SGD并手动设置动量体验不同优化器的行为差异加入BatchNorm1d层观察训练稳定性的变化我自己在实际操作中的体会是入门阶段最大的收获不是准确率数字而是建立数据 → 模型 → 损失 → 优化这条链路的直觉。当你看到一个报错能根据报错位置快速判断是数据问题、模型结构问题还是训练配置问题这才是真正入门的标志。上面代码如果照抄跑通你已经有能力在此基础上做上面这些扩展实验了。量变引发质变多写几个不同类型的网络很多概念自然就通了。