跳转至

三:神经网络模块 (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.Moduleinitforward 分别负责什么?

  • init:定义模型的静态结构。在这里声明所有子模块(层)、参数和缓冲区。它负责回答“模型由哪些组件构成”。PyTorch 通过它来追踪所有需要梯度的参数和需要保存的状态。

  • forward:定义模型的动态计算逻辑。它接收输入数据,按顺序调用各个子模块,执行前向传播,并返回输出。forward 的调用是隐式的,通过 model(input) 触发,绝不能直接调用 model.forward(x),因为前者会触发框架的钩子(hooks),而后者不会。

核心原则:init 构建“骨架”,forward 注入“灵魂”。


为什么推荐在 init 中定义所有层,而不是在 forward 中直接创建?

这是一个工程最佳实践,而非语法限制。原因如下:

  1. 参数追踪与优化:只有注册在 nn.Module 上的子模块,其参数才会被自动追踪到。如果在 forward 中临时创建 nn.Linear,其参数将不会被 model.parameters() 发现,从而无法被优化器更新,也无法正确保存和加载模型。

  2. 设备一致性:通过 model.to(device) 可以自动将所有注册的子模块参数和缓冲区移动到目标设备。临时创建的层不会被移动,导致计算设备错误。

  3. 可复现性与调试:init 中的定义构成了模型的完整静态图,这使得 print(model) 可以展示完整结构,便于调试和版本管理。如果层是动态创建的,模型结构将不可预测。

  4. 性能:动态创建层会导致不必要的内存分配和初始化开销,尤其在循环中极慢。

结论:模型的结构必须是确定的、可追踪的。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,或任何需要持久化但在训练时不更新的状态。
self.register_buffer('running_mean', torch.zeros(10))

如何获取模型的所有参数?named_parametersparameters 的区别。

  • model.parameters():返回一个生成器,遍历所有可训练参数张量,但不包含参数名称。

  • model.named_parameters():返回一个生成器,每个元素是 (name, param) 元组。名称对应于模块层级路径(例如 'fc1.weight')。这对于选择性冻结、调试和日志记录非常有用。

for name, param in model.named_parameters():
    print(name, param.shape)

重要:这两个方法默认只返回 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:,}")

扩展:还可以使用 torchsummarytorchinfo 库获得更详细的分层统计。


怎样冻结模型中的某一层或几层?

冻结意味着将参数的 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)都有 weightbias 属性(如果 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 函数进行初始化。


如何对模型参数进行自定义初始化?kaimingxavier 初始化有何不同?

通过 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 输出尺寸(高度和宽度)公式为:

image.png

参数说明:

  • 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)。计算:

image.png

空洞卷积在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_meanrunning_var,用于评估模式。更新公式:

image.png

  • 方差同理。

  • momentum 参数控制EMA的更新速度,默认0.1(在PyTorch中实现为“累积动量”,实际公式可能略有差异,但概念如此)。

评估模式(model.eval()):

  • 不再计算当前批次的统计量。

  • 直接使用训练阶段积累的 running_meanrunning_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)。它会直接修改输入张量,节省内存,但可能带来以下问题:

  1. 覆盖前向激活值:反向传播时,计算梯度需要用到前向的原始输入。原地激活会破坏这些值,导致无法正确计算梯度。PyTorch的autograd机制会自动检测到这种情况,并抛出错误。因此,如果某个张量需要梯度,不能对其使用原地激活,除非该张量是叶子节点或不需梯度。

  2. 计算图依赖错误:如果同一输入张量被多个路径使用,原地修改会影响其他路径。

  3. 调试困难:内存共享导致难以追踪值的变化。

安全实践:

  • 只在确定张量不需要梯度或不会在之后被用到时,才使用 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 中的标准多分类损失,它将 LogSoftmaxNLLLoss(负对数似然损失)组合在了一起。具体而言,它先对预测的logits应用 softmax 取对数,然后计算真实标签对应的负对数似然。

输入要求:

  • 预测值(input):模型输出的原始logits,形状 (N, C)(N, C, d1, d2, ...)(用于分割等)。C 是类别数。不需要预先经过 softmax 或 log_softmax。

  • 目标值(target):形状应与输入兼容,对于标准分类,形状为 (N,),每个值是一个整数类索引,范围 [0, C-1]。也可以使用类概率分布(形状 (N, C)),但此时内部会采用不同的路径。

  • 权重(可选):可以传入每个类别的权重,用于处理样本不平衡。

  • ignore_index(可选):指定某个类别索引在损失计算中被忽略。

内部计算流程:

  1. 对输入logits应用 log_softmax(dim=1) 得到对数概率。

  2. 取出每个样本对应真实标签位置的对数概率。

  3. 如果提供了权重,加权求和;否则直接求平均(默认 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。这有助于数值稳定性。