上篇文章我们把张量和自动求导过了一遍,热热身还可以,真到自己动手写模型时,很多人还是愣住:到底怎么把卷积、全连接这些层拼成一个能训练的网络?今天这篇是深度学习入门 (PyTorch) 系列的下篇,核心就一个——搭建神经网络,主角是 PyTorch 的 nn.Module。
这篇内容解决的是从“会算张量”到“能搭模型”之间的断层。你不需要已经掌握所有网络结构,只需要有 Python 面向对象基础,最好已经知道 torch.Tensor 是个啥。我会用一个经典的手写数字识别任务做例子,带着你从空类开始,把网络结构写出来、把参数初始化讲明白、把训练循环串起来,再把 nn.Module 最容易踩的坑挨个点一遍。文里的所有代码都是可以直接复制跑通的,不是我故意缩水演示。
提示:所有代码基于 PyTorch 2.x 编写,Python 要求在 3.9 以上。如果你还没装好环境,先把 GPU 版 PyTorch 装好再往下读。
1. 为什么网络要包在 nn.Module 里:一组张量加几个函数还不够吗
很多人第一次接触 PyTorch 时会产生一个疑问:我用普通 Python 类存权重,用函数写前向计算,不也能训练吗?确实能,如果你只写一个 5 层的玩具网络,那点代码量怎么折腾都行。可一旦层数变多、结构变复杂、还要加分布式、混合精度、可视化,问题就来了。
1.1 没有 nn.Module 时,我们遇到的问题
假设你想用一个全连接层,输入 784 维,输出 128 维。最简单的方式是创造两个张量:
w1 = torch.randn(784, 128, requires_grad=True) b1 = torch.randn(128, requires_grad=True)然后每次前向都手动写out = x @ w1 + b1。这倒是能跑,但你想过这些问题没有:
- 模型里有 20 个这样的参数变量,你怎么统一管理它们?总不可能每次训练时手动列一个 40 个元素的列表。
- 你想把模型从 CPU 搬到 GPU,是不是得写个函数把所有张量
.to("cuda")?层数一多,漏掉一个是迟早的事。 - 你想保存模型,是不是得自己组装字典再手动还原?
这些都是重复劳动,而且极其容易出错。nn.Module 存在的意义,就是把“参数注册、设备迁移、模型保存、子模块组织”这些琐碎但必须做的事情,全部自动化。
1.2 nn.Module 在背后替你管理了什么
先看一个最简模型:
import torch import torch.nn as nn class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 128) self.fc2 = nn.Linear(128, 10)就这么几行,model.parameters()就能列出一共四组参数:fc1.weight、fc1.bias、fc2.weight、fc2.bias。你可能觉得这不稀罕,但关键点是:你在__init__里把某个对象赋值给self.fc1,这个动作触发了nn.Module重载过的__setattr__方法,它会自动识别出“这是一个 nn.Module 子模块”,然后把它登记进模型的管理系统里。
同理,如果你赋值的是普通张量,它不会被当成参数;如果你赋值的是nn.Parameter,它会被自动注册成可训练参数。这一套注册机制是整个框架的基石。你不需要手动写“把所有子模块收集到列表”的逻辑,model.parameters()、model.state_dict()、model.to(device)全都是在遍历这棵由 nn.Module 组成的树。
你可以把 nn.Module 理解成一个收纳箱:箱子自带标签系统,你放进去的每一层、每个参数都会被自动归类。发往设备时,箱子统一搬运;保存时,箱子统一打包。你用原生 Python 手写这些,至少得花几十行代码,而且每换一种改动都要重新维护。
1.3call与 forward:不要直接调 forward
新手经常看到一个魔法:model(x)能跑前向,但model.forward(x)也能跑。两者看起来一样,实际上有区别。
model(x)走的是__call__方法,nn.Module在__call__里做了不少事情,其中最重要的就是插入了 hook 机制。所谓 hook,你可以理解成“摄像头”:你可以在某个层执行前后挂上自定义函数,去偷看中间激活值、修改梯度。PyTorch 的可视化工具和很多 debug 手段都依赖 hook。如果你直接调用forward,这些 hook 全部不会触发。
另外__call__还会保证某些类型转换和上下文状态的一致性。所以一个铁律:永远用model(x),不要用model.forward(x)。除非你在调试某个层时故意绕过 hook,否则没有理由直接调 forward。
2. 从零写一个能识别数字的简易网络:拆解init与 forward 的分工
下面进入正经搭建环节。我们的目标很具体:输入是一张28x28的灰度图,输出是 10 个类别的分数。所以输入特征数是28*28=784,输出数是 10。
2.1 先定目标:用全连接网络处理 28x28 图像输入
全连接网络也叫多层感知机(MLP),结构就是一串线性层叠起来,中间加非线性激活函数。这个例子虽然简单,但足够展示 nn.Module 的所有核心用法。下面是我在实际项目里习惯使用的网络骨架,注释写得很详细:
import torch import torch.nn as nn import torch.nn.functional as F class DigitNet(nn.Module): def __init__(self, hidden_size=128, num_classes=10): super().__init__() self.fc1 = nn.Linear(28 * 28, hidden_size) self.bn1 = nn.BatchNorm1d(hidden_size) self.fc2 = nn.Linear(hidden_size, num_classes) def forward(self, x): # x 形状: (batch_size, 1, 28, 28) x = x.view(x.size(0), -1) # 展平成 (batch_size, 784) x = self.fc1(x) x = self.bn1(x) x = F.relu(x) x = self.fc2(x) return x一个nn.Module子类最核心的义务只有两个:在__init__里创建参数和子模块,在forward里写出前向计算过程。forward不一定要调某个现成层,你完全可以自己在里面写张量运算,比如x = x * 0.5 + 1。只要输入输出都是 Tensor,逻辑就成立。
2.2init里放参数,forward 里放计算逻辑
很多新手分不清“哪部分放__init__,哪部分放forward”,我的判断标准很简单:凡是带可训练参数的层,就放__init__;凡是纯计算逻辑,可以放forward。
nn.Linear里有权重和偏置,必须放__init__。F.relu没有任何可训练参数,它是个无状态函数,当然可以放 forward 里直接调用。BatchNorm1d有可训练的缩放因子和偏移量,所以也要放__init__。
这里有个容易混淆的点:nn.ReLU和nn.functional.relu有什么区别?前者是一个 Module,可以放进nn.Sequential里,但它内部没有可训练参数,所以只会白白多占一层管理开销;后者是纯函数,写起来干净。我个人的习惯是:如果是自己手写模型,激活函数一律用F.relu;如果是在nn.Sequential里按顺序堆叠,那就用nn.ReLU,因为 Sequential 只认模块。
BatchNorm1d放在Linear后面、ReLU前面,这是当前比较通行的做法。因为 BatchNorm 需要统计当前批次的均值和方差,先做归一化再做非线性激活,能让激活输出分布更稳定。顺序不要轻易改成Linear -> ReLU -> BN,那样起到的效果会打折扣。
2.3 动态图:每次 forward 都是一次全新的执行
PyTorch 的一个关键特性是动态图,意思是每次调用model(x),前向过程都会从头到尾重新执行一遍 Python 代码,同时把执行路径记录下来,建立计算图。这个特性让调试变得极其舒服。
你可以直接在forward里加print(x.shape),然后立刻看到结果;可以加一个if分支,让不同输入走不同路径;甚至可以在前向过程中循环调用同一个层。这些在静态图框架里都很难做到,但在 PyTorch 里就是普通 Python 逻辑。对于一个入门阶段的人来说,这意味着你不需要一开始就理解“计算图”这个概念,只要会写 Python 流程控制,就能写前向。
2.4 用一次小批次输入验证维度
刚写完网络,别急着训练,先喂一小批数据看看形状对不对:
model = DigitNet() fake_input = torch.randn(4, 1, 28, 28) output = model(fake_input) print(output.shape) # 期望输出 torch.Size([4, 10])我的建议是,写完模型之后第一件事就是这个维度自测。因为网络层数一多,某一个线性层输入维度配错的情况太常见了。配上错误提示虽然会告诉你哪一层不匹配,但不如你心里先有个预期:输入 4 张图,输出就应该是[4, 10]。
如果你在这个阶段就想看模型长什么样,用print(model)或者summary类工具都可以。print(model)会把所有子模块按缩进打出来,结构一目了然。真正常见的做法是先打印结构,再喂假数据验证。
3. 参数的生命周期:优化器如何找到模型里所有可训练权重
模型写完了,接下来有个更现实的问题:训练时优化器该怎么拿到这些参数?
3.1 named_parameters 与 state_dict:把模型扒开看
nn.Module提供了两个非常常用的方法:
model.parameters():返回一个生成器,产出所有参数张量。model.named_parameters():返回 (名字, 参数张量) 的元组生成器,名字类似fc1.weight。
优化器接收的就是model.parameters():
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)这就是标准的参数注册流程:你在__init__里定义好层,框架自动把参数收集起来,优化器通过parameters()拿到所有参数引用。后续optimizer.step()执行时,它内部会遍历自己保存的参数列表,原地更新每个参数的data。
这里要注意:优化器拿到的是参数对象的引用,不是拷贝。所以你后来给模型调用了.to("cuda"),参数对象变成了新的张量,那么之前创建优化器时拿到的引用就会失效。所以正确顺序是:
- 创建模型
- 把模型转移到设备
- 创建优化器
如果你先创建优化器再转移模型,训练时会出现参数一直不更新或者直接报错的情况。这个坑我至少见过十个人踩过。
model.state_dict()返回一个字典,键就是参数名,值是对应的张量。保存模型时通常只保存state_dict,因为它体积小、不拖泥带水,不包含模型结构代码。万一以后你修改了模型类名,只要参数名保持一致,老权重照样能加载。
torch.save(model.state_dict(), "model_weights.pth") # 加载 model = DigitNet() model.load_state_dict(torch.load("model_weights.pth"))注意,load_state_dict要求字典里的键和当前模型的参数名完全匹配。如果你改了某一层的名字,加载时就会报 unexpected key 或 missing key。
3.2 初始化:同样重要却容易被忽略的一步
PyTorch 的nn.Linear默认会使用一种合理的初始化方式,一般来说不用额外操心。但如果你做的是更深层的网络,或者某个任务对初始状态特别敏感,就得手动干预了。
初始化通常放在模型创建之后、训练之前:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_uniform_(m.weight, a=0.2) nn.init.constant_(m.bias, 0.0) model.apply(init_weights)apply方法会递归地把函数作用到所有子模块上,所以你可以在这个函数里用isinstance判断层类型,分别设置初始化策略。
为什么要关注初始化?因为如果权重全部初始化为 0,那么所有神经元会学到同样的东西,对称性问题会让网络表达能力完全退化。如果权重初始值过大,经过深层网络后激活值会爆炸;过小则梯度消失。推荐新手记住两个口诀:线性层用 kaiming 初始化,配合 ReLU 使用;带 sigmoid/tanh 的层优先用 xavier 初始化。
3.3 保存加载与切换到评估模式
训练完模型之后,你需要把它放到“评估模式”。这个“模式”和推理时的计算方式有关,尤其影响 BatchNorm 和 Dropout:
- 训练模式下,BatchNorm 用当前批次的均值和方差,Dropout 随机失活神经元。
- 评估模式下,BatchNorm 用训练阶段积累的全局滑动统计量,Dropout 不再失活。
所以测试模型前必须切换模式:
model.eval() with torch.no_grad(): logits = model(test_batch)model.eval()只是设置一个标志位,它会递归地影响所有子模块。torch.no_grad()让整个代码块内不建立计算图,节省显存和计算时间。推理时这两者通常一起用。
如果你在训练循环的某一步忘了切回model.train(),下一个 epoch 里 BatchNorm 会一直在错误的统计口径里打转,模型效果会莫名其妙变差。我习惯在每个 epoch 开头先model.train(),在 eval 之前再model.eval(),宁可多写一次,绝不偷懒。
3.4 自定义 nn.Parameter:当内置层不够用时
内置层终有一天会不够用。比如你想实现一个自定义缩放层,输入向量乘一个可训练的缩放系数。这时就需要手动创建参数:
class ScaleLayer(nn.Module): def __init__(self, feature_dim): super().__init__() self.scale = nn.Parameter(torch.ones(feature_dim)) def forward(self, x): return x * self.scalenn.Parameter是 Tensor 的子类,最大的不同就是:一旦把它赋给nn.Module的属性,它就会自动被注册成可训练参数。那么model.parameters()里就会包含这个scale,优化器也会更新它。
这里还要区分两种情况:如果你希望某个量被保存到模型里,但不参与梯度更新,那就用buffer注册,最常见的例子是 BatchNorm 里的 running_mean。buffer的注册方式有两种,一种是像上面一样用self.register_buffer("name", tensor),它会出现在state_dict里,但不会出现在parameters()里。这个细节看似小,实则会严重影响模型保存和加载的正确性。
4. 把模型放进训练循环:DataLoader、损失函数与优化器的配合
模型本身只是一块待组装的硬件,真正让它长出能力的是训练循环。这一节我把完整训练过程拆开讲,不搞玄学。
4.1 一幅完整的训练骨架长什么样
假设你已经拿到了手写数字数据集,并且用DataLoader封装成可迭代对象,那么下面这段代码就是 PyTorch 训练的通用骨架:
import torch import torch.nn as nn from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = DigitNet().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) valid_loader = DataLoader(valid_dataset, batch_size=64, shuffle=False) num_epochs = 5 for epoch in range(num_epochs): model.train() total_loss = 0.0 for images, labels in train_loader: images = images.to(device) labels = labels.to(device) logits = model(images) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in valid_loader: images = images.to(device) labels = labels.to(device) logits = model(images) preds = logits.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) accuracy = correct / total print(f"epoch={epoch}, loss={avg_loss:.4f}, acc={accuracy:.4f}")CrossEntropyLoss在 PyTorch 里已经帮你组合了 Softmax 和对数损失,所以你网络最后一层只需要输出原始分数 logits,不需要额外加 Softmax。这个细节总是有人忘记,导致训练时数值不稳定。
4.2 optimizer.zero_grad、backward、step 的固定顺序为什么不能乱
这个顺序是新手最容易搞混的地方。很多人明白三行代码是必要的,但不明白为什么必须是zero_grad -> backward -> step这个顺序。
PyTorch 的梯度是累积式的。默认情况下,每次执行backward,计算出来的梯度会累加到张量的.grad属性上。如果你不在下一次反向传播之前把.grad清零,上一次的梯度就会和这次的叠加在一起,造成梯度值越来越大,优化方向被严重污染。
所以zero_grad必须在每次 forward 之前,或者说在一次新的反向传播之前清零。把zero_grad写在loss.backward()后面就晚了,因为backward会把梯度累加到还没清零的张量上。
step的作用是拿着.grad去更新参数数值。更新完参数之后,计算图也已经释放了,下一轮循环再从零开始 forward。也就是说,整个循环的逻辑是:清空旧梯度 -> 计算前向 -> 计算损失 -> 反向传播 -> 更新参数。这五个动作打包起来,叫一个训练步。
这里有个常见问题:为什么有时候 loss 一直不下降?检查一下optimizer.zero_grad()是不是被写在了backward后面。如果写在了backward后面,那这次更新用的是“上一次的梯度”,虽然也能跑,但实际效果会非常奇怪,就像跑步时先把脚踩到终点再弯腰系鞋带,总是慢半拍。
4.3 训练里到底看哪些指标,什么时候该停下检查
训练初期,我看的最重要的两个信号是:loss 有没有持续下降,以及验证集准确率有没有跟着上升。
如果 loss 下降但验证集准确率不涨,多半是过拟合已经开始了,或者验证集和训练集分布不一致。这时候优先检查数据划分:训练集和验证集是否经过了同样的预处理?有没有 shuffle?标签有没有对齐?
如果 loss 变成 NaN,第一反应不是调学习率,而是先回退到几十步的小批次上,把model.train()打开,逐个检查输入数据里有没有 NaN。数据里的 NaN 会直接污染梯度,让你后续所有努力白费。
至于学习率选多大,我建议刚入门时先固定1e-3跑热身,因为 Adam 对学习率相对宽容。如果你的网络很深,或者用的是纯 SGD,那就要小一到两个数量级。不要迷信某个固定值,一切以实际曲线为准。
5. nn.Module 最常见的一批报错:我的排查顺序和解决办法
最后这一节,分享我在实际调试中反复遇到的 nn.Module 相关报错。这些坑每个都很经典,每一行错误信息背后都有固定的根因。
5.1 高频报错排查表:症状、初检项、修复
我把几个最常出现的报错整理成一张表,方便你横向对照:
| 症状 | 错误信息特征 | 常见根因 |
|---|---|---|
| 模型没有参数 | 优化器报“no parameters” | __init__里忘记调用super().__init__() |
| 自定义层参数不更新 | 训练时 loss 不降,层权重大小不变 | 把子模块放进了普通 list,而不是nn.ModuleList |
| 加载权重报键不匹配 | unexpected key / missing key | 前面保存的模型参数名和当前模型不一致,或者模型结构改了 |
| 预测结果不稳定 | 同一输入多次预测结果不同 | 推理时忘了model.eval(),Dropout 还在生效 |
| loss 一直卡在某个数 | loss 值非常大且不随 epoch 变化 | 数据没归一化,或者学习率过大 |
| 设备不匹配 | Expected all tensors to be on the same device | 输入数据、标签、模型没放在同一设备上 |
这里我想多说一句:报错信息里最关键的是那个“Expected all tensors to be on the same device”。很多新手看到一长串英文就发怵,其实这句话只说明数据设备不一致。你拿鼠标把它们划出来,看是哪个张量在 CPU、哪个在 GPU,问题就解决了一半。
5.2 三处高频现场:可迭代容器、eval 切换、NaN 问题
第一处高频现场:普通 Python list 导致子模块不注册。很多人会这样写:
class MyNet(nn.Module): def __init__(self): super().__init__() self.layers = [nn.Linear(10, 10) for _ in range(3)]这看起来没问题,但实际上self.layers是一个普通 list,PyTorch 的__setattr__只会在你给属性赋值一个 Module 的时候自动注册,list 里的 Module 不会被自动扫描到。结果就是模型没有参数,反向传播完全失效。正确做法是:
self.layers = nn.ModuleList([nn.Linear(10, 10) for _ in range(3)])nn.ModuleList就是专门用来存子模块的容器,它会把你塞进去的每个模块都注册到上层的管理系统里。同理,如果你用字典存子模块,那就用nn.ModuleDict。
第二处高频现场:eval 切换漏景。Dropout 层只在训练时随机失活,如果你推理时不切model.eval(),同样的输入每次输出的结果都不一样,看起来就像模型在抽风。这个问题容易被忽视,因为代码不报错,只是结果不稳定。遇到这种“玄学”问题,先查两个地方:模型是否在 eval 模式、是否开启了no_grad。
第三处高频现场:NaN 问题。很多人一看到 NaN 就怀疑学习率太大,但输入数据本身有 NaN 也会导致梯度爆炸。排查顺序我一般是:打印data.isnan().sum()-> 打印logits.isnan().sum()-> 打印loss。哪一步出现 NaN,问题就在哪一步之前。如果只有 loss 是 NaN,再去降学习率。
5.3 初始化与张量设备不一致的坑
最后一个坑,是初始化与设备之间微妙的相互作用。
如果你在 CPU 上用torch.randn创建一个参数,然后赋给一个已经被.to("cuda")的模型里的某个属性,那么这个参数会留在 CPU 上。模型在 GPU 上做前向时,就会报设备不一致。PyTorch 的nn.Parameter注册机制不会自动帮你迁移你手写的普通参数,只有save和load时才会统一处理。
所以自定义参数时,最安全的办法是在模型内部创建参数,然后通过model.to(device)统一迁移。不要在模型外部造好张量再硬塞进去。要是你实在想在外面创建,那就在训练前执行一次param = nn.Parameter(param.to(device))。
还有一类问题出现在初始化阶段:apply函数里如果不是原地操作,比如写了m.weight = m.weight * 2,那新生成的张量可能没有被正确注册到原模型里,最终导致参数丢失。初始化时请一律用nn.init下的原地方法,也就是那些带下划线结尾的函数,比如normal_、constant_、kaiming_uniform_。
我自己初期调试时犯过的最大错误,是以为model = DigitNet()之后,模型里的参数马上就会出现在某个全局变量里。实际上参数是惰性的,它们存在于模块内部,只有通过parameters()或state_dict()才能访问。这个认知一旦建立,后面理解优化器、hook、混合精度都会顺畅很多。
这篇实战内容基本到这里,PyTorch 的 nn.Module 入门关键不在于记住 API 名字,而是理解它管理参数的机制,然后把训练循环跑通。你如果能独立跑通上面那段手写数字识别的代码,再回来做一个隐藏层更大的网络,难度会小很多。之后遇到任何报错,先按第五节的行文顺序去查:前向维度、参数注册、设备、模式切换,多半不会跑偏。