1. 建立神经网络的核心思路:先想清楚这是件什么事
聊到 PyTorch 系列教程的第五节,前面几篇我们处理完张量的各种操作、把自动求导机制也摸了一遍,终于到了正题:怎么把一个网络真正搭出来。说实话,很多初学者在这个节点上会卡住,因为前面学的都是零散的积木——张量怎么变形、梯度怎么回传、学习率是什么——到了建网络这一步,突然要把这些东西拧成一台能跑的机器,思路就容易乱。
我的建议是,先别急着敲代码,想清楚一个问题:神经网络到底是什么?
从 PyTorch 的角度看,一个神经网络就是一堆参数(权重和偏置)加上一套计算规则。参数决定网络的"记忆",计算规则决定输入的"加工方式"。训练网络,本质就是不断调整这堆参数,让加工出来的结果越来越接近我们想要的答案。听起来不难,但真要在代码里把这堆参数、计算、更新串起来,就需要几个关键的抽象工具。PyTorch 里最重要的一个抽象,就是nn.Module。
nn.Module是所有网络的基类。你自己定义的任何一个网络,都必须继承它。这个类替你干了三件脏活:自动收集你挂载在它下面的所有子模块、自动收集所有参数、提供统一的设备迁移接口。换句话说,只要你把网络定义成nn.Module的子类,参数管理这件事就从手工劳动变成了自动化流水线。你也可以不用它,像上古时代那样自己维护一个weights = torch.randn(...)的列表然后手动更新,但相信我,一旦网络超过三层,你会疯的。
那建立网络需要哪三件事?第一是结构设计:输入几维、中间过几层、每层用什么算子。第二是参数接管:所有层的参数要能被统一遍历到,交给优化器。第三是前向逻辑:数据从进入网络到离开网络,经历了哪些计算步骤。把这个三件事想透了,nn.Module的代码无非是把它们一一落地。
用个生活化的类比:搭神经网络就像配一台组装电脑。nn.Module是那块主板,各种层(线性层、卷积层、激活函数)是显卡、内存、CPU,forward方法就是主板上那些线路——决定电流从哪进、经过哪些元件、从哪出。optimizer则是电源管理程序,持续监测和调节各部件的状态。理解了这层关系,你就知道为什么 PyTorch 建网络的第一步永远是写一个继承nn.Module的类了。
这一节的内容适合谁?如果你已经知道张量是什么、requires_grad是干嘛的,但一打开 PyTorch 官方教程看到class NeuralNetwork(nn.Module)就发懵,那这篇文章就是给你准备的。我会把网络定义、前向传播、训练流程拆开讲清楚,最后附上完整的可运行代码和踩坑记录。读完你应该能独立写一个像模像样的网络,并且知道出问题时该往哪里查。
2. 用 nn.Module 搭建网络:继承与重写的正确姿势
2.1 标准模板与两个核心方法
所有nn.Module的自定义子类,骨架都是固定的:__init__里定义层,forward里定义计算。我先把最经典的多层感知机模板摆出来:
import torch import torch.nn as nn import torch.nn.functional as F class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = torch.flatten(x, 1) x = self.fc1(x) x = F.relu(x) x = self.fc2(x) return x这段代码只有十行,但里面有好几个细节值得展开说。先看__init__:第一行super().__init__()是必须的,这行代码让父类的初始化逻辑先跑一遍,把内部的参数注册表、模块注册表都准备好。漏掉这行,后面所有self.fc1挂载的层都不会被model.parameters()捕获到,训练时梯度为零,loss 纹丝不动。这个错误特别隐蔽,因为代码不报错,只是模型完全学不到东西。
再看我们定义了什么。nn.Linear(784, 128)意思是构造一个线性层,输入维度 784,输出维度 128。这个层的内部由两部分组成:权重矩阵weight(形状是 [128, 784])和偏置bias(形状是 [128])。你不必手动初始化它们,PyTorch 会用默认的 Kaiming 均匀分布帮你搞定。但如果对初始化有特殊要求,可以单独做,后面我细讲。
forward方法则是前向传播的核心逻辑。注意我用了torch.flatten(x, 1)把输入从多维压成二维——如果输入是一批 MNIST 图片,形状是 [batch_size, 1, 28, 28],那么 flatten 之后变成 [batch_size, 784],正好喂给第一个线性层。很多人在这一步踩坑:把 flatten 版本写成了x.view(x.size(0), -1),虽然功能一样,但flatten更直观。代码中展示的是一个四步的流水线:展平、全连接、激活、全连接。每步都是对输入的变换,最终输出 [batch_size, 10] 的分数向量。
2.2 Functional 与 Module 到底选哪个
新手最容易困惑的一个问题:F.relu和nn.ReLU有什么区别?两者都能做 ReLU 激活,但使用场景不同。nn.ReLU是一个nn.Module类,它有参数、有状态(虽然 ReLU 本身没有可学习参数),可以作为一个子模块挂载到网络里。F.relu则是一个无状态的函数调用,不属于模块体系。
那该用哪个?我的习惯是:没有参数的激活函数,直接用 Functional 版本写在forward里,比如F.relu、F.sigmoid、F.softmax。有参数或者有特殊状态的层,比如nn.BatchNorm2d(有 running_mean 和 running_var 状态)、nn.Dropout(有训练/评估模式的切换逻辑),必须用 Module 版本。因为nn.Dropout需要在model.eval()时自动关闭随机失活,这个行为必须通过模块状态来管理。如果你用F.dropout(x, p=0.5)这种函数式写法,它不会感知到当前是训练还是评估模式,很容易出现"测试时 dropout 没关掉"的诡异情况。
类似的还有nn.Conv2d。卷积有可学习参数(卷积核的权重和偏置),所以只能用 Module 版。但在forward里也可以配合F.relu使用:先过self.conv1(x),再F.relu(...)。这种组合方式很常见,也是官方许多示例网络的写法。
我个人的实践习惯是:init里只放有可学习参数或状态管理的模块,forward 里用函数式接口完成激活、池化、dropout 这些无参数操作。这样网络定义部分干净清晰,参数一目了然,行为可控。
2.3 多层结构设计的几个要点
回到网络结构本身。为什么需要中间层?一句话:线性模型的表达能力有限,只有一层线性变换的网络只能学到输入到输出的线性映射,而现实中大多数问题不是线性的。引入非线性激活函数和多个隐藏层,让网络具备拟合任意复杂函数的能力。
这里有个设计直觉:层数越多、每层越宽,网络容量越大,但也越容易过拟合。我的建议是,从简单结构起步,先用一个隐藏层(比如 128 神经元)跑通流程,再逐步加层加宽。很多人一上来搭个四层 1024 神经元的网络,训练半天 loss 不降还以为是代码错了,其实只是模型容量远大于数据复杂度,再加上没有正则化,梯度路径太长导致信号衰减。
激活函数的选择也有讲究。ReLU 几乎成了默认配置,原因是计算简单、梯度不易饱和。但 ReLU 有个"死亡神经元"问题:如果某次梯度更新把权重推入负值区间,该神经元后续输出恒为 0,永远无法再激活。遇到这种情况可以考虑 LeakyReLU 或 ELU。实操中,先用 ReLU 跑通,再根据 loss 曲线决定是否替换。
还有一个容易忽略的点:最后一个全连接层后面通常不跟激活函数。因为我们后面要接交叉熵损失,nn.CrossEntropyLoss内部包含 softmax 计算,前置激活会打乱概率分布,反而让训练不稳定。有些教程喜欢在最后一层加F.log_softmax,那就要配合nn.NLLLoss使用——虽然也合法,但不如直接用CrossEntropyLoss一条龙服务省事。
3. 深入网络内部:参数、数据流与设备管理
3.1 参数注册与 state_dict:看不见的自动化
nn.Module最核心的设计是内部维护了两个字典:_modules和_parameters。当你在__init__里写下self.fc1 = nn.Linear(...)的时候,这个赋值操作会触发nn.Module的__setattr__魔法方法,把fc1自动注册到_modules字典里,而不是普通实例属性。这就是为什么model.parameters()能遍历到所有子模块的参数——它不是靠反射去逐个找,而是靠这套注册机制。
nn.Linear内部持有的weight和bias也不是普通的torch.Tensor,而是nn.Parameter。nn.Parameter是Tensor的子类,唯一的区别是:只要一个nn.Module的属性是nn.Parameter类型,这个参数就会自动出现在该模块的_parameters字典里。于是当你调用model.parameters()时,PyTorch 递归遍历所有子模块的_parameters,把这些 Parameter 全部收集起来交给优化器。这个过程是自动完成的,但理解它能帮你定位很多问题:如果你把参数存成普通 Tensor 而不是nn.Parameter,优化器根本看不到它,训练自然不起作用。
参数管理最常用的入口是state_dict()。这个方法把整个网络的所有参数和缓冲区(比如 BatchNorm 的统计量)打包成一个有序字典,键是参数的路径名,比如fc1.weight、fc2.bias。保存模型就是torch.save(model.state_dict(), "model.pth"),加载则是model.load_state_dict(torch.load("model.pth"))。
这里我要强调一个实操准则:保存模型永远保存state_dict,而不是整个模型对象。理由有三个:一是state_dict只含参数,体积小;二是加载时对代码版本更宽容;三是只加载参数让网络结构定义和训练代码完全解耦,将来修改了网络定义里的某个层名,也更容易排查问题。踩过坑的人都知道,直接用torch.save(model, ...)存出的完整对象,换台机器、换个 PyTorch 版本就可能加载失败,而state_dict几乎从不出这种幺蛾子。
3.2 设备迁移与随机种子:两件"小事"引发的血案
训练前长可设备相关的心,确实容易出"看似正常却不学习"的怪病。
先说设备迁移。很多初学者写model = SimpleNet()后直接跑训练,发现 loss 下降极慢或者完全不动。查看卡住的位置通常会发现:模型在 CPU 上,数据在 GPU 上——PyTorch 虽然不会报错(因为 CPU 模型吃 CPU 张量没问题),但整个前向过程都跑在 CPU 上,GPU 完全闲置。这只是慢,不算 bug。更隐蔽的 bug 是模型和数据不在同一个设备上:CPU 模型吃进了 GPU 张量,PyTorch 会直接抛错。所以我的标准操作是一上来就写:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleNet().to(device)然后把每一个喂给模型的张量也做.to(device)。选.to(device)而不是.cuda(),是因为前者兼容 CPU/GPU 切换,后者在无 GPU 环境会直接报错。这里有个容易忽略的细节:model.to(device)是原地操作,不用写model = model.to(device);但张量的.to(device)会返回新张量,必须用赋值接住,比如data = data.to(device)。
再说随机种子。神经网络初始化依赖随机数,如果不设种子,每次实验的初始状态都不同,结果难以复现。我在实验前固定三处:
torch.manual_seed(42) torch.cuda.manual_seed_all(42)这两行能保证网络层初始化的随机权重是固定的。如果你的代码里其它地方还有用到random模块或numpy随机数,最好也加上random.seed(42)和np.random.seed(42)。训练不收敛的时候,固定随机种子能帮你排除初始状态的影响,专注于调超参数和查代码逻辑。
3.3 权重的初始化技巧:别让网络从"歪路"出发
PyTorch 给每个层配了默认初始化,本质上是遵循经验法则。nn.Linear默认的权重初始化范围是 U(-sqrt(k), sqrt(k)),k 是输入维度的倒数;卷积层默认是 Kaiming 均匀分布。这套默认方案在大多数中等深度的网络上表现良好,直接用就行。
但如果你发现网络非常深、梯度消失明显,或者想在特定场景下用自定义分布,可以手动覆盖。标准写法是在__init__里对每个层应用初始化函数。例如:
for m in self.modules(): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias)self.modules()会递归产出所有子模块,配合isinstance判断类型后针对性初始化。xavier_uniform_适合使用 sigmoid/tanh 激活的线性层,kaiming_normal_适合 ReLU 系列。之所以把初始化单独拎出来讲,是因为我见过很多次训练失败的案例,根因不是学习率太大也不是网络结构有问题,而是权重初始值选得不当——尤其是把所有权重初始化为 0 这种自杀式操作,会让所有神经元完全对称,梯度更新后各层依然一致,网络永远学不出不同的特征。
4. 实操过程:构建一个完整可训练的图像识别网络
4.1 数据准备:先别拿 ImageNet 开刀
很多人学到这里,喜欢直接拿复杂数据集练手,我的经验是先上小任务。这里用经典的 MNIST 手写数字识别做示例,网络用的 LeNet 风格的简化版。之所以选这个搭配,是因为 MNIST 数据简单、图像分辨率小(28x28)、类别只有 10 个,在一台普通笔记本的 CPU 上几分钟就能跑完一个 epoch,非常适合验证代码正确性。
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data = datasets.MNIST(root="./data", train=True, download=True, transform=transform) test_data = datasets.MNIST(root="./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=64, shuffle=True) test_loader = DataLoader(test_data, batch_size=128, shuffle=False)MNIST 的均值 0.1307 和标准差 0.3081 是数据集的固有统计量,是官方算好之后公布的。很多教程直接写死这两个数,但如果你换数据集就得自己算。用transforms.Normalize做标准化,能让输入特征分布接近 0 均值、1 方差,这会让梯度下降更容易收敛——数据尺度差异太大时,损失函数的地形像一条狭长峡谷,梯度方向震荡严重,收敛极慢。
DataLoader的作用是把数据集切成 batch。batch_size=64意味着每 64 张图作为一批送入网络。shuffle=True在训练集上打乱顺序,避免模型学习到样本间的排列规律。测试集不用 shuffle,保持顺序便于统计指标。
4.2 定义网络:LeNet 风格加上非线性
我们的网络结构这样设计:两个卷积层提取特征,两个全连接层做分类,中间穿插最大池化和 ReLU 激活。代码:
import torch.nn as nn import torch.nn.functional as F class LeNetLike(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2) self.conv2 = nn.Conv2d(6, 16, kernel_size=5, padding=2) self.fc1 = nn.Linear(16 * 7 * 7, 120) self.fc2 = nn.Linear(120, 10) def forward(self, x): x = F.max_pool2d(F.relu(self.conv1(x)), kernel_size=2) x = F.max_pool2d(F.relu(self.conv2(x)), kernel_size=2) x = torch.flatten(x, 1) x = F.relu(self.fc1(x)) x = self.fc2(x) return x现在把维度变化讲透。输入 MNIST 单通道 28x28 图像,batch 为 64,即x形状 [64, 1, 28, 28]。
第一个卷积层conv1:输入通道 1,输出通道 6,卷积核 5x5,padding 2。padding 的作用是保持空间尺寸不变。计算公式是 (28 + 2×2 - 5) / 1 + 1 = 28,所以输出形状是 [64, 6, 28, 28]。ReLU 激活不改变形状,但把负值置 0。
接着max_pool2d核大小 2,步长默认等于核大小,形状减半变成 [64, 6, 14, 14]。这里池化的作用是降采样,既减少计算量,又提供一定的平移不变性。
第二个卷积层conv2:输入通道 6,输出通道 16,形状从 [64, 6, 14, 14] 变为 [64, 16, 14, 14],再池化后变成 [64, 16, 7, 7]。到这一步,图像信息被压缩成了 16 个 7x7 的特征图。
torch.flatten(x, 1)把 [64, 16, 7, 7] 展平成 [64, 16×7×7] 即 [64, 784]。fc1把 784 维映射到 120 维,ReLU 后再经过fc2映射到 10 维。最后的 10 维向量对应 0~9 十个类别的得分。
在__init__里注意self.fc1 = nn.Linear(16 * 7 * 7, 120)中的 1677 是根据前面层的输出尺寸算出来的。不少人会在这一步算错,我通常先打印一下每一层的输出形状来验证:
x = torch.randn(1, 1, 28, 28) model = LeNetLike() print(model(x).shape) # torch.Size([1, 10])这种临时打印方法极其好用。写网络定义时养成的习惯:先造一个假输入跑一遍前向,确认输出形状符合预期,再进入训练循环。
4.3 训练循环:看明白每一步才能写好
训练循环是整个框架里出现 bug 概率最高的地方,问题集中在"顺序"上。我先给完整的训练代码,再逐行解释为什么顺序不能错:
import torch.optim as optim model = LeNetLike().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) for epoch in range(5): running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f"Epoch {epoch+1}: loss = {running_loss / len(train_loader):.4f}")每个 Batch 的四步是有顺序的:
第一,optimizer.zero_grad()。这一步清空上一轮累积的梯度。PyTorch 的梯度默认是累积的,如果你不手动清零,一轮更新后梯度不会消失,下一轮会把新旧梯度加在一起,导致更新方向混乱。初学者最容易忘的就是这行,症状是 loss 曲线上下震荡、不收敛。
第二,前向传播outputs = model(images)。数据进入已经定义好的网络,经过 forward 里的每个计算步骤,得到预测得分。由于训练模式下 dropout 保持开启(本例没有),每个 batch 的随机性不同,梯度也会带噪声。
第三,计算损失loss = criterion(outputs, labels)。nn.CrossEntropyLoss内部先把模型输出的 10 维得分向量软化成概率分布,再和真实的类别标签计算交叉熵。这个损失函数一个很大的优点是自带 softmax,所以网络最后一层的原始输出不需要额外过 softmax。我在 2.3 里特意提过,这里再强调一次。
第四,反向传播loss.backward()。这条语句会从 loss 出发,沿计算图反向走一遍,每经过一个节点就把梯度计算出来,存到各叶子张量的.grad属性里。务必注意:backward()只是算梯度,还没更新权重。很多人以为调了backward就把参数更新了,其实没有,权重更新必须要第五步。
第五,optimizer.step()。优化器拿上一步算好的.grad,按设定的规则(比如 SGD 的学习率 0.01 和动量 0.9)去更新model.parameters()里的每个参数。到这一步,一个完整的参数更新周期才走完。
这段流程看似简单,但新手最容易混淆的是什么时候该zero_grad、什么时候该step。记一个口决:每个 batch 的循环内,先清零,再前向,算 loss,反向,最后更新。顺序乱了,网络行为就会变得不可控。
4.4 验证与保存:看一眼效果再收工
训练完一个 epoch,要看模型泛化能力,不能只看训练集。我加一段简单的测试函数:
def evaluate(model, test_loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / totalmodel.eval()这句可能被初学者忽略,但它非常重要。Dropout、BatchNorm 这些层有"训练模式"和"评估模式"两种行为:训练时 dropout 随机失活,BatchNorm 用当前 batch 的统计量;测试时 dropout 必须关闭,BatchNorm 必须切换到长期累积的 running 统计量。如果不调eval(),测试过程还带着 dropout 的随机性,准确率会忽高忽低,不稳定。with torch.no_grad()也很关键:测试阶段不需要梯度,这能显著减少内存占用和计算开销。如果你忘了加,会发现显存占用高得离谱,或者某些情况下反向传播的累积导致速度越来越慢。
最后保存:
torch.save(model.state_dict(), "lenet_mnist.pth")按 3.1 的说法,只存state_dict是正确姿势。将来要恢复,只需model = LeNetLike()之后model.load_state_dict(torch.load("lenet_mnist.pth"))即可。别存整个 model,否则版本一换就踩坑。
5. 常见问题与排查技巧实录
模型构建和训练过程中,我比较确信下面几个问题是出现概率最高的。我把它们整理成了一个速查表,每一条背后都有真实的调试经历。
| 问题表现 | 根本原因 | 排查方法 |
|---|---|---|
| loss 不下降,稳定在一个固定值 | 学习率太低;网络初始化不当;数据标签错乱 | 打印初始 loss 判断模型是否"学到了";调大 lr;检查标签映射 |
| loss 变成 NaN | 学习率太高;输入含 NaN;梯度爆炸 | 降低 lr;检查数据标准化;加梯度裁剪clip_grad_norm_ |
| 训练慢得像蜗牛 | 数据/模型留在 CPU 上没做.to(device) | 用device统一迁移;打印str(device)确认 |
| 训练时 loss 震荡剧烈 | 忘了zero_grad;batch_size 过小 | 检查训练循环顺序;适当加大 batch_size |
| 测试准确率低但训练高 | 过拟合;eval()模式未切换;dropout 没关闭 | 加早停/正则化;确认model.eval() |
报错Expected input batch_size (...) to match target batch_size | 最后全连接层输出维度与标签维度不匹配 | 检查fc的输出维度是否等于类别数 |
报错shape [x, y] is invalid for input of size z | 展平或 reshape 操作维度写错 | 用print(model(torch.randn(...)).shape)逐层输出检查 |
| 模型参数没有更新 | 参数被存成普通 Tensor 而不是 nn.Parameter;optimizer.zero_grad() 之后才 backward | 打印next(model.parameters()).grad看是否有梯度 |
| 加载模型报 key 不匹配 | 网络结构定义与 state_dict 不一致 | 打印model.state_dict().keys()对比 |
除了表格里的问题,再分享几个我自己调试时的独门技巧。
第一,先小后大。第一次训练不要跑完整的数据集,可以取 2~3 个 batch,看看模型能不能把 loss 降到接近 0。如果几个 batch 都降不下去,说明实现有 bug,而不是超参问题。这个方法能帮你快速定位是代码问题还是模型容量问题。
第二,把关卡盯死在第一轮。第一轮 loss 的值非常有参考意义。对于 10 分类 MNIST,随机初始化的网络第一轮 loss 应该接近 log(10) ≈ 2.3026。如果你第一轮的 loss 偏离这个值太多,基本可以认定有问题:偏高说明数据或标签不对劲,偏低说明你在训练集上提前暴露了信息(比如数据泄漏)。
第三,打印每一层的形状。我在 4.2 里提过用假输入前向检查形状。调试时遇到维度报错,我会在 forward 里临时插几行print(x.shape),肉眼确认每个操作前后的形状变化。定位后删掉这些 print,完事。这个方法简单粗暴,但面对维度问题比看报错日志快得多。
第四,梯度可视化。怀疑梯度问题的时候,可以在loss.backward()后加一句打印print(model.fc1.weight.grad)看看梯度是否存在、是否过大或过小。如果梯度全是 0,多半是反向传播断在了某个地方,比如requires_grad设置错误或者输入数据被 detach 了。
还有一些小细节值得注意。数据增强和标准化别在DataLoader里和模型输入处各做一遍,容易造成分布漂移。训练过程中打印 loss 用loss.item()而不是直接打印 loss 张量,前者只取出 Python 数值,不建立额外计算图。model.train()别忘了在每轮训练开始前调用,因为如果你之前调过eval(),模型会一直保持评估模式,train 模式不恢复会让 dropout 和 BatchNorm 在训练阶段用错误的行为方式。
写在最后的个人体会
在多个项目里跟"建立神经网络"这一关打过照面之后,我发现最值得强调的依然不是网络结构本身,而是对每行代码行为的理解。nn.Module提供了自动化,但自动化不等于黑盒,你得知道它什么时候替你做参数注册、什么时候能帮你迁移设备、什么时候又因为漏配而静默失败。
这个阶段的建议很简单:一定不要复制网上的代码然后直接跑。你可以参考结构,但每一层为什么存在、维度怎么对、loss 选什么、优化器配哪些参数,都要亲手算一遍。等你能闭上眼写出一个 LeNetLike 的前向流程和训练循环,才算真正跨过了 PyTorch 的第一道门槛。接下来可以动的东西就多了:自定义损失函数、容器模块nn.Sequential简化结构、学习率调度、迁移学习、混合精度训练……要学的路还很长,但地基就在这里。