04-nn模块详解:用积木搭建神经网络
本文你将学到 :如何继承 nn.Module 写出自己的网络层、nn.Parameter 与自动反向传播的关系、Conv2d/Pool/Linear/BatchNorm/Dropout 五大常用层的参数含义与输出形状计算、ReLU 的 inplace 陷阱、Sequential 与 ModuleList 两种容器的三种写法与本质区别(为什么普通 list 存的层"训练不到")、LSTM 与 LSTMCell 的关系,以及损失函数为什么也是一种 layer。全文代码基于 PyTorch 1.8,均可直接运行。
文章目录
- 04-nn模块详解:用积木搭建神经网络
-
- [1. nn.Module:一切网络的基类](#1. nn.Module:一切网络的基类)
-
- [1.1 手写一个全连接层](#1.1 手写一个全连接层)
- [1.2 模块嵌套:多层感知机](#1.2 模块嵌套:多层感知机)
- [2. 常用神经网络层](#2. 常用神经网络层)
-
- [2.1 卷积层 Conv2d](#2.1 卷积层 Conv2d)
- [2.2 池化层](#2.2 池化层)
- [2.3 Linear、BatchNorm 与 Dropout](#2.3 Linear、BatchNorm 与 Dropout)
- [3. 激活函数与 inplace](#3. 激活函数与 inplace)
- [4. Sequential 与 ModuleList:两种搭积木容器](#4. Sequential 与 ModuleList:两种搭积木容器)
- [5. 循环神经网络层](#5. 循环神经网络层)
- [6. 损失函数概览](#6. 损失函数概览)
- 总结

上一篇我们用 autograd 手动实现了模型训练,代码能跑,但写起来很累:参数要自己建、自己更新,网络一深就没法维护。torch.nn 就是为了解决这个问题而生的------它构建在 autograd 之上,把"层"抽象成一个个积木,你只需要声明积木、拼积木,反向传播全部自动完成。
1. nn.Module:一切网络的基类
torch.nn 的核心数据结构是 Module。这个抽象很巧妙:它既可以表示单独一个层(比如一个卷积层),也可以表示一整个网络 。实际开发中 99% 的场景就是一句话------继承 nn.Module,重写 __init__ 和 forward。
1.1 手写一个全连接层
全连接层(仿射层)的数学形式是 y = Wx + b,其中 W 和 b 是可学习参数。用 nn.Module 实现只要不到 10 行:
python
import torch as t
from torch import nn
class Linear(nn.Module):
def __init__(self, in_features, out_features):
super().__init__() # 必须先调用父类构造函数
# 用nn.Parameter包裹的张量才会被登记为"可学习参数"
self.W = nn.Parameter(t.randn(in_features, out_features))
self.b = nn.Parameter(t.randn(out_features))
def forward(self, x):
x = x.mm(self.W) # 矩阵乘法,等价于 x @ self.W
return x + self.b.expand_as(x)
layer = Linear(4, 3)
input = t.randn(2, 4)
output = layer(input) # 注意:调用layer(input)而不是layer.forward(input)
print(output.shape) # torch.Size([2, 3])
for name, parameter in layer.named_parameters():
print(name, parameter.shape)
# W torch.Size([4, 3])
# b torch.Size([3])
这段代码浓缩了 nn.Module 的全部使用要点,逐条拆开说:
- 必须调用父类构造函数 。
super().__init__()会初始化_parameters、_modules等内部字典(下一篇会深挖),漏掉这行,后面给属性赋值 Parameter 会直接报错。 - 可学习参数必须封装成
nn.Parameter。Parameter是一种特殊的 Tensor,默认requires_grad=True。只有封装成 Parameter,这个张量才会出现在parameters()迭代器里,优化器才能"看到"它。如果只写self.W = t.randn(...),训练时 W 就是个纹丝不动的常量。 - 只写前向,不写反向 。
forward里全是普通的 Tensor 运算,反向传播由 autograd 自动推导,这是nn.Module比手写Function好用的关键。 - 用
layer(input)而不是layer.forward(input)。前者实际调用__call__,它除了执行 forward,还负责触发钩子函数(hook)等机制。直接调 forward 会跳过这些机制,是不规范的写法。 - 参数可以带名字遍历 。
named_parameters()比parameters()多返回一个名字,调试时非常有用。
1.2 模块嵌套:多层感知机
nn.Module 真正强大的地方在于可以递归嵌套:一个 module 可以把其他 module 当成自己的子模块,子模块的参数会自动归入主模块。用刚才自定义的 Linear 拼一个多层感知机:
python
class Perceptron(nn.Module):
def __init__(self, in_features, hidden_features, out_features):
super().__init__()
# 前面自定义的Linear在这里成了子module
self.layer1 = Linear(in_features, hidden_features)
self.layer2 = Linear(hidden_features, out_features)
def forward(self, x):
x = self.layer1(x)
x = t.sigmoid(x) # 层与层之间可以插入任意处理
return self.layer2(x)
perceptron = Perceptron(3, 4, 1)
for name, param in perceptron.named_parameters():
print(name, param.size())
# layer1.W torch.Size([3, 4])
# layer1.b torch.Size([4])
# layer2.W torch.Size([4, 1])
# layer2.b torch.Size([1])
注意输出的参数名:子模块的参数名会自动加上子模块名做前缀,规则是"子模块名.参数名",比如 layer1.W。这个命名规范在做参数分组(给不同层设不同学习率)、加载权重时会反复用到,值得记住。
PyTorch 内置的所有网络层(Conv、Linear、LSTM......)都遵循同样的套路:继承 nn.Module、封装 Parameter、实现 forward,而且大多针对 GPU 做了 cuDNN 优化。阅读某个层的文档时,重点关注三件事:
- 构造函数参数 ,如
nn.Linear(in_features, out_features, bias)中每个参数的作用; - 有哪些可学习参数,如 Linear 有 weight 和 bias 两个;
- 输入输出形状 ,如 Linear 输入是
(N, input_features),N 是 batch_size。
还有一个新手必踩的坑:所有层的输入都必须是一个 batch 。如果手上只有一条数据,要用 tensor.unsqueeze(0) 或 tensor[None] 伪装成 batch_size=1 的 batch,否则形状对不上。
2. 常用神经网络层
2.1 卷积层 Conv2d
卷积网络的本质就是卷积层、池化层、激活层等的堆叠。二维卷积的完整签名:
python
torch.nn.Conv2d(in_channels, out_channels, kernel_size,
stride=1, padding=0, dilation=1, groups=1, bias=True)
六个高频参数:
| 参数 | 含义 |
|---|---|
| in_channels | 输入通道数,RGB 图为 3,灰度图为 1 |
| out_channels | 输出通道数,即卷积核个数 |
| kernel_size | 卷积核大小,正方形核可直接写一个 int |
| stride | 滑动步长 |
| padding | 边界填充,默认 0 |
| bias | 是否带偏置,默认 True |
设计网络时必须会算输出形状。设输入为 (N, C_in, H_in, W_in),则:
H_out = ⌊(H_in + 2×padding - kernel_size) / stride⌋ + 1
W_out 同理
用一个"锐化卷积核"实际感受一下卷积的效果:
python
from PIL import Image
from torchvision.transforms import ToTensor, ToPILImage
to_tensor = ToTensor()
to_pil = ToPILImage()
lena = Image.open('imgs/lena.png') # 200×200灰度图
lena = to_tensor(lena).unsqueeze(0) # 伪装成batch_size=1
print("Input:", lena.size()) # torch.Size([1, 1, 200, 200])
# 手工构造一个锐化卷积核:中心为1,周围为-1/9
kernel = t.ones(3, 3) / (-9.)
kernel[1][1] = 1
conv = nn.Conv2d(1, 1, (3, 3), 1, bias=False)
conv.weight.data = kernel.view(1, 1, 3, 3) # 直接覆盖权重
out = conv(lena)
print("Output:", out.size()) # torch.Size([1, 1, 198, 198])
to_pil(out.data.squeeze(0)) # 可视化:图像边缘被明显强化
验证一下公式:输入 200×200,kernel=3,stride=1,padding=0,输出 = (200 + 0 - 3)/1 + 1 = 198,和程序打印一致。这里直接给 conv.weight.data 赋值是为了演示卷积的物理含义(锐化滤波),实际训练中权重由优化器更新,一般不要手动改。
2.2 池化层
池化可以理解成一种"权重固定"的特殊卷积,作用是下采样:保留主要特征的同时缩小特征图、降低参数量,一定程度上抑制过拟合。常用的有最大池化 MaxPool2d、平均池化 AvgPool2d,以及自适应版本 AdaptiveAvgPool2d:
python
input = t.randint(10, (1, 1, 4, 4))
print(input)
# tensor([[[[6, 8, 9, 2],
# [0, 3, 1, 4],
# [7, 0, 9, 9],
# [9, 3, 2, 7]]]])
pool = nn.AvgPool2d(2, 2) # 2×2窗口,步长2
print(pool(input))
# tensor([[[[4, 4],
# [4, 6]]]]) # 每个2×2区域取平均
print(list(pool.parameters())) # [] ------ 池化层没有可学习参数
list(pool.parameters()) 返回空列表,印证了池化层无参数这一点------它不参与训练,只按固定规则降采样。
2.3 Linear、BatchNorm 与 Dropout
除卷积和池化外,还有三个出场率极高的层:
- Linear:全连接层;
- BatchNorm:批标准化层,把每个通道的输出拉到指定的均值和方差,分 1D/2D/3D 版本,风格迁移中常用它的变体 InstanceNorm;
- Dropout:训练时随机丢弃一部分神经元防止过拟合,同样分 1D/2D/3D。
三者串起来演示:
python
# batch_size=2,特征维度3
input = t.randn(2, 3)
linear = nn.Linear(3, 4)
h = linear(input) # 输出形状(2, 4)
# BatchNorm:4个通道,手动把仿射参数设为 标准差4、均值0
bn = nn.BatchNorm1d(4)
bn.weight.data = t.ones(4) * 4
bn.bias.data = t.zeros(4)
bn_out = bn(h)
print(bn_out.mean(0), bn_out.std(0, unbiased=False))
# 均值≈0,标准差≈4 ------ 输出分布被强行"标准化"到指定形态
# Dropout:每个元素以0.5的概率被置零
dropout = nn.Dropout(0.5)
o = dropout(bn_out)
print(o) # 大约一半元素变成0
BatchNorm 的输出均值几乎为 0、标准差几乎等于我们设定的 4,这正是它的工作方式:先把 batch 内数据标准化,再用可学习的 weight/bias 做仿射变换,让网络自己决定需要什么样的分布。提醒一句:例子里直接改 weight.data 只是为了演示,实际训练中这些参数会随学习不断变化,除非做特殊初始化,不要手动修改。
BatchNorm 和 Dropout 还有个共同点:训练阶段和测试阶段的行为不一样 (BatchNorm 测试时用滑动平均统计量,Dropout 测试时不丢弃)。怎么切换两种模式,下一篇讲 train()/eval() 时会展开。
3. 激活函数与 inplace
纯线性层叠加多少层都等价于一个线性变换,激活函数负责引入非线性,提升网络的表达能力。最常用的 ReLU 定义极简:ReLU(x) = max(0, x)。
python
relu = nn.ReLU(inplace=True)
input = t.randn(2, 3)
print(input)
output = relu(input)
print(output) # 所有负数被截断为0,效果等价于input.clamp(min=0)
重点说说 inplace=True:它让输出直接覆盖输入的内存 ,省掉一份显存。为什么 ReLU 敢这么干?因为 ReLU 的反向传播只凭输出就能推算梯度(输出为 0 处梯度为 0,其余处为 1),不需要保留原始输入。但 inplace 不是万能的------只有少数 autograd 操作支持(如 tensor.sigmoid_()),而且只有当这个 Tensor 仅作为激活层的输入、不再被其他计算依赖时才能用。拿不准时就不加,正确性优先于那点显存。
除了 ReLU,常用的还有 sigmoid、tanh,按网络结构和数据分布灵活选用即可。
4. Sequential 与 ModuleList:两种搭积木容器
前馈网络里每层输出直接喂给下一层,每次都手写 forward 很啰嗦。PyTorch 提供两个容器简化这件事。
Sequential:把子模块串成流水线,前向传播时自动逐层传递。它有三种等价写法:
python
# 写法一:先建空容器,逐个add_module(可以命名)
net1 = nn.Sequential()
net1.add_module('conv', nn.Conv2d(3, 3, 3))
net1.add_module('batchnorm', nn.BatchNorm2d(3))
net1.add_module('activation_layer', nn.ReLU())
# 写法二:直接按顺序传入,子模块自动以0、1、2编号
net2 = nn.Sequential(
nn.Conv2d(3, 3, 3),
nn.BatchNorm2d(3),
nn.ReLU()
)
# 写法三:传OrderedDict,兼得命名和一次性构造
from collections import OrderedDict
net3 = nn.Sequential(OrderedDict([
('conv1', nn.Conv2d(3, 3, 3)),
('bn1', nn.BatchNorm2d(3)),
('relu1', nn.ReLU())
]))
# 取子模块:按名字或序号都行
net1.conv, net2[0], net3.conv1
# 三个网络都可以直接整体调用
input = t.rand(1, 3, 4, 4)
output = net1(input)
# 等价于手动逐层:net3.relu1(net1.batchnorm(net1.conv(input)))
三种写法效果一样,区别只在于子模块怎么命名、怎么索引。工程上推荐写法三:既能一次性构造,又有可读的层名。
ModuleList :像 Python list 一样使用,但没有实现 forward ,不能直接 modellist(input),需要自己在循环里逐个调用:
python
modellist = nn.ModuleList([nn.Linear(3, 4), nn.ReLU(), nn.Linear(4, 2)])
input = t.randn(1, 3)
for model in modellist:
input = model(input) # 手动逐层前向
# output = modellist(input) # 报错!ModuleList没有实现forward
那为什么不直接用 Python 自带的 list?这是本节最重要的一个坑,看对比实验:
python
class MyModule(nn.Module):
def __init__(self):
super().__init__()
self.list = [nn.Linear(3, 4), nn.ReLU()] # 普通list
self.module_list = nn.ModuleList([nn.Conv2d(3, 3, 3), nn.ReLU()])
def forward(self):
pass
model = MyModule()
for name, param in model.named_parameters():
print(name, param.size())
# module_list.0.weight torch.Size([3, 3, 3, 3])
# module_list.0.bias torch.Size([3])
# ------ 普通list里那个Linear的参数完全没出现!
原因:ModuleList 本身是 nn.Module 的子类,赋值给属性时会被主模块自动识别、登记为子模块;普通 list 则被当成普通 Python 对象,里面的层不会 进入参数列表。后果很严重------优化器拿不到这些参数,反向传播时它们永远不会被更新 ,而且代码不报错,模型只是默默地训练不动。同理还有 ParameterList,用于存放一组 Parameter。经验法则:构造函数里凡是想用 list、tuple、dict 装层或参数的地方,先想想是不是该换成 ModuleList/ParameterList。
选择建议:结构固定、一条道走到黑的堆叠用 Sequential;层数不定、需要条件分支或循环复用的场景用 ModuleList。
5. 循环神经网络层
PyTorch 内置了三种最常用的循环网络:RNN(vanilla)、LSTM、GRU,每种都有对应的 Cell 版本(RNNCell、LSTMCell、GRUCell)。两者的分工是:
- RNN/LSTM/GRU:一次吃进整个序列,封装完善,用起来省心;
- Cell 版本:一次只处理序列中一个时间步,灵活性高。RNN 层本质上就是对 Cell 的循环调用。
用同一份输入验证两者等价:
python
t.manual_seed(2021)
# 输入形状 (seq_len=2, batch_size=3, input_size=4)
input = t.randn(2, 3, 4).float()
# 方式一:nn.LSTM 一次处理整个序列
lstm = nn.LSTM(4, 3, 1) # 输入4维、3个隐藏元、1层
h0 = t.randn(1, 3, 3) # 初始隐藏状态 hidden state
c0 = t.randn(1, 3, 3) # 初始单元状态 cell state
out1, hn = lstm(input, (h0, c0))
print(out1.shape) # torch.Size([2, 3, 3])
# 方式二:nn.LSTMCell 手动沿时间步循环
t.manual_seed(2021)
input = t.randn(2, 3, 4).float()
lstm_cell = nn.LSTMCell(4, 3) # 一个Cell只对应一层
hx, cx = t.randn(3, 3), t.randn(3, 3)
out = []
for i_ in input: # 沿时间维逐步展开
hx, cx = lstm_cell(i_, (hx, cx))
out.append(hx)
out2 = t.stack(out)
print(out1.allclose(out2)) # True ------ 两种写法结果完全一致
这个实验直观展示了"RNN 层 = Cell + 时间循环"。日常任务直接用 nn.LSTM 即可;需要在时间步之间插入自定义逻辑(比如注意力机制、teacher forcing)时,才值得下沉到 Cell 级别手写循环。
NLP 场景还离不开词向量层 nn.Embedding,本质是一张可学习的查表矩阵:
python
# 词表大小4,每个词映射成5维向量
embedding = nn.Embedding(4, 5)
# 也可以用预训练好的词向量初始化
weight = t.arange(0, 20).view(4, 5).float()
embedding_pre = nn.Embedding.from_pretrained(weight)
input = t.arange(3, 0, -1).long() # 输入是词的索引,必须是LongTensor
output = embedding(input) # 形状(3, 5),每行是对应词的向量
输入是整数索引,输出是查表得到的向量。用 from_pretrained 可以直接加载 word2vec、GloVe 等预训练权重。
6. 损失函数概览
损失函数在 PyTorch 里也被实现为 nn.Module 的子类------它就是一种"特殊的层",只不过实践中通常单独提取出来定义。以分类任务最常用的交叉熵为例:
python
# batch_size=3,二分类,score是模型对每个类别打的分
score = t.randn(3, 2)
# 三个样本的真实类别分别是1、0、1,label必须是LongTensor
label = t.Tensor([1, 0, 1]).long()
criterion = nn.CrossEntropyLoss()
loss = criterion(score, label)
print(loss) # tensor(0.5865),一个标量
两个易错点:其一,nn.CrossEntropyLoss 内部已经包含了 softmax 和取对数操作,所以输入应该是未经 softmax 的原始分数(logits) ,不要自己先 softmax 一遍;其二,label 是类别索引的 LongTensor,不是 one-hot 编码。回归任务常用 nn.MSELoss、nn.L1Loss,用法完全一致:先实例化成 criterion,再像调用普通层一样 criterion(output, target)。
总结
本文要点回顾:
nn.Module是万物基类 :继承它、在__init__里声明子层和 Parameter、在forward里写前向逻辑,反向传播全自动;调用用layer(x)而非layer.forward(x)。nn.Parameter决定参数"训不训练得到" :只有封装成 Parameter 的张量才会进入parameters(),才会被优化器更新;子模块参数按"子模块名.参数名"自动命名。- 常用层:Conv2d 要会算输出形状公式;池化层没有可学习参数;BatchNorm 调整分布、Dropout 防过拟合,两者在训练和测试阶段行为不同。
- inplace 激活省显存,但仅当输入不被后续计算依赖时才能用。
- 容器选择 :固定串行结构用 Sequential,动态结构用 ModuleList;绝不能用普通 list 存子层,否则参数游离在模型之外,训练悄无声息地失效。
- RNN 层是 Cell 的循环封装;损失函数本质上也是 layer,交叉熵的输入是 logits 而非概率。
下一篇《05-nn模块进阶:优化器、初始化与ResNet实战》将继续深入:optim 优化器与分组学习率、nn.functional 与 nn.Module 的取舍、nn.init 参数初始化、hooks 与模型保存加载,最后亲手实现经典的 ResNet34。