三:神经网络模块 (torch.nn)
如何定义一个包含两个隐藏层的MLP?¶
定义一个经典的多层感知器需要继承 nn.Module,并在 init 中定义网络层,在 forward 中定义数据流。
import torch.nn as nn
class TwoLayerMLP(nn.Module):
def __init__(self, input_dim, hidden_dim1, hidden_dim2, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim1)
self.fc2 = nn.Linear(hidden_dim1, hidden_dim2)
self.fc3 = nn.Linear(hidden_dim2, output_dim)
self.activation = nn.GELU() # 常用的激活函数
self.dropout = nn.Dropout(0.2)
def forward(self, x):
x = self.activation(self.fc1(x))
x = self.dropout(x)
x = self.activation(self.fc2(x))
x = self.fc3(x) # 最后一层不加激活(回归)
return x
设计要点:
-
所有带参数的操作(
nn.Linear,nn.BatchNorm)必须在init中实例化并赋给self。 -
无参数的激活、Dropout 也推荐在
init中定义,以保持代码整洁和模块可复用。 -
最后一层通常不加激活,除非是分类任务(需配合 CrossEntropyLoss 的 logits 输入)。
2. nn.Module 的 init 和 forward 分别负责什么?¶
-
init:定义模型的静态结构。在这里声明所有子模块(层)、参数和缓冲区。它负责回答“模型由哪些组件构成”。PyTorch 通过它来追踪所有需要梯度的参数和需要保存的状态。 -
forward:定义模型的动态计算逻辑。它接收输入数据,按顺序调用各个子模块,执行前向传播,并返回输出。forward的调用是隐式的,通过model(input)触发,绝不能直接调用model.forward(x),因为前者会触发框架的钩子(hooks),而后者不会。
核心原则:init 构建“骨架”,forward 注入“灵魂”。
为什么推荐在 init 中定义所有层,而不是在 forward 中直接创建?¶
这是一个工程最佳实践,而非语法限制。原因如下:
-
参数追踪与优化:只有注册在
nn.Module上的子模块,其参数才会被自动追踪到。如果在forward中临时创建nn.Linear,其参数将不会被model.parameters()发现,从而无法被优化器更新,也无法正确保存和加载模型。 -
设备一致性:通过
model.to(device)可以自动将所有注册的子模块参数和缓冲区移动到目标设备。临时创建的层不会被移动,导致计算设备错误。 -
可复现性与调试:
init中的定义构成了模型的完整静态图,这使得print(model)可以展示完整结构,便于调试和版本管理。如果层是动态创建的,模型结构将不可预测。 -
性能:动态创建层会导致不必要的内存分配和初始化开销,尤其在循环中极慢。
结论:模型的结构必须是确定的、可追踪的。init 就是这份确定性契约。
将子模块添加到 Module 中有哪几种方式?各有什么特点?¶
有三种主要方式:
-
self.child = nn.Linear(...):最常见,直接赋值。子模块会自动注册,参数被追踪。 -
self.add_module('child', nn.Linear(...)):功能等价于直接赋值,但模块名是字符串。常用于循环中动态添加模块,或处理模块名为变量的情况。 -
nn.Sequential(*layers):容器模块,将多个子模块按顺序封装。内部的每个模块会自动注册。它本身也是一个nn.Module,可被赋值给self。
特点对比:
-
直接赋值和
add_module适用于非顺序或需要灵活控制的网络结构。 -
Sequential适合线性堆叠的网络块,代码最简洁,但缺少灵活性(无法在同一层有多个输入/输出或分支)。
nn.Parameter 和普通张量的核心区别是什么?¶
核心区别:nn.Parameter 是一个 Tensor,但会被自动添加到 nn.Module 的参数列表中。
-
注册与优化:当将一个张量赋值给
nn.Module的一个属性时,若该张量是nn.Parameter,它会被模块自动追踪,并通过model.parameters()暴露给优化器。普通张量则被忽略。 -
设备与类型转换:
nn.Parameter会随model.to(device)或model.float()自动转换设备和数据类型;普通张量不会。 -
保存与加载:
nn.Parameter会被model.state_dict()和torch.save自动保存和恢复;普通张量不会,必须手动处理。
示例:
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.W = nn.Parameter(torch.randn(10, 5)) # 可训练参数
self.x = torch.randn(10) # 普通张量,不会被优化
register_buffer 用来注册什么?与 Parameter 有何不同?¶
register_buffer(name, tensor) 用于向模块注册一个张量,这个张量不是可训练参数,但需要被模块追踪并保存。
- 与
Parameter相同点: - 都会被
model.to(device)移动到目标设备。 -
都会被
state_dict()和torch.save保存和恢复。 -
与
Parameter不同点: register_buffer注册的张量不参与梯度计算,不出现在model.parameters()中,优化器忽略它。- 典型用途:存储 BatchNorm 层的
running_mean,running_var,或任何需要持久化但在训练时不更新的状态。
如何获取模型的所有参数?named_parameters 和 parameters 的区别。¶
-
model.parameters():返回一个生成器,遍历所有可训练参数张量,但不包含参数名称。 -
model.named_parameters():返回一个生成器,每个元素是(name, param)元组。名称对应于模块层级路径(例如'fc1.weight')。这对于选择性冻结、调试和日志记录非常有用。
重要:这两个方法默认只返回 requires_grad=True 的参数。如果需要所有参数(包括被冻结的),需显式调用 model.state_dict() 或遍历 model.modules()。
如何统计模型的总参数量和可训练参数量?¶
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total: {total_params:,}, Trainable: {trainable_params:,}")
扩展:还可以使用 torchsummary 或 torchinfo 库获得更详细的分层统计。
怎样冻结模型中的某一层或几层?¶
冻结意味着将参数的 requires_grad 设为 False,阻止其接收梯度更新。
# 冻结单个层
for param in model.fc1.parameters():
param.requires_grad = False
# 冻结整个 backbone
for param in model.backbone.parameters():
param.requires_grad = False
注意:冻结操作是不可逆的吗?不,你可以随时重新设置 requires_grad=True,但对于已经冻结的参数,其梯度将不再被计算。
如何查看并修改某一层的权重和偏置?¶
所有带参数的层(如 nn.Linear)都有 weight 和 bias 属性(如果 bias=True)。它们是 nn.Parameter,可以直接访问和修改。
fc1 = model.fc1
print(fc1.weight.data) # 查看权重
print(fc1.bias.data) # 查看偏置
# 直接修改(慎用,通常用于特殊初始化或赋值)
fc1.weight.data = torch.randn_like(fc1.weight)
fc1.bias.data.fill_(0.0)
警告:直接修改 .data 会绕过 autograd 追踪,在训练中可能导致梯度异常。更好的做法是使用 nn.init 函数进行初始化。
如何对模型参数进行自定义初始化?kaiming 和 xavier 初始化有何不同?¶
通过 torch.nn.init 模块,可以方便地对参数进行多种初始化。
def init_weights(m):
if isinstance(m, nn.Linear):
# Kaiming(He)初始化:适用于 ReLU 系列激活
nn.init.kaiming_uniform_(m.weight, nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Conv2d):
# Xavier(Glorot)初始化:适用于 tanh、sigmoid 或线性激活
nn.init.xavier_uniform_(m.weight)
model.apply(init_weights) # 递归应用到所有子模块
Kaiming vs. Xavier 区别:
-
Xavier 初始化:基于输入和输出神经元的数量,保持前向和反向的方差一致。假设激活函数是线性的或 tanh/sigmoid(但现代网络很少使用)。
-
Kaiming 初始化:专门为 ReLU 及其变体(Leaky ReLU、GELU)设计,因为它考虑了ReLU会“杀死”一半的神经元(负半轴输出为0),所以需要放大初始化方差以补偿信息流。
现代实践:对于使用 ReLU 的 CNN 或 MLP,默认使用 Kaiming 初始化。对于 Transformer 中的线性层,通常使用较小的正态分布初始化,但 xavier 仍有用武之地。许多预训练模型(如 GPT)有自己特定的初始化策略,微调时应遵循原始代码。
如何利用backward hook实现梯度反转层?¶
梯度反转层(Gradient Reversal Layer, GRL)在正向传播时是恒等映射,反向传播时将梯度乘以一个负系数(通常为 −λ−λ),从而实现梯度的“反转”。它常用于对抗性训练,如领域自适应(Domain-Adversarial Neural Network, DANN),逼迫特征提取器学习到领域无关的表征。
在PyTorch中,可以通过 register_backward_hook 或更现代的方式(torch.autograd.Function)来实现。最稳定和推荐的方式是自定义一个 torch.autograd.Function,因为hook方式在某些情况下可能不够可靠。
实现(自定义Function):
from torch.autograd import Function
class GradientReversal(Function):
@staticmethod
def forward(ctx, x, lambda_=1.0):
# 保存λ供反向使用
ctx.lambda_ = lambda_
return x.view_as(x)
@staticmethod
def backward(ctx, grad_output):
# 梯度乘以负的λ并返回(对于x的梯度)
return grad_output.neg() * ctx.lambda_, None # 第二个梯度对应lambda_参数(不需要)
def grad_reverse(x, lambda_=1.0):
return GradientReversal.apply(x, lambda_)
使用示例:
x = torch.randn(3, requires_grad=True)
y = grad_reverse(x, lambda_=0.1)
loss = y.sum()
loss.backward()
print(x.grad) # 梯度被反转且缩放
注意:使用 register_backward_hook 也可行,但需注意 hook 的签名,并且它直接修改梯度流。自定义 Function 更清晰、更可控。
nn.Linear的权重矩阵形状是怎样的?偏置呢?¶
对于 nn.Linear(in_features, out_features, bias=True):
-
权重矩阵
weight:形状为(out_features, in_features),数据类型默认float32。前向计算为 y=xWT+by=xWT+b,其中输入 xx 形状为(*, in_features)。权重矩阵存储的是W,而 PyTorch 使用W^T进行乘法以优化内存布局。 -
偏置
bias:形状为(out_features,),是一个一维向量。如果bias=False,则此属性为None。
内部存储:weight 的形状确实是 (out_features, in_features)。可以通过 print(layer.weight.shape) 验证。
给定输入尺寸、卷积核等参数,如何计算Conv2d的输出尺寸?¶
nn.Conv2d 输出尺寸(高度和宽度)公式为:

参数说明:
-
H_in:输入高度 -
padding:单边填充量(若为int,则上下左右相同) -
dilation:空洞卷积的膨胀率,默认为1 -
kernel_size:卷积核大小 -
stride:步长,默认为1
输出通道数:等于 out_channels。
示例:输入 (1, 3, 32, 32),Conv2d(3, 16, kernel_size=3, stride=2, padding=1)。计算:

空洞卷积在nn.Conv2d中如何设置?对感受野有何影响?¶
设置方式:通过 dilation 参数。例如 nn.Conv2d(..., dilation=2)。dilation 默认为1(普通卷积),为2时卷积核元素之间插入一个零(即2倍膨胀),实际覆盖的视野变大。
对感受野的影响:
-
空洞卷积可以在不增加参数量的情况下,指数级扩大感受野。
-
实际核覆盖大小(有效核大小) =
dilation * (kernel_size - 1) + 1。例如kernel_size=3, dilation=2,有效核大小为2*2+1=5,即每个卷积核元素在输入上相距2个像素,能够捕获更广范围的上下文,但不会增加计算量。 -
常应用于语义分割中,用 dilated convolution 来保持分辨率同时增大感受野。
输出尺寸的计算公式如上题所示,其中 dilation 参数已包含在内。
转置卷积nn.ConvTranspose2d主要应用在什么场景?¶
转置卷积(有时不准确地称为反卷积)主要用于上采样和生成模型中,将低分辨率特征图恢复到高分辨率,如:
-
图像分割(如 U-Net 的解码器部分,逐步恢复空间尺寸)
-
生成对抗网络(GAN)的生成器,从噪声向量生成图像
-
自编码器的解码器,重构输入
-
特征可视化(将特征反向映射到像素空间)
原理:它不是卷积的逆运算,而是一种通过学习得到核的“反向”卷积操作,通过矩阵转置实现上采样。它会产生棋盘效应,需要注意。
自适应池化AdaptiveAvgPool2d的优势是什么?¶
优势:允许指定输出的目标尺寸 (H_out, W_out),而不需要计算输入尺寸、池化核大小和步长。内部自动计算所需的池化核大小和步长,使得无论输入特征图的尺寸如何,输出尺寸固定。
优点:
-
简化网络设计:可用于全卷积网络的最后,直接得到固定尺寸的特征,然后连接全连接层,使得网络可以接受任意尺寸的输入图像。
-
避免繁琐的尺寸计算:尤其在不同大小的数据集或数据增强(如随机裁剪)时,可以保持输出尺寸一致。
-
性能开销可控:池化操作计算量小。
典型应用:在图像分类中,全局平均池化 nn.AdaptiveAvgPool2d((1,1)) 将每个通道的特征图压缩为一个标量,直接作为全连接层的输入,参数量少,防止过拟合。
BatchNorm在训练和评估模式下的行为有何不同?momentum参数的作用。¶
训练模式(model.train()):
-
对当前 mini-batch 的每个通道计算均值 μbatchμbatch 和方差 σbatch2σbatch2。
-
利用这两个统计量对输入进行归一化。
-
同时,以指数移动平均(EMA)的方式更新全局的
running_mean和running_var,用于评估模式。更新公式:

-
方差同理。
-
momentum参数控制EMA的更新速度,默认0.1(在PyTorch中实现为“累积动量”,实际公式可能略有差异,但概念如此)。
评估模式(model.eval()):
-
不再计算当前批次的统计量。
-
直接使用训练阶段积累的
running_mean和running_var进行归一化,保证推理时的确定性。
momentum的作用:决定了全局统计量更新时,当前批次数据的影响权重。较小的动量值使得全局统计量更新缓慢,更依赖于历史数据;较大的动量值则让最近批次的影响更大。
注意:使用BatchNorm时,训练必须保证batch size足够大(一般>16),否则统计量不准确。
LayerNorm和BatchNorm的主要区别是什么?Transformer为何用LayerNorm?¶
核心区别:
Transformer为何使用LayerNorm?
-
Transformer 处理的序列长度通常可变,且 batch size 有时无法很大(尤其大模型),BatchNorm 在 batch 维度上统计不稳定。
-
LayerNorm 对每个样本单独归一化,保持样本间的独立性,适合自回归模型(需要防止信息泄露)。
-
LayerNorm 更适用于自然语言处理中的特征维度(hidden_size),计算效率高,且已被证明能提升Transformer训练稳定性。
Dropout层在训练和测试时的行为差异是什么?原理上为何能正则化?¶
行为差异:
-
训练时:Dropout 以概率
p随机将输入元素置零,并对幸存元素乘以 11−p1−p1(即缩放),以保持期望值不变。 -
测试时:Dropout 层失效,不丢弃任何元素,也不进行缩放,直接透传。
正则化原理:
-
集成学习视角:每次训练时随机丢弃部分神经元,相当于训练了多个不同的子网络。测试时相当于这些子网络的模型平均,能减少过拟合。
-
减少神经元共适应:Dropout 阻止神经元之间形成复杂的、仅依赖于特定其他神经元的“协同关系”,迫使每个神经元学习到更鲁棒、更独立的特征表示。
-
数据噪声注入:在训练中引入噪声,提升模型泛化能力。
激活函数作为层(如nn.ReLU)和作为函数(F.relu)使用有何区别?¶
-
作为层
nn.ReLU():它是一个nn.Module子类,可以像普通层一样在init中定义,并应用于forward中。它属于模型的一部分,会被注册到模块树中,可被model.to()等影响。 -
作为函数
F.relu():它是一个纯函数,直接对张量进行运算。通常在forward方法中直接调用,不需要事先定义。它不包含可学习参数,不会被模块追踪。
选择建议:
-
如果激活函数有状态(如PReLU有可训练参数),必须用层。
-
对于无参数的激活函数,两者在功能上等价。但使用层方式更符合模块化思想,便于模型图可视化(如
torchsummary)。函数方式写起来更简洁。实际上,nn.ReLU()内部正是调用F.relu()。
性能:无差别。
使用inplace=True的激活函数需要注意什么?¶
inplace=True 表示原地操作,例如 nn.ReLU(inplace=True)。它会直接修改输入张量,节省内存,但可能带来以下问题:
-
覆盖前向激活值:反向传播时,计算梯度需要用到前向的原始输入。原地激活会破坏这些值,导致无法正确计算梯度。PyTorch的autograd机制会自动检测到这种情况,并抛出错误。因此,如果某个张量需要梯度,不能对其使用原地激活,除非该张量是叶子节点或不需梯度。
-
计算图依赖错误:如果同一输入张量被多个路径使用,原地修改会影响其他路径。
-
调试困难:内存共享导致难以追踪值的变化。
安全实践:
-
只在确定张量不需要梯度或不会在之后被用到时,才使用
inplace=True。 -
通常默认不使用原地激活,因为内存节省通常有限,且风险高。除非在大模型推理中显存极其紧张,且已经充分验证。
如何自定义一个激活函数并集成到nn.Module中?¶
通过继承 nn.Module 实现 forward 方法即可。如果需要可学习的参数,也可以在其中定义.
import torch
import torch.nn as nn
class Swish(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
return x * torch.sigmoid(x)
# 使用
swish = Swish()
y = swish(x)
如果是有参数的激活函数,例如PReLU,则需在 init 中定义可训练参数。
注册:由于继承了 nn.Module,这个自定义激活函数可以像其他层一样被 nn.Sequential 或直接赋值给属性,并自动被追踪和移动设备。
交叉熵损失nn.CrossEntropyLoss内部包含了什么操作?输入有什么要求?¶
nn.CrossEntropyLoss 是 PyTorch 中的标准多分类损失,它将 LogSoftmax 和 NLLLoss(负对数似然损失)组合在了一起。具体而言,它先对预测的logits应用 softmax 取对数,然后计算真实标签对应的负对数似然。
输入要求:
-
预测值(input):模型输出的原始logits,形状
(N, C)或(N, C, d1, d2, ...)(用于分割等)。C是类别数。不需要预先经过 softmax 或 log_softmax。 -
目标值(target):形状应与输入兼容,对于标准分类,形状为
(N,),每个值是一个整数类索引,范围[0, C-1]。也可以使用类概率分布(形状(N, C)),但此时内部会采用不同的路径。 -
权重(可选):可以传入每个类别的权重,用于处理样本不平衡。
-
ignore_index(可选):指定某个类别索引在损失计算中被忽略。
内部计算流程:
-
对输入logits应用
log_softmax(dim=1)得到对数概率。 -
取出每个样本对应真实标签位置的对数概率。
-
如果提供了权重,加权求和;否则直接求平均(默认 reduction='mean')。
示例:
loss_fn = nn.CrossEntropyLoss()
logits = torch.randn(10, 5, requires_grad=True) # 10个样本,5类
targets = torch.randint(0, 5, (10,))
loss = loss_fn(logits, targets)
注意:由于内置了softmax,网络最后一层不应加激活,直接输出logits。这有助于数值稳定性。