PyTorch 深度学习专题【左扬精讲】—— torch.nn 模块:神经网络构建的核心框架

PyTorch 深度学习专题【左扬精讲】—— torch.nn 模块:神经网络构建的核心框架

在深度学习的世界里,PyTorch 已成为最受欢迎的框架之一,其灵活的动态计算图和直观的 API 设计让无数研究者爱不释手。而 torch.nn 模块,正是 PyTorch 中用于构建神经网络的 核心基础设施——从最基础的线性层到复杂的注意力机制,从损失函数到优化器接口,一切神经网络相关的能力都沉淀在这个模块中。

本文将系统性地剖析 torch.nn 模块的设计哲学、核心组件和使用要点。通过 What-How-Why 三段式讲解,让你彻底理解这个模块解决了什么问题、如何解决,以及为什么这样设计。

本文涉及的核心模块与概念:
├── torch.nn                    ← 神经网络构建核心模块
├── torch.nn.Module             ← 所有网络层的基类
├── torch.nn.Parameter          ← 可学习参数封装
├── torch.nn.Container
│   ├── nn.Sequential           ← 顺序容器
│   ├── nn.ModuleList           ← 动态模块列表
│   └── nn.ModuleDict           ← 动态模块字典
├── torch.nn.Layers
│   ├── nn.Linear               ← 全连接层
│   ├── nn.Conv1d/2d/3d        ← 卷积层
│   ├── nn.LSTM/GRU            ← 循环层
│   └── nn.Embedding            ← 嵌入层
├── torch.nn.Activation          ← 激活函数
├── torch.nn.Loss               ← 损失函数
└── 正则化与归一化
    ├── nn.Dropout              ← Dropout
    ├── nn.BatchNorm            ← 批归一化
    └── nn.LayerNorm            ← 层归一化

学习重点:本文聚焦于 PyTorch 2.x 的 torch.nn 模块核心概念。通过本文,你将理解:(1) nn.Module 的设计哲学与参数管理机制;(2) 常见网络层的原理与适用场景;(3) 模型构建、训练与部署的最佳实践。

PyTorch 2.x torch.nn 神经网络 nn.Module 深度学习

一、torch.nn 模块概述

1.1 What — torch.nn 是什么?

torch.nn 是 PyTorch 官方提供的神经网络构建模块,它将深度学习中常用的网络组件封装为可复用的 Python 类。从功能上看,这个模块提供了三大类能力:

  • 网络层(Layers):卷积层、池化层、全连接层、循环层等,它们是神经网络的基本 building block
  • 损失函数(Loss Functions):MSELoss、CrossEntropyLoss 等,用于衡量模型预测与真实标签之间的差异
  • 容器与工具:nn.Module 基类、nn.Sequential 顺序容器、参数初始化工具等

在 PyTorch 的模块层级中,torch.nn 位于 torch 的核心层级之下,专门负责神经网络模型的定义与组织。而 torch.nn.functional(常写作 F)则提供了这些组件的函数式接口,用于更细粒度的操作。

1.2 Why — 为什么需要 torch.nn?

为什么需要专门的神经网络构建模块?

深度学习模型的构建涉及大量重复性工作:参数的声明与管理、计算图的组织与构建、设备迁移(CPU/GPU)、序列化与反序列化等。如果没有统一的抽象层,开发者需要为每个模型重复编写这些基础设施代码,既繁琐又容易出错。

没有 torch.nn 会发生什么?

  • 每次定义新模型都要手动管理 tensor 的创建、参数注册、梯度追踪
  • 模型参数的设备迁移需要在每个 tensor 上单独调用 .to(device)
  • 模型的序列化(保存/加载)需要开发者自己处理参数的映射关系
  • 不同模型之间的代码复用性极差,每个项目都要"从零开始"

torch.nn 模块通过 nn.Module 基类,将这些通用能力抽象和封装,让开发者可以专注于模型本身的逻辑设计,而不用操心底层工程细节。

1.3 How — torch.nn 是如何设计的?

torch.nn 的设计哲学:组合优于继承,接口优于实现

nn.Module 是整个模块的核心基类,它采用组合模式——每个 Module 可以包含其他 Module 作为子模块。这种设计天然支持嵌套结构,让复杂网络的构建变得模块化、可组合。

torch.nn 的核心设计原则:

  1. 参数自动注册:通过 nn.Parameter 封装的参数会自动注册到模块的 parameters() 列表中
  2. 递归遍历:所有子模块的参数会被递归收集,支持任意嵌套结构
  3. 计算图整合:子模块的前向计算自动纳入整体计算图,支持自动求导
  4. 设备统一管理:通过 .to(device) 可以一次性迁移整个模型及其所有参数
import torch
import torch.nn as nn

# 示例:nn.Module 的组合设计
class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()  # 调用父类构造函数
        # 组合:线性层 + 激活函数作为子模块
        self.linear = nn.Linear(10, 5)   # 输入10维,输出5维
        self.relu = nn.ReLU()             # ReLU 激活

    def forward(self, x):
        # 前向传播:组合子模块的计算
        x = self.linear(x)
        x = self.relu(x)
        return x

# 创建模型实例
model = SimpleNet()
print(model)
# 输出:SimpleNet(
#   (linear): Linear(in_features=10, out_features=5, bias=True)
#   (relu): ReLU()
# )
图解 nn.Linear(10, 5):全连接层的维度变换原理

很多初学者对 nn.Linear(10, 5) 的两个参数感到困惑。本节用图解 + 数学 + 具象类比,帮你彻底吃透全连接层的维度变换机制。

一、一句话通俗解释

nn.Linear(10, 5) = 全连接层,它的工作本质是:

  • 第一个参数 10:告诉网络"你的输入每个样本有 10 个数(10维特征)
  • 第二个参数 5:告诉网络"处理完后,每个样本我要输出 5 个数(5维特征)"

👉 核心变换:原本每个样本是 10 维向量,经过全连接层,变成 5 维向量。这就是"10维→5维"的含义。

二、数学角度拆解:全连接层公式 y = xWᵀ + b

全连接层的本质是一次矩阵乘法加一次偏置加法:

┌─────────────────────────────────────────────────────────────────┐
│                        y = x · Wᵀ + b                           │
│                                                                 │
│   输入 x          权重 W          运算          输出 y           │
│  [batch, 10]  ×  [5, 10]     =  [batch, 5]   [batch, 5]       │
│                           +  [5]                               │
└─────────────────────────────────────────────────────────────────┘

逐项拆解:

  • x:输入张量,形状 [batch_size, 10]。batch_size 是每批多少个样本,10 是每个样本多少个特征值
  • W:权重矩阵,形状 [5, 10]。这是网络要学习的参数,第一维 5 匹配输出维度,第二维 10 匹配输入维度
  • b:偏置向量,形状 [5]。同样是可学习参数,对输出每个维度加一个偏移
  • y:输出张量,形状 [batch_size, 5]。每条样本从 10 维变成了 5 维

三、具象类比:10维→5维到底是什么感觉?

想象一下:

  • 一个学生有 10 门课的成绩(语文、数学、英语 ... 共10个数字)
  • 全连接层把 10 个分数综合成 5 个指标(逻辑思维分、表达能力分 ... 共5个数字)
  • 这 5 个指标不是简单丢弃了 5 门课,而是用加权组合的方式,把 10 门课的信息压缩进 5 个综合维度里

所以:维度变换 ≠ 信息丢失,而是信息的重组与提炼。

四、结合代码走一遍完整数据流

import torch
import torch.nn as nn

model = SimpleNet()

# 造一批数据:2个样本,每个样本10维
x = torch.randn(2, 10)  # shape: [2, 10]
print(f"输入 x 的形状: {x.shape}")  # torch.Size([2, 10])
#      ↑ batch=2(2条数据),每条10个特征

out = model(x)
print(f"输出 out 的形状: {out.shape}")  # torch.Size([2, 5])
#      ↑ 同样是2条数据,但每条变成了5个特征

# 打印模型结构,直观看维度
print(model)
# SimpleNet(
#   (linear): Linear(in_features=10, out_features=5, bias=True)
#   (relu): ReLU()
# )
#         ↑ in=10 表示"我期待每个样本给我10个数"
#         ↑ out=5 表示"处理完后我要输出5个数"

数据流的每一步:

Step 1: x = torch.randn(2, 10)
        ┌────────────────────────────────────────┐
        │  2条数据,每条10维 → shape = [2, 10]   │
        └────────────────────────────────────────┘

Step 2: self.linear(x)     # 线性变换
        ┌────────────────────────────────────────┐
        │  [2, 10] × [5, 10]ᵀ + [5]            │
        │  = [2, 5]                             │
        │  10维特征 → 5维特征                    │
        └────────────────────────────────────────┘

Step 3: self.relu(x)       # 激活函数(不变维度)
        ┌────────────────────────────────────────┐
        │  [2, 5] → [2, 5]  (维度不变!)     │
        │  ReLU 负责把负数变成0,不改形状         │
        └────────────────────────────────────────┘

Step 4: return x
        ┌────────────────────────────────────────┐
        │  最终输出:2条样本,每条5维             │
        └────────────────────────────────────────┘

五、为什么要从10维映射到5维?三个核心原因

原因1:特征压缩与降维

原始 10 维特征可能有冗余或噪声。通过映射到更低维度,网络被迫提取最关键的信息,丢弃无关细节。

原因2:可学习的特征组合

这不是简单的"删掉5个数字",而是用权重矩阵做线性组合:每个输出维度都是 10 个输入维度的加权和(加偏置)。这意味着:

  • 输出维度1 ≈ a₁×输入₁ + a₂×输入₂ + ... + a₁₀×输入₁₀
  • 输出维度2 ≈ b₁×输入₁ + b₂×输入₂ + ... + b₁₀×输入₁₀
  • ...以此类推

这些权重 (a₁, a₂...a₁₀), (b₁, b₂...b₁₀) 等都是通过反向传播自动学习的。

原因3:适配下游任务与计算效率

最终分类/回归任务可能只需要少量特征。5 维输出既保留了关键信息,又减少了后续层的参数量和计算量。

六、彻底厘清:最容易混淆的四个概念

混淆点1:维度 vs 批次

"10维"指的是单个样本的特征数量,和 batch_size 完全无关。batch=32、batch=128 都可以,只要每个样本是 10 维就行:

x1 = torch.randn(1, 10)    # 1个样本,10维 → shape [1, 10]
x32 = torch.randn(32, 10)  # 32个样本,每个10维 → shape [32, 10]
x1000 = torch.randn(1000, 10)  # 1000个样本,每个10维 → shape [1000, 10]
# 三种情况都合法,Linear(10, 5) 都输出 [N, 5]

混淆点2:哪些操作会改变维度,哪些不会?

操作是否改变维度说明
nn.Linear ✅ 改变 通过权重矩阵投影改变维度
nn.Conv2d ✅ 改变(取决于 stride/padding) 卷积核滑动改变 H×W
nn.ReLU / nn.Sigmoid ❌ 不改变 逐元素操作,只改数值大小
nn.MaxPool / nn.AvgPool ✅ 改变(H×W维度) 空间下采样
nn.Flatten ✅ 改变(多维→一维) 展平操作
view / reshape ✅ 改变(形状重塑) 重排维度,不改数据内容

混淆点3:Linear 的 bias 可以不要吗?

可以!在 nn.Linear(10, 5, bias=False) 中设置 bias=False,偏置向量就不会被创建。这常见于:

  • 对标已有预训练权重(如 VGG 的某些层)
  • 确信偏置不是必需的场景

混淆点4:10维输入,输出一定是5维吗?

是的,输出维度严格由第二个参数决定,和输入数据内容无关:

linear = nn.Linear(10, 5)

x_1000d = torch.randn(2, 1000)  # 1000维输入 ❌ 会报错!
x_10d = torch.randn(2, 10)      # 10维输入 ✅
x_5d = torch.randn(2, 5)        # 5维输入 ❌ 会报错!

# Linear(10,5) 要求输入必须是 [*, 10],即最后一个维度必须是10
# 输出一定是 [*, 5],即最后一个维度一定是5

七、实战延伸:不同维度的组合场景

# 常见的维度变换链
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        # 784维(28x28图像)→ 256维 → 128维 → 10维(分类)
        self.net = nn.Sequential(
            nn.Linear(784, 256),  # 降维:784→256
            nn.ReLU(),
            nn.Linear(256, 128),  # 降维:256→128
            nn.ReLU(),
            nn.Linear(128, 10)    # 分类头:128→10
        )

    def forward(self, x):
        return self.net(x)

# 验证维度变换
model = MLP()
x = torch.randn(4, 784)  # 4个样本,784维特征
out = model(x)
print(out.shape)  # torch.Size([4, 10])
# 维度流: [4, 784] → [4, 256] → [4, 128] → [4, 10]

本节要点:

  • torch.nn 是 PyTorch 神经网络的构建核心,提供层、损失函数、容器等基础设施
  • 通过 nn.Module 基类实现参数管理、设备迁移、序列化等通用能力
  • 采用组合模式,支持嵌套结构,让复杂网络可模块化构建

二、nn.Module:神经网络的核心基类

2.1 What — nn.Module 是什么?

nn.Module 是 PyTorch 中所有神经网络模型的基类,它是整个 torch.nn 模块的核心设计。任何自定义的神经网络模型,都应该继承自 nn.Module。

从设计角度看,nn.Module 承担了五大核心职责:

职责具体能力解决的问题
结构化与模块化 支持嵌套子模块,递归遍历 复杂网络的组织与复用
参数管理 自动注册、收集参数 参数的统一管理与优化器绑定
设备管理 .to(device) 统一迁移 CPU/GPU 迁移的便捷操作
前向传播 统一的 forward() 接口 计算逻辑与调用方式的标准化
序列化 state_dict() / load_state_dict() 模型保存与加载

2.2 Why — 为什么需要 nn.Module?

nn.Module 的出现解决了什么问题?

在深度学习实践中,模型的复杂度不断提升:从早期的几层全连接网络,到如今动辄上百层的 Transformer。管理如此复杂的网络结构,如果没有统一的抽象层,工程难度将指数级上升。

没有 nn.Module 会发生什么?

  • 手动维护 tensor 列表来存储参数:self.params = [W1, b1, W2, b2, ...]
  • 手动遍历参数列表进行梯度清零:for p in self.params: p.grad = None
  • 手动递归收集嵌套结构的参数:需要自己实现递归遍历逻辑
  • 模型保存时手动序列化每个参数:需要自己管理参数名和顺序

nn.Module 通过自动化的机制,将这些重复性的"脏活累活"封装起来,让研究者可以专注于模型本身的创新。

2.3 How — nn.Module 的核心机制

2.3.1 参数注册机制

当你将一个 nn.Module 实例赋值给类属性时(如 self.linear = nn.Linear(...)),nn.Module 会自动将其注册为子模块,并递归收集其参数。

import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        # 当赋值给 self.xxx 时,自动注册为子模块
        self.layer1 = nn.Linear(784, 256)  # 注册:layer1.weight, layer1.bias
        self.layer2 = nn.Linear(256, 10)   # 注册:layer2.weight, layer2.bias
        self.relu = nn.ReLU()              # 注册:无参数(如 ReLU)

    def forward(self, x):
        x = self.layer1(x)
        x = self.relu(x)
        x = self.layer2(x)
        return x

model = MLP()

# parameters() 会递归收集所有可学习参数
for name, param in model.named_parameters():
    print(f"{name}: {param.shape}")

# 输出:
# layer1.weight: torch.Size([256, 784])
# layer1.bias: torch.Size([256])
# layer2.weight: torch.Size([10, 256])
# layer2.bias: torch.Size([10])
我理解参数注册的机制是

机制一:属性赋值自动注册

当你执行 self.layer = nn.Linear(...) 时,nn.Module.__setattr__ 会拦截这个赋值操作。如果检测到赋值为 nn.Module 或 nn.Parameter 实例,会自动将其加入内部管理结构(_modules 或 _parameters 字典)。

机制二:parameters() 的递归收集

parameters() 方法会递归遍历 _modules 字典,收集每个子模块的参数。参数名通过层级拼接形成,如 "block1.conv.weight"。

机制总结:2 个关键字典 + 1 个递归遍历

  • 2 个关键字典:_parameters(直接参数)+ _modules(子模块)
  • 1 个递归遍历:named_parameters() 深度优先遍历所有子模块

避坑提醒:

  • 不要用普通 Python list 装 Module:self.layers = [nn.Linear(...), nn.Linear(...)] 不会自动注册,应该用 nn.ModuleList
  • 注册顺序影响参数遍历顺序:同一层级按定义顺序遍历

2.3.2 forward 方法与计算图

forward() 方法是 nn.Module 的核心抽象接口,它定义了模型的前向计算逻辑。当你调用 model(x) 时,PyTorch 会自动调用 model.forward(x)。

import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
        self.bn = nn.BatchNorm2d(16)
        self.relu = nn.ReLU()
        self.fc = nn.Linear(16 * 32 * 32, 10)

    # forward 方法定义了前向传播的计算逻辑
    def forward(self, x):
        x = self.conv(x)      # 卷积
        x = self.bn(x)        # 批归一化
        x = self.relu(x)      # 激活
        x = x.view(x.size(0), -1)  # flatten
        x = self.fc(x)        # 全连接
        return x

# 创建模型并执行前向传播
model = Net()
x = torch.randn(1, 3, 32, 32)  # batch=1, channel=3, 32x32
output = model(x)  # 等价于 model.forward(x)

# 自动构建计算图,支持反向传播
print(f"Output shape: {output.shape}")  # torch.Size([1, 10])

注意:不要直接调用 model.forward(x),而应该使用 model(x)。前者不会触发 __call__ 中的钩子函数(hooks)和异常处理机制。

2.3.3 实例化模型与访问参数

import torch
import torch.nn as nn

# 定义一个简单的卷积网络
class ConvNet(nn.Module):
    def __init__(self):
        super().__init__()
        # 卷积层:输入3通道,输出16通道,卷积核3x3
        self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
        self.bn1 = nn.BatchNorm2d(16)
        self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
        self.bn2 = nn.BatchNorm2d(32)
        self.fc = nn.Linear(32 * 8 * 8, 10)
        self.relu = nn.ReLU()
        self.pool = nn.MaxPool2d(2, 2)

    def forward(self, x):
        x = self.pool(self.relu(self.bn1(self.conv1(x))))
        x = self.pool(self.relu(self.bn2(self.conv2(x))))
        x = x.view(x.size(0), -1)  # 展平
        x = self.fc(x)
        return x

model = ConvNet()

# ========== 访问参数的方法 ==========
# 1. 获取所有参数(tensor 列表)
params = list(model.parameters())
print(f"Total parameters: {len(params)}")

# 2. 获取带名称的参数(推荐)
for name, param in model.named_parameters():
    print(f"{name}: {param.shape}, requires_grad={param.requires_grad}")

# 3. 访问特定层的参数
conv1_weight = model.conv1.weight  # 等价于 model.state_dict()['conv1.weight']
print(f"Conv1 weight shape: {conv1_weight.shape}")

# 4. 访问 buffer(非梯度参数,如 BatchNorm 的 running_mean)
for name, buf in model.named_buffers():
    print(f"Buffer {name}: {buf.shape}")

2.3.4 设备管理:.to 方法

.to(device) 方法是 nn.Module 提供的统一设备迁移接口,它会递归地将模型的所有参数和缓冲区迁移到指定设备。

import torch
import torch.nn as nn

model = ConvNet()

# ========== 设备管理 ==========
# 检查可用设备
print(f"CUDA available: {torch.cuda.is_available()}")
print(f"CUDA device count: {torch.cuda.device_count()}")

# 迁移到 CPU(默认)
model_cpu = model.to('cpu')

# 迁移到 CUDA GPU(如果有)
if torch.cuda.is_available():
    device = torch.device('cuda:0')
    model_gpu = model.to(device)
    print(f"Model is now on: {next(model_gpu.parameters()).device}")

    # 或者更简洁的方式
    model = model.to('cuda')
    print(f"Model device: {next(model.parameters()).device}")

# 在 GPU 上创建输入,传入模型
x = torch.randn(1, 3, 32, 32).to('cuda')
output = model(x)
print(f"Output on: {output.device}")

小贴士:设备迁移是递归的,这意味着你只需要调用一次 model.to(device),模型的所有参数、缓冲区和子模块都会自动迁移。但注意,输入数据也需要手动迁移到同一设备:x = x.to(device)。

2.3.5 模型模式:train() 和 eval()

train() 和 eval() 方法用于切换模型的运行模式,它们主要影响以下行为:

  • Dropout:train 模式启用 Dropout(随机丢弃神经元),eval 模式关闭
  • BatchNorm:train 模式使用 batch 统计量更新 running stats,eval 模式使用固定的统计量
import torch
import torch.nn as nn

class NetWithDropout(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(100, 50)
        self.dropout = nn.Dropout(p=0.5)  # 50% 概率丢弃
        self.fc2 = nn.Linear(50, 10)
        self.bn = nn.BatchNorm1d(50)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = self.bn(x)
        x = self.dropout(x)  # 训练时丢弃,推理时不丢弃
        x = self.fc2(x)
        return x

model = NetWithDropout()

# ========== 训练模式 ==========
model.train()  # 切换到训练模式
print(f"Training mode: dropout={model.dropout.training}")  # True

# ========== 推理模式 ==========
model.eval()   # 切换到评估模式
print(f"Eval mode: dropout={model.dropout.training}")  # False

# eval 模式下,with torch.no_grad() 可以进一步禁用梯度计算
with torch.no_grad():
    output = model(torch.randn(1, 100))
    print(f"Output (inference): {output}")

本节要点:

  • nn.Module 是所有神经网络的基类,提供五大核心能力:结构化、参数管理、设备管理、前向传播、序列化
  • forward() 方法定义计算逻辑,通过 model(x) 调用
  • .to(device) 统一迁移模型到指定设备
  • train()/eval() 切换模式,影响 Dropout 和 BatchNorm 的行为

三、nn.Module 的子类:常用容器与容器类

3.1 nn.Sequential — 顺序容器

nn.Sequential 是最常用的容器类,它按照传入的顺序依次执行各个子模块。它的本质是:将多个层组合成一个"流水线",数据从第一层流入,经过层层处理,从最后一层流出。

import torch
import torch.nn as nn

# 方式一:直接传入层序列(按索引访问)
model = nn.Sequential(
    nn.Conv2d(3, 16, kernel_size=3, padding=1),  # 卷积层
    nn.BatchNorm2d(16),                            # 批归一化
    nn.ReLU(),                                     # 激活函数
    nn.MaxPool2d(2, 2),                            # 池化层
    nn.Conv2d(16, 32, kernel_size=3, padding=1),
    nn.BatchNorm2d(32),
    nn.ReLU(),
    nn.AdaptiveAvgPool2d((1, 1)),                  # 自适应池化
    nn.Flatten(),
    nn.Linear(32, 10)
)

# 按索引访问层:model[0] 是第一个卷积层
print(model[0])  # Conv2d(3, 16, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
print(model[0].weight.shape)  # 访问第一个卷积层的权重

# 方式二:使用 OrderedDict 给层命名(按名称访问)
from collections import OrderedDict
model = nn.Sequential(OrderedDict([
    ('conv1', nn.Conv2d(3, 16, 3, padding=1)),
    ('bn1', nn.BatchNorm2d(16)),
    ('relu1', nn.ReLU()),
    ('pool', nn.MaxPool2d(2, 2)),
    ('conv2', nn.Conv2d(16, 32, 3, padding=1)),
    ('bn2', nn.BatchNorm2d(32)),
    ('relu2', nn.ReLU()),
    ('gap', nn.AdaptiveAvgPool2d((1, 1))),
    ('flatten', nn.Flatten()),
    ('fc', nn.Linear(32, 10))
]))

# 前向传播
x = torch.randn(1, 3, 32, 32)
output = model(x)
print(f"Output shape: {output.shape}")  # torch.Size([1, 10])

# 通过名称访问层(仅 OrderedDict 方式支持)
print(model.conv1)       # 访问 conv1 层
print(model.bn1.weight)  # 访问 bn1 的 weight 参数

3.2 nn.ModuleList 和 nn.ModuleDict

当需要动态组织多个层时,nn.ModuleList 和 nn.ModuleDict 提供了更灵活的组织方式。

import torch
import torch.nn as nn

# ========== nn.ModuleList:模块列表,通过索引访问 ==========
class DynamicMLP(nn.Module):
    def __init__(self, num_layers, input_dim, hidden_dim, output_dim):
        super().__init__()
        # 创建多个隐藏层
        self.layers = nn.ModuleList([
            nn.Linear(input_dim if i == 0 else hidden_dim, hidden_dim)
            for i in range(num_layers)
        ])
        self.output_layer = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        for layer in self.layers:
            x = torch.relu(layer(x))
        return self.output_layer(x)

model = DynamicMLP(num_layers=5, input_dim=784, hidden_dim=256, output_dim=10)

# ========== nn.ModuleDict:模块字典,通过键访问 ==========
class FlexibleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoders = nn.ModuleDict({
            'cnn': nn.Sequential(
                nn.Conv2d(3, 64, 3, padding=1),
                nn.ReLU()
            ),
            'mlp': nn.Sequential(
                nn.Linear(784, 256),
                nn.ReLU()
            )
        })
        self.classifier = nn.Linear(64 + 256, 10)

    def forward(self, x, mode='cnn'):
        if mode == 'cnn':
            x = self.encoders['cnn'](x)
        elif mode == 'mlp':
            x = x.view(x.size(0), -1)
            x = self.encoders['mlp'](x)
        else:
            # 双路编码
            cnn_feat = self.encoders['cnn'](x)
            mlp_feat = x.view(x.size(0), -1)
            mlp_feat = self.encoders['mlp'](mlp_feat)
            x = torch.cat([cnn_feat.flatten(1), mlp_feat], dim=1)
        return self.classifier(x)

注意:必须使用 nn.ModuleList 和 nn.ModuleDict,而不是 Python 原生的 list 和 dict。只有前者才会正确注册子模块,后者的内容不会被 PyTorch 识别为模型参数。

3.3 自定义容器

你也可以继承 nn.Module 创建自定义容器,以适应特殊的模型结构需求。

import torch
import torch.nn as nn

# ========== 自定义残差容器 ==========
class ResidualBlock(nn.Module):
    """残差块:输出 = f(x) + x"""
    def __init__(self, channels):
        super().__init__()
        self.conv = nn.Conv2d(channels, channels, 3, padding=1)
        self.bn = nn.BatchNorm2d(channels)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        residual = x  # 保存输入(恒等映射)
        out = self.conv(x)
        out = self.bn(out)
        out = out + residual  # 残差连接
        out = self.relu(out)
        return out

# ========== 自定义多分支容器 ==========
class MultiBranchNet(nn.Module):
    """多分支网络"""
    def __init__(self, in_channels):
        super().__init__()
        # 三个不同深度的分支
        self.branch1 = nn.Sequential(
            nn.Conv2d(in_channels, 32, 1),
            nn.ReLU()
        )
        self.branch2 = nn.Sequential(
            nn.Conv2d(in_channels, 32, 1),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.ReLU()
        )
        self.branch3 = nn.Sequential(
            nn.Conv2d(in_channels, 32, 1),
            nn.Conv2d(32, 32, 5, padding=2),
            nn.ReLU()
        )
        self.fusion = nn.Conv2d(96, 64, 1)  # 融合三个分支

    def forward(self, x):
        b1 = self.branch1(x)
        b2 = self.branch2(x)
        b3 = self.branch3(x)
        # 通道维度拼接
        fused = torch.cat([b1, b2, b3], dim=1)
        return self.fusion(fused)

本节要点:

  • nn.Sequential:顺序容器,适合线性堆叠的层
  • nn.ModuleList:动态列表,适合层数需要程序控制的场景
  • nn.ModuleDict:动态字典,适合需要按名称选择层的场景
  • 可以继承 nn.Module 创建自定义容器

四、torch.nn 中的 Layers(网络层)

4.1 What — 什么是 Layers?

在 PyTorch 中,Layer(层)是神经网络的基本构建单元,每个层封装了一组参数和一个数学变换。从数据流的角度看:层接收输入张量,经过数学运算,输出变换后的张量。

4.2 Why — 为什么要封装成 Layer?

Layer 封装解决了什么问题?

神经网络的核心是数学运算的组合。如果每次构建网络都要从头编写矩阵乘法、激活函数、梯度计算等代码,不仅效率低下,还容易出错。将这些运算封装成标准化的"层",可以实现:

  • 代码复用:一次定义,到处使用
  • 语义明确:层名即功能,见名知意
  • 参数管理:层的参数自动注册,便于优化器绑定
  • 模块化设计:复杂网络由简单层组合而成

4.3 核心 Layers 一览

类别层名称一句话说明典型应用场景
全连接层 nn.Linear 输入与权重的矩阵乘加变换 MLP、分类器的最后一层
nn.Bilinear 双线性变换:y = x1·W·x2 + b 推荐系统、注意力机制
卷积层 nn.Conv1d 一维卷积,适合序列数据 文本分类、语音处理
nn.Conv2d 二维卷积,适合图像 图像分类、目标检测
nn.Conv3d 三维卷积,适合视频/体数据 视频理解、3D 医学影像
nn.ConvTranspose2d 转置卷积(上采样) 语义分割、生成对抗网络
循环层 nn.RNN 基础循环神经网络 简单序列建模(已较少使用)
nn.LSTM 长短期记忆网络 机器翻译、文本生成
nn.GRU 门控循环单元 语音识别、时间序列
nn.RNNCell RNN 单步计算单元 自定义 RNN 逻辑
嵌入层 nn.Embedding 离散ID映射为稠密向量 NLP、推荐系统
nn.EmbeddingBag 嵌入的加权求和/平均 TextCNN、句子嵌入
池化层 nn.MaxPool2d 最大值池化,下采样 几乎所有 CNN
nn.AdaptiveAvgPool2d 自适应池化,输出固定尺寸 特征提取网络的输出对齐

4.4 常见层详解

4.4.1 nn.Linear — 全连接层

nn.Linear 实现:y = x·W^T + b,即线性变换(也称仿射变换)。它是神经网络中最基础的层,几乎所有复杂网络都会用到它。

import torch
import torch.nn as nn

# 创建一个全连接层:输入特征10维,输出特征5维
linear = nn.Linear(in_features=10, out_features=5, bias=True)

# 输入:张量 (batch_size, 10)
x = torch.randn(32, 10)  # batch=32, 10维输入
output = linear(x)

print(f"Input shape: {x.shape}")       # torch.Size([32, 10])
print(f"Output shape: {output.shape}")  # torch.Size([32, 5])

# 查看层参数
print(f"Weight shape: {linear.weight.shape}")  # torch.Size([5, 10])
print(f"Bias shape: {linear.bias.shape}")       # torch.Size([5])

4.4.2 nn.Conv2d — 二维卷积层

nn.Conv2d 是卷积神经网络(CNN)的核心,用于提取图像的局部空间特征。它通过滑动窗口的方式,将卷积核与输入进行局部连接,实现参数共享和局部感受野。

import torch
import torch.nn as nn

# 创建卷积层
# 输入:3通道(如RGB),输出:16通道,卷积核3x3
conv = nn.Conv2d(
    in_channels=3,      # 输入通道数
    out_channels=16,    # 输出通道数(卷积核数量)
    kernel_size=3,      # 卷积核尺寸
    stride=1,          # 步长(默认1)
    padding=1,         # 填充(保持尺寸可设为 kernel_size//2)
    bias=True          # 是否使用偏置
)

# 输入:张量 (batch, channels, height, width)
x = torch.randn(1, 3, 32, 32)  # batch=1, 3通道, 32x32图像
output = conv(x)

print(f"Input shape: {x.shape}")       # torch.Size([1, 3, 32, 32])
print(f"Output shape: {output.shape}")  # torch.Size([1, 16, 32, 32])
print(f"卷积核数量: {conv.out_channels}")  # 16
print(f"卷积核尺寸: {conv.kernel_size}")   # (3, 3)

卷积层参数详解:

  • in_channels:输入通道数,RGB图像为3,灰度图为1
  • out_channels:输出通道数,即卷积核的数量,决定提取多少种特征
  • kernel_size:卷积核尺寸,常用3x3、5x5、7x7
  • stride:滑动步长,>1 时会减小输出尺寸(下采样)
  • padding:边缘填充,控制输出尺寸
  • groups:分组卷积,控制输入输出通道的连接方式

4.4.3 nn.LSTM — 长短期记忆网络

nn.LSTM 是处理序列数据的核心组件,通过门控机制解决传统 RNN 的梯度消失问题,能够学习长距离依赖关系。

import torch
import torch.nn as nn

# 创建 LSTM 层
lstm = nn.LSTM(
    input_size=256,      # 输入特征维度
    hidden_size=512,     # 隐藏状态维度
    num_layers=2,        # LSTM 层数
    batch_first=True,    # True: (batch, seq, feature),否则 (seq, batch, feature)
    dropout=0.1,         # 层间 Dropout
    bidirectional=True   # 双向 LSTM
)

# 输入序列
batch_size = 4
seq_len = 10
input_size = 256

# (batch, seq_len, input_size)
x = torch.randn(batch_size, seq_len, input_size)

# 前向传播
# output: (batch, seq_len, hidden_size * directions)
# h_n: (num_layers * directions, batch, hidden_size)
# c_n: (num_layers * directions, batch, hidden_size)
output, (h_n, c_n) = lstm(x)

print(f"Input shape: {x.shape}")        # torch.Size([4, 10, 256])
print(f"Output shape: {output.shape}")  # torch.Size([4, 10, 1024]) 1024=512*2(bidirectional)
print(f"Hidden shape: {h_n.shape}")     # torch.Size([4, 4, 512])  4=2*2(num_layers*bidirectional), 4=batch, 512=hidden
print(f"Cell shape: {c_n.shape}")      # torch.Size([4, 4, 512])  同 h_n

4.4.4 nn.Embedding — 嵌入层

nn.Embedding 将离散的整数索引(如词表中的单词ID)映射为连续的稠密向量,是 NLP 任务的基础组件。

import torch
import torch.nn as nn

# 创建嵌入层:词表大小10000,嵌入维度300
embedding = nn.Embedding(
    num_embeddings=10000,  # 词表大小(索引范围:0~9999)
    embedding_dim=300,     # 嵌入向量维度
    padding_idx=0           # 填充索引(可选,该索引的向量全零)
)

# 输入:整数索引序列 (batch, seq_len)
# 常见场景:句子中每个词的ID
batch_size = 8
seq_len = 20
x = torch.randint(0, 10000, (batch_size, seq_len))  # 随机生成词索引

output = embedding(x)

print(f"Input shape: {x.shape}")        # torch.Size([8, 20])
print(f"Output shape: {output.shape}")  # torch.Size([8, 20, 300])

# 获取单个词的嵌入
word_id = torch.tensor([42])  # 单词ID
word_vec = embedding(word_id)  # torch.Size([1, 300])
print(f"Single word embedding: {word_vec.shape}")

4.5 How — 如何选择合适的 Layer?

层选择的实战指导

场景一:图像分类任务

典型结构:Conv2d + BatchNorm2d + ReLU + MaxPool2d 循环堆叠,最后用 AdaptiveAvgPool2d + Linear 输出分类结果。

# 图像分类网络结构示意
class ImageNet(nn.Module):
    def __init__(self, num_classes=1000):
        super().__init__()
        self.features = nn.Sequential(
            # Block 1: 224 -> 112
            nn.Conv2d(3, 64, 7, stride=2, padding=3),
            nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(3, stride=2, padding=1),
            # Block 2-4: 继续堆叠...
        )
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.classifier = nn.Linear(2048, num_classes)

场景二:NLP 文本分类

典型结构:Embedding + LSTM/Transformer + Linear。嵌入层将词ID转为向量,LSTM/Transformer 编码序列,最后 Linear 输出分类。

# 文本分类网络
class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True)
        self.fc = nn.Linear(hidden_dim * 2, num_classes)  # *2 因为双向

    def forward(self, text):
        # text: (batch, seq_len)
        embedded = self.embedding(text)  # (batch, seq_len, embed_dim)
        _, (hidden, _) = self.lstm(embedded)
        # 双向 LSTM 的最后隐藏状态拼接
        hidden = torch.cat([hidden[-2], hidden[-1]], dim=1)
        return self.fc(hidden)

场景三:推荐系统 / 特征交叉

使用 Embedding 将用户/物品ID映射为向量,通过 Linear 或更复杂的交互层学习特征组合。

本节要点:

  • Layer 是神经网络的基本构建单元,封装参数和数学变换
  • 全连接层(Linear):通用变换,用于特征投影和分类
  • 卷积层(Conv2d):提取局部空间特征,用于图像
  • 循环层(LSTM):处理序列数据,捕获时序依赖
  • 嵌入层(Embedding):离散ID转稠密向量,用于NLP

五、激活函数与损失函数

5.1 激活函数(Activation Functions)

激活函数为网络引入非线性,让网络能够学习复杂的模式。没有激活函数,多层网络等价于单层线性变换。

激活函数公式特点适用场景
nn.ReLU max(0, x) 计算高效,梯度稀疏 几乎所有场景(首选)
nn.LeakyReLU x if x>0 else 0.01x 负区间有梯度,避免"死神经元" GAN、生成模型
nn.Sigmoid 1/(1+e^{-x}) 输出0-1,易解释 二分类输出层
nn.Tanh (e^x - e^{-x})/(e^x + e^{-x}) 输出-1~1,零中心 LSTM门控、文本生成
nn.Softmax e^{x_i}/Σe^{x_j} 多分类概率归一化 多分类输出层
import torch
import torch.nn as nn
import torch.nn.functional as F

# 使用方式一:作为 nn.Module 实例(可学习参数或有状态)
relu = nn.ReLU()
x = torch.randn(4, 4)
output = relu(x)

# 使用方式二:直接用 functional API(无状态,常用在 Sequential 中)
output = F.relu(x)

# Softmax 使用示例
# 多分类:最后一层 logits,dim=-1 表示在最后一个维度做 Softmax
logits = torch.randn(4, 10)  # batch=4, 10个类别
probs = F.softmax(logits, dim=-1)  # 转概率
print(f"Sum per row: {probs[0].sum().item()}")  # ≈ 1.0

5.2 损失函数(Loss Functions)

损失函数衡量模型预测与真实标签之间的差异,是训练优化的目标。

损失函数适用场景一句话说明
nn.MSELoss 回归任务 预测值与真实值的均方误差
nn.CrossEntropyLoss 多分类 包含 Softmax + 交叉熵,一步到位
nn.BCEWithLogitsLoss 二分类、多标签 Sigmoid + 二进制交叉熵,数值稳定
nn.BCELoss 二分类(已 logits 需先 Sigmoid) 纯二进制交叉熵
nn.L1Loss 回归(对异常值鲁棒) MAE,绝对值误差
nn.SmoothL1Loss 回归(如目标检测边框) L1 和 L2 的平滑组合
import torch
import torch.nn as nn

# ========== 多分类:CrossEntropyLoss ==========
# 输入:原始 logits(未经 Softmax),形状 (batch, num_classes)
# 目标:类别索引,形状 (batch,)
criterion_ce = nn.CrossEntropyLoss()

logits = torch.randn(4, 10)  # batch=4, 10个类别
targets = torch.tensor([3, 1, 7, 5])  # 真实类别索引

loss = criterion_ce(logits, targets)
print(f"CrossEntropy Loss: {loss.item():.4f}")

# ========== 二分类:BCEWithLogitsLoss ==========
# 输入:原始 logits,形状 (batch,)
# 目标:0/1 标签,形状 (batch,)
criterion_bce = nn.BCEWithLogitsLoss()

logits = torch.randn(4)  # 二分类 logits
targets = torch.tensor([1., 0., 1., 0.])

loss = criterion_bce(logits, targets)
print(f"BCE Loss: {loss.item():.4f}")

# ========== 回归:MSELoss ==========
criterion_mse = nn.MSELoss()

predictions = torch.randn(4, 1)  # 预测值
targets = torch.randn(4, 1)      # 真实值

loss = criterion_mse(predictions, targets)
print(f"MSE Loss: {loss.item():.4f}")

注意:当你使用 nn.CrossEntropyLoss 时,不要在模型最后再加 nn.Softmax!因为 CrossEntropyLoss 内部已经包含了 Softmax 计算,外部再加会导致数值不稳定。

六、正则化与归一化

6.1 Dropout — 随机丢弃,防止过拟合

6.1.1 定义

nn.Dropout 是深度学习中最常用的正则化技术之一。它的核心行为是:在训练阶段,按照概率 p 随机将一部分神经元的输出置为 0,从而强迫网络不能依赖少数神经元,必须学习更鲁棒、更冗余的特征表示。

其数学形式可简化为:

r ~ Bernoulli(1 - p)        # 以概率 (1-p) 生成 0/1 掩码
output = input * r / (1-p)   # 训练时掩码乘以输入,并缩放
                              # 推理时不使用掩码,也不缩放
  • 训练时:随机“关掉”一部分神经元,输出被掩码遮住
  • 推理/验证时:Dropout 关闭,所有神经元都参与计算,不缩放
6.1.2 为什么要用 Dropout?

问题背景:过拟合(overfitting)

神经网络在训练集上表现很好,但在测试集上表现明显变差,说明模型“记住了训练数据”,而不是学到了“可泛化的规律”。

为什么 Dropout 能缓解过拟合?

  • 打破神经元共适应:如果没有 Dropout,某些神经元可能总是依赖另一些神经元一起工作。Dropout 让每次前向传播的网络结构都略有不同,迫使每个神经元都独立地学会有用的特征。
  • 集成学习视角:每次 dropout 都相当于一个“随机子网络”,每次训练不同子网络;推理时使用全部神经元,可视为所有这些子网络的“几何平均”,起到 bagging 效果。
  • 减少特征之间的复杂共适应:避免少数神经元把错误放大到整个网络,提高整体鲁棒性。

对比:不使用 Dropout 的风险

不使用 Dropout:
  - 神经元容易形成依赖对,一个失效会导致另一个也失效
  - 复杂共适应会让模型对训练集噪声过度敏感
  - 测试集准确率通常低于训练集 5%~20%

使用 Dropout 后:
  - 网络鲁棒性提升,对噪声更不敏感
  - 测试集准确率更接近训练集,差距缩小
  - 对深层网络尤其有效,越深收益越大
6.1.3 怎么做:常见用法与调参经验

基本用法:

self.dropout = nn.Dropout(p=0.5)  # 50% 丢弃率

训练与推理模式:

model.train()   # 训练模式:Dropout 生效,随机丢弃
model.eval()    # 推理模式:Dropout 关闭,所有神经元参与

使用位置:

  • 全连接层之后、激活函数之后
  • 通常在“宽而深”的层后使用,如 `fc → ReLU → Dropout`
  • 卷积层后也可用,但现代 CNN 更常用 BatchNorm

常见 p 值:

场景推荐 p说明
全连接层 0.3 ~ 0.6 默认常用 0.5;参数越多,p 可稍高
卷积层 0.1 ~ 0.3 卷积层本身参数共享,过拟合风险相对低
输入层 0.1 ~ 0.2 仅用于噪声较大的输入

进阶:Inverted Dropout(PyTorch 实现方式)

PyTorch 在训练时做“反向缩放”:不是推理时放大,而是训练时除以 (1-p)。这样推理阶段无需做任何调整,代码更简洁:

训练时:output = input * mask / (1-p)   # 期望值保持原量级
推理时:output = input                    # 直接使用,无需缩放

完整示例:

import torch
import torch.nn as nn

class DropoutNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 256)
        self.dropout1 = nn.Dropout(p=0.5)  # 50% 丢弃率
        self.fc2 = nn.Linear(256, 128)
        self.dropout2 = nn.Dropout(p=0.3)  # 30% 丢弃率
        self.fc3 = nn.Linear(128, 10)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = self.dropout1(x)  # 训练时随机丢弃,推理时通过
        x = torch.relu(self.fc2(x))
        x = self.dropout2(x)
        x = self.fc3(x)
        return x

model = DropoutNet()

# 训练时
model.train()   # Dropout 生效
out = model(x)

# 推理时
model.eval()    # Dropout 关闭
out = model(x)
6.1.4 常见误区
  • 推理时忘记切 mode:始终调用 model.eval(),否则 Dropout 在推理时仍会随机丢弃神经元,导致结果不稳定
  • Dropout 不是越大越好:p 过高会让网络欠拟合,p 过低则正则化不足,需要根据验证集调参
  • BatchNorm 和 Dropout 不要随便堆叠:在 ResNet/Transformer 等结构中,两者可能冲突,需要谨慎设计

6.2 BatchNorm — 批归一化

6.2.1 定义

nn.BatchNorm 是一类对 batch 维度进行均值方差归一化的层。它把当前 batch 内每个特征的分布“拉回”到均值 0、方差 1 的标准正态分布,从而稳定训练、加速收敛、缓解梯度消失/爆炸。

对每个特征 channel c,BatchNorm 执行:

μ_c = (1/N) Σ x_i,c            # batch 均值
σ²_c = (1/N) Σ (x_i,c - μ_c)²  # batch 方差
x̂_i,c = (x_i,c - μ_c) / sqrt(σ²_c + ε)   # 归一化

y_i,c = γ_c · x̂_i,c + β_c       # 可学习的缩放和平移
  • μ, σ²:当前 batch 的统计量
  • γ, β:可学习参数,让网络可以“undo”归一化,保留表达能力
  • ε:极小值,防止除零

变体:

类名输入形状适用场景
BatchNorm1d [N, C] 或 [N, C, L] 全连接层、1D 时序
BatchNorm2d [N, C, H, W] 2D 图像 CNN
BatchNorm3d [N, C, D, H, W] 3D 视频/体数据
6.2.2 为什么要用 BatchNorm?

问题背景:Internal Covariate Shift(ICS)

在深层网络中,前面层的参数更新会导致后面层输入分布发生变化。这种“分布漂移”让后续层必须不断重新适应新的输入分布,导致训练缓慢、梯度不稳定。

BatchNorm 的四大收益:

  • 稳定训练:把每层输入拉回到稳定的分布,减少梯度爆炸/消失
  • 加速收敛:允许使用更大的学习率,epoch 更少即可收敛
  • 降低对初始化的敏感度:不再需要精心调参初始权重
  • 轻微正则化:batch 统计量带有噪声,带来类似 Dropout 的正则效果

直观理解:

没有 BatchNorm:
  第 1 层输出: 均值 0.5,方差 4.2  → 第 2 层要适应这个分布
  第 1 层更新后: 均值 -0.3,方差 1.1 → 第 2 层又要重新适应
  每一层都在不断“搬家”,训练很慢

加入 BatchNorm 后:
  第 1 层输出: 强制变成 均值 0,方差 1
  第 2 层始终接收稳定分布,训练稳定且快速
6.2.3 怎么做:代码与常见用法

在 CNN 中:

class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
        self.bn1 = nn.BatchNorm2d(64)          # num_features = 输出通道数
        self.relu = nn.ReLU()
        self.pool = nn.MaxPool2d(2)

    def forward(self, x):
        x = self.pool(self.relu(self.bn1(self.conv1(x))))
        return x

x = torch.randn(32, 3, 224, 224)  # batch=32
out = model(x)
print(out.shape)  # torch.Size([32, 64, 112, 112])

在 MLP 中:

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 256)
        self.bn1 = nn.BatchNorm1d(256)
        self.fc2 = nn.Linear(256, 128)
        self.bn2 = nn.BatchNorm1d(128)
        self.fc3 = nn.Linear(128, 10)

    def forward(self, x):
        x = torch.relu(self.bn1(self.fc1(x)))
        x = torch.relu(self.bn2(self.fc2(x)))
        return self.fc3(x)

x = torch.randn(32, 784)
out = model(x)
print(out.shape)  # torch.Size([32, 10])

运行时统计量:

bn2d = nn.BatchNorm2d(num_features=64)
x = torch.randn(32, 64, 32, 32)
x = bn2d(x)

print(f"Output shape: {x.shape}")           # torch.Size([32, 64, 32, 32])
print(f"Running mean shape: {bn2d.running_mean.shape}")   # torch.Size([64])
print(f"Running var shape: {bn2d.running_var.shape}")     # torch.Size([64])
6.2.4 使用注意事项
  • num_features 必须等于通道数:BatchNorm2d(64) 的 64 对应输入张量的 channel 维度
  • batch 不能太小:如果 batch=1 或 2,均值/方差统计不可靠,BatchNorm 效果会变差
  • 推理时使用滑动平均:PyTorch 在训练时维护 running_mean 和 running_var,推理时用它们代替当前 batch 统计量
  • 顺序:通常是 Conv/Linear → BatchNorm → Activation,不要放反
  • 与 LayerNorm 选择:BatchNorm 依赖 batch,适合 CNN;LayerNorm 不依赖 batch,适合 Transformer、小 batch、变长序列

BatchNorm vs LayerNorm:BatchNorm 在 batch 维度做归一化,适合 batch 较大且稳定的场景(如图像);LayerNorm 在特征维度做归一化,不依赖 batch,适合序列模型和 Transformer。

6.3 LayerNorm — 层归一化

6.3.1 定义

nn.LayerNorm 是一种对单个样本内部的特征维度做归一化的技术。它不会跨样本统计 batch 信息,而是对每一个样本、每一个 token 的特征向量,单独计算均值与方差,并将其归一化到均值 0、方差 1 附近。

其数学表达可写成:

μ = (1/D) Σ x_i              # 在当前样本的特征维度上求均值
σ² = (1/D) Σ (x_i - μ)²      # 在当前样本的特征维度上求方差
x̂_i = (x_i - μ) / sqrt(σ² + ε)   # 归一化
y_i = γ_i · x̂_i + β_i        # 可学习的缩放和平移
  • D:归一化维度的大小,通常由 normalized_shape 指定
  • γ, β:可学习参数,让网络可以“撤销”或调整归一化后的分布
  • 关键点:所有统计都在“单个样本内部”完成,不同样本之间互不影响

输入形状示例:

# 最后一维归一化:适用于 [N, seq_len, embed_dim]
ln = nn.LayerNorm(normalized_shape=512)

# 多个维度一起归一化:适用于 [N, C, H, W]
ln = nn.LayerNorm(normalized_shape=[256, 16, 16])
6.3.2 为什么要用 LayerNorm?

问题背景:BatchNorm 在哪些场景会失效?

  • 小 batch 场景:batch 太小会导致均值/方差估计不准,BatchNorm 不稳定
  • 变长序列:NLP 中 batch 内样本长度往往不同,难以统一做 batch 维归一化
  • 在线/ continual 学习:推理时 batch=1,BatchNorm 的 running stats 不再可靠
  • 强化学习:batch 不稳定,分布变化剧烈

LayerNorm 的四大收益:

  • 不依赖 batch:每个样本独立归一化,batch=1 也能正常工作
  • 适配变长序列:序列长度不影响归一化逻辑,天然适合 Transformer
  • 稳定深层训练:减少层间分布漂移,让梯度传播更稳定
  • 保留表达能力:通过 γ, β 可学习参数,网络仍然可以恢复任意分布

直观理解:

BatchNorm:把一批样本中同一特征拉到同一分布
  → 依赖 batch,batch 越小越不准

LayerNorm:把单个样本中不同特征拉到同一分布
  → 不依赖 batch,每个样本自己管自己

例如,对一个词向量 [0.2, -0.5, 1.3, ..., 0.8]:
  BatchNorm 看:batch 里所有样本的第 1 维
  LayerNorm 看:这个样本自己的 512 个维度
6.3.3 怎么做:代码与常见用法

基本用法:

import torch
import torch.nn as nn

# 对最后一维做归一化,embed_dim=512
ln = nn.LayerNorm(normalized_shape=512)

# 4D 输入:(batch, seq_len, embed_dim)
x = torch.randn(8, 30, 512)  # batch=8,序列长度30,嵌入维度512
x = ln(x)

print(f"输入形状: {x.shape}")  # torch.Size([8, 30, 512])
print(f"输出均值(应≈0): {x.mean(dim=-1).mean().item():.6f}")
print(f"输出方差(应≈1): {x.var(dim=-1).mean().item():.6f}")

在 Transformer / BERT 中使用:

import torch.nn as nn

class TransformerBlock(nn.Module):
    def __init__(self, embed_dim=512, num_heads=8):
        super().__init__()
        self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)
        self.norm1 = nn.LayerNorm(embed_dim)       # 注意力后的层归一化
        self.ffn = nn.Sequential(
            nn.Linear(embed_dim, embed_dim * 4),
            nn.GELU(),
            nn.Linear(embed_dim * 4, embed_dim),
        )
        self.norm2 = nn.LayerNorm(embed_dim)       # FFN 后的层归一化

    def forward(self, x):
        # Pre-LN 结构(GPT 系列常用)
        x = x + self.attn(self.norm1(x), x, x)[0]  # 残差 + 注意力
        x = x + self.ffn(self.norm2(x))             # 残差 + FFN
        return x

对多维输入归一化:

# 图像输入 (N, C, H, W),同时对 C、H、W 归一化
ln = nn.LayerNorm(normalized_shape=[256, 16, 16])
x = torch.randn(4, 256, 16, 16)
x = ln(x)
# 输出形状不变:torch.Size([4, 256, 16, 16])
6.3.4 与 BatchNorm 的对比
对比项BatchNormLayerNorm
统计维度 batch 维度 特征维度
依赖 batch 是,batch 太小效果差 否,batch=1 也可工作
典型场景 CNN、大 batch 图像 Transformer、NLP、小 batch
训练/推理行为 训练用 batch 统计,推理用 running stats 每次都是当前样本统计,无 running stats
参数量 每个 channel 2 个参数 (γ, β) 每个归一化维度 2 个参数 (γ, β)
6.3.5 常见注意点
  • normalized_shape 要与实际维度对齐:比如输入最后一维是 512,就设 LayerNorm(512)
  • 训练和推理无需切换 mode:LayerNorm 没有 running stats,行为前后一致
  • 放在残差之前还是之后?常见两种结构:Pre-LN(如 GPT、BERT)和 Post-LN(如原始 Transformer),两者训练稳定性不同
  • 与 BatchNorm 不要随意混用:同一模块内通常只选一种归一化策略

本节要点:

  • nn.Dropout:训练时随机丢弃神经元,防止过拟合
  • nn.BatchNorm:batch 维度归一化,适合 CNN
  • nn.LayerNorm:特征维度归一化,适合 Transformer

七、模型构建实战案例

7.1 案例一:LeNet-5 手写数字识别

背景介绍

LeNet-5 是深度学习领域的“Hello World”,由 Yann LeCun 等人在 1998 年提出,是最早成功商用的卷积神经网络。它用于识别手写数字 MNIST(28×28 灰度图),整体参数量仅约 6 万,但验证了“局部感受野 + 权值共享”的核心思想。

MNIST 的挑战在于:同一个人写“7”,可以有几百种形态;机器必须从像素级的原始输入中,抽象出“高层的曲线、转角、闭环”等特征,才能准确分类。

LeNet-5 的设计哲学是:先用卷积层提取局部特征(保留空间结构),再用全连接层做分类决策(压缩空间信息)。

结构拆解:逐层维度变化
输入图像: (1, 32, 32)          # 通道=1, 高=32, 宽=32

卷积层 C1:
  Conv2d(1, 6, kernel=5, padding=2)
  → 特征图: (6, 28, 28)        # 32-5+2*2 = 28 (无 padding 时公式: H_out = H_in - kernel + 2*pad)

池化层 S2:
  MaxPool2d(2, 2)
  → 特征图: (6, 14, 14)        # 28/2 = 14

卷积层 C3:
  Conv2d(6, 16, kernel=5)
  → 特征图: (16, 10, 10)       # 14-5+0 = 10

池化层 S4:
  MaxPool2d(2, 2)
  → 特征图: (16, 5, 5)         # 10/2 = 5

全连接层 F5:
  Linear(16*5*5, 120)
  → 输出: (120,)               # 展平后 400 维

全连接层 F6:
  Linear(120, 84)
  → 输出: (84,)

全连接层 F7 (输出层):
  Linear(84, 10)
  → 输出: (10,)                 # 10 个类别的 logits
核心代码逐行解析
class LeNet5(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        # 卷积层:提取局部特征,out_channels=6 表示输出 6 个特征图
        self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
        self.conv2 = nn.Conv2d(6, 16, kernel_size=5)

        # 池化层:下采样,降低分辨率,减少计算量
        self.pool = nn.MaxPool2d(2, 2)

        # 全连接层:展平后做分类
        # 16*5*5 = 400,对应最后一个池化层的总神经元数
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, num_classes)

        self.relu = nn.ReLU()  # 共享 ReLU 实例,节省内存

    def forward(self, x):
        # 卷积 + 池化:提取特征
        x = self.pool(self.relu(self.conv1(x)))  # (1,32,32) → (6,14,14)
        x = self.pool(self.relu(self.conv2(x)))  # (6,14,14) → (16,5,5)

        # 展平:将 (16,5,5) → (400,)
        x = x.view(x.size(0), -1)

        # 全连接:分类
        x = self.relu(self.fc1(x))  # (400,) → (120,)
        x = self.relu(self.fc2(x))  # (120,) → (84,)
        x = self.fc3(x)             # (84,) → (10,)  # 最后一层无激活,logits 给 CrossEntropyLoss
        return x
维度计算关键点
  • padding=2 的作用:输入 32×32,kernel=5 时,不 padding 会在第一层就把边缘信息丢掉;加 padding=2 可以让输出保持 28×28
  • 为什么第二层卷积 kernel_size=5 无 padding:输入是 14×14,经过 14-5+1=10,不 padding 刚好是 10×10
  • 16*5*5=400:卷积提取的空间特征在最后一个池化层保留为 5×5 的 16 个特征图,展平后就是 400 维向量
  • 最后一层无激活:因为 CrossEntropyLoss 内部会做 Softmax,直接用 logits 更稳定
挂件点:实际训练注意事项
  • MNIST 原图是 28×28,这里预处理时 padding 到 32×32;如果用 28×28,只需把第一个池化输出改为 (6,12,12)
  • 原始 LeNet 用 sigmoid 激活,现代实现改用 ReLU,收敛更快
  • 现代 MNIST 精度很容易超过 99%,LeNet 足够用

7.2 案例二:ResNet 残差块

背景介绍

ResNet(Residual Network) 由 He Kaiming 等人在 2015 年提出,是 ImageNet 比赛冠军模型。它的核心贡献是解决了深层网络退化问题:网络越深,训练误差和测试误差反而更高——这不是过拟合,而是优化困难。

退化问题的本质:非线性层越来越多,随机初始化的权重让每个残差分支的输出在训练初期趋近于零,于是深层网络退化为浅层网络的恒等映射表达能力,网络无法有效学习。

ResNet 的解法:恒等映射(Identity Mapping)

与其让网络直接学习底层→高层的完整映射 H(x),不如让网络学习残差 F(x) = H(x) - x。如果恒等映射是最优解,网络只需要让 F(x)=0;如果需要改造,网络同时学习 F(x) 和 x。两件事互不干扰。

普通网络: H(x) = F(x)              # 要学会整个映射
残差网络: H(x) = F(x) + x         # 只需要学会残差 F(x)
                                    # x 是恒等映射的"捷径"(shortcut)

训练初期: F(x) ≈ 0 → H(x) ≈ x    # 退化为浅层网络,可以正常训练
训练后期: F(x) 学习增量信息        # 超越浅层网络
残差块结构拆解

每个残差块包含两条路径:

  • 残差路径(F(x)):两个 3×3 卷积 + BN + ReLU,负责学习增量
  • 捷径路径(x):直接传递输入,如果尺寸不一致则用 1×1 卷积调整
class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()

        # ---- 残差路径(主路径)----
        # stride>1 时做下采样;bias=False 因为后面 BatchNorm 会做偏置
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3,
                               stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)

        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)

        self.relu = nn.ReLU(inplace=True)

        # ---- 捷径路径(shortcut)----
        # 当尺寸不一致时(stride≠1 或通道数变化),需要调整 x 使其能与 F(x) 相加
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                # 1×1 卷积:既可以改变通道数,也可以改变分辨率
                nn.Conv2d(in_channels, out_channels, kernel_size=1,
                          stride=stride, bias=False),
                nn.BatchNorm2d(out_channels)
            )

    def forward(self, x):
        residual = x                     # 保存输入(恒等映射)
        out = self.conv1(x)              # 第一个卷积
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)            # 第二个卷积
        out = self.bn2(out)
        out += self.shortcut(residual)   # 关键:F(x) + x
        out = self.relu(out)
        return out
三种尺寸变化场景
场景输入输出Shortcut 处理
尺寸相同 (N, 64, 32, 32) (N, 64, 32, 32) 直接相加,shortcut = 空
分辨率减半 (N, 64, 32, 32) (N, 64, 16, 16) 1×1 conv + stride=2
通道数翻倍 (N, 64, 32, 32) (N, 128, 16, 16) 1×1 conv + stride=2 + 通道扩维
完整 ResNet 如何堆叠残差块
class ResNet18(nn.Module):
    def __init__(self, num_classes=1000):
        super().__init__()
        self.in_channels = 64

        # 初始卷积层
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

        # 残差块堆叠
        self.layer1 = self._make_layer(out_channels=64,  blocks=2, stride=1)   # 64×56×56
        self.layer2 = self._make_layer(out_channels=128, blocks=2, stride=2)  # 128×28×28
        self.layer3 = self._make_layer(out_channels=256, blocks=2, stride=2)  # 256×14×14
        self.layer4 = self._make_layer(out_channels=512, blocks=2, stride=2)  # 512×7×7

        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, out_channels, blocks, stride):
        layers = []
        # 第一个 block 可能需要下采样
        layers.append(ResidualBlock(self.in_channels, out_channels, stride))
        self.in_channels = out_channels
        # 后续 blocks 尺寸不变
        for _ in range(1, blocks):
            layers.append(ResidualBlock(out_channels, out_channels))
        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.maxpool(self.relu(self.bn1(self.conv1(x))))  # 224→112
        x = self.layer1(x)  # 112→56
        x = self.layer2(x)  # 56→28
        x = self.layer3(x)  # 28→14
        x = self.layer4(x)  # 14→7
        x = self.avgpool(x) # 7→1
        x = torch.flatten(x, 1)
        return self.fc(x)

# ResNet-18/34/50/101/152 区别:残差块数量和每个 block 的卷积配置
# ResNet-18: [2,2,2,2] blocks,对应 18 层
# ResNet-50: [3,4,6,3] blocks,且用 1×1→3×3→1×1 的 bottleneck 结构
挂件点:残差连接的三大注意事项
  • F(x) 和 x 必须形状一致才能相加:维度不匹配时用 1×1 卷积调整,这是最常见的 bug 来源
  • 捷径路径不需要激活函数:恒等映射本身就是 identity,直接加结果更好
  • ResNet 训练技巧:先用 lr=0.1, SGD+momentum=0.9, weight_decay=1e-4;现代也常用 AdamW 配合余弦退火
  • BatchNorm 在哪里?残差路径里每个卷积后都有 BN,1×1 shortcut 里也有 BN

7.3 案例三:完整的训练流程

背景介绍

前面两个案例聚焦于模型定义,但深度学习真正重要的另一半是训练闭环——从数据到梯度更新,再到模型持久化。这是一个经典的 MLP(多层感知机)训练流程,覆盖了 PyTorch 项目中最核心的 7 个步骤:

  • ① 定义模型
  • ② 定义损失函数和优化器
  • ③ 准备数据
  • ④ 训练循环(前向→损失→梯度→更新)
  • ⑤ 评估
  • ⑥ 保存模型
  • ⑦ 加载模型推理
完整代码与逐行解析
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# ============================================================
# 步骤①:定义模型
# ============================================================
class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        # nn.Sequential:按顺序堆叠层,最简洁的定义方式
        self.net = nn.Sequential(
            nn.Linear(784, 256),     # 输入层:28×28=784 → 256
            nn.ReLU(),               # 激活函数
            nn.Dropout(0.2),         # 防止过拟合
            nn.Linear(256, 128),    # 隐藏层
            nn.ReLU(),               # 激活函数
            nn.Linear(128, 10)       # 输出层:10 个类别
        )
        # 注意:最后一层无激活!CrossEntropyLoss 内部包含 Softmax

    def forward(self, x):
        return self.net(x)

# 创建模型实例
model = SimpleNet()
print(f"模型结构:\n{model}")
print(f"参数量: {sum(p.numel() for p in model.parameters()):,}")

# ============================================================
# 步骤②:定义损失函数和优化器
# ============================================================
criterion = nn.CrossEntropyLoss()   # 分类交叉熵损失(内置 softmax)
optimizer = optim.Adam(
    model.parameters(),
    lr=0.001,                       # 学习率
    betas=(0.9, 0.999),             # 动量估计
    weight_decay=1e-4               # L2 正则化
)

# ============================================================
# 步骤③:准备数据(这里用随机数据演示,实际用真实数据集)
# ============================================================
X_train = torch.randn(1000, 784)    # 1000 个样本,784 维
y_train = torch.randint(0, 10, (1000,))  # 1000 个标签

# TensorDataset:将特征和标签打包
train_dataset = TensorDataset(X_train, y_train)

# DataLoader:批量加载,自动 shuffle、打乱、重采样
train_loader = DataLoader(
    train_dataset,
    batch_size=32,     # 每个 batch 32 个样本
    shuffle=True,      # 每个 epoch 打乱一次
    num_workers=2,     # 多进程加载(加速)
    drop_last=False    # 不丢弃最后不完整的 batch
)

# ============================================================
# 步骤④:训练循环
# ============================================================
model.train()   # 开启训练模式:Dropout 生效,BatchNorm 用 batch 统计
num_epochs = 5

for epoch in range(num_epochs):
    running_loss = 0.0   # 累计损失
    correct = 0          # 预测正确数
    total = 0            # 总样本数

    for batch_idx, (inputs, targets) in enumerate(train_loader):
        # ---- 前向传播 ----
        outputs = model(inputs)        # shape: (32, 10)
        loss = criterion(outputs, targets)

        # ---- 反向传播 ----
        optimizer.zero_grad()          # 梯度清零(必须!)
        loss.backward()                # 计算梯度

        # ---- 参数更新 ----
        optimizer.step()               # 按梯度和学习率更新参数

        # ---- 统计 ----
        running_loss += loss.item()
        _, predicted = outputs.max(1)  # 取最大 logit 对应类别
        total += targets.size(0)
        correct += predicted.eq(targets).sum().item()

    # 每个 epoch 结束后打印一次平均指标
    epoch_loss = running_loss / len(train_loader)
    epoch_acc = 100. * correct / total
    print(f"Epoch [{epoch+1}/{num_epochs}] "
          f"Loss: {epoch_loss:.4f} Acc: {epoch_acc:.2f}%")

# ============================================================
# 步骤⑤:评估(验证/测试)
# ============================================================
model.eval()   # 开启评估模式:Dropout 关闭,BatchNorm 用 running stats
with torch.no_grad():  # 推理时不需要梯度,节省显存和计算
    test_input = torch.randn(1, 784)
    output = model(test_input)
    _, predicted = output.max(1)
    print(f"预测类别: {predicted.item()}")
    print(f"各类别概率: {torch.softmax(output, dim=1)}")

# ============================================================
# 步骤⑥:保存模型
# ============================================================
# 方式一:保存整个模型(包含结构,不推荐,跨版本可能不兼容)
torch.save(model, 'model_whole.pth')

# 方式二:保存参数(推荐,只保存权重,兼容性好)
torch.save(model.state_dict(), 'model_weights.pth')

# 方式三:保存完整检查点(推荐,包含优化器状态、epoch 等,方便断点续训)
checkpoint = {
    'epoch': 5,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss': epoch_loss,
}
torch.save(checkpoint, 'checkpoint.pth')

# ============================================================
# 步骤⑦:加载模型推理
# ============================================================
# 加载参数(需要先有模型结构)
model.load_state_dict(torch.load('model_weights.pth', weights_only=True))
model.eval()

# 加载检查点(恢复完整状态)
checkpoint = torch.load('checkpoint.pth', weights_only=False)
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
start_epoch = checkpoint['epoch']
print(f"从 epoch {start_epoch} 恢复训练")
训练流程关键点梳理
  • model.train() vs model.eval():两者主要控制 Dropout、BatchNorm、BatchNorm 的行为差异;不切换不会报错,但结果会不稳定
  • optimizer.zero_grad() 必须调用:PyTorch 默认梯度累积,不清零会导致梯度叠加更新错误
  • with torch.no_grad():推理时禁止梯度计算,可节省约 30-50% 显存、显著加速
  • loss.item() 而非 loss:loss 是计算图节点,item() 提取纯数值;打印 loss 时必须用 item(),否则显存泄漏
  • CrossEntropyLoss 不在最后一层:该损失函数内部集成了 log_softmax,直接传 logits,不要手动 softmax
  • drop_last:设为 True 可避免最后一个 batch 样本太少导致 BN 统计不稳定
挂件点:3 种保存方式的适用场景
保存方式代码优点缺点
保存整个模型 torch.save(model, 'xxx.pth') 简单直接 不跨版本、不可移植、有安全风险
保存 state_dict(推荐) torch.save(model.state_dict(), 'xxx.pth') 轻量、跨版本、可移植 需要重建模型结构
保存完整检查点 torch.save({'model': ..., 'opt': ..., 'epoch': ...}) 支持断点续训 文件较大
扩展:GPU 训练(单机单卡)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)       # 模型搬到 GPU

for inputs, targets in train_loader:
    inputs = inputs.to(device)  # 数据搬到 GPU
    targets = targets.to(device)

    outputs = model(inputs)
    loss = criterion(outputs, targets)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()

本节要点:

  • 模型定义:继承 nn.Module,实现 forward()
  • 训练流程:前向传播 → 计算损失 → 梯度清零 → 反向传播 → 参数更新
  • 模式切换:train() 训练模式,eval() 评估模式
  • 模型持久化:state_dict() 保存参数,load_state_dict() 加载

八、FAQ(常见问题)

以下是关于 torch.nn 模块的 20 个常见问题,涵盖核心概念、实战技巧和避坑指南。

Q1. nn.Module 和 nn.functional 的区别是什么?

一句话结论:nn.Module 是有状态的(有参数),nn.functional 是无状态的(纯函数)。

nn.ReLU 是类实例,F.relu 是函数调用。推荐在 nn.Sequential 中使用 nn.ReLU(),因为它可以统一管理状态;在需要控制行为的地方(如 inplace=True),使用 F.relu(x, inplace=True)。

Q2. 为什么要在每个 batch 前调用 optimizer.zero_grad()?

一句话结论:PyTorch 默认会累积梯度,不清零会导致梯度计算错误。

每个 batch 的梯度需要独立计算,如果不清零,新梯度会加上之前的梯度,导致参数更新方向错误。正确的顺序是:zero_grad() → backward() → step()。

Q3. .to(device) 和 .cuda() 有什么区别?

一句话结论:.to(device) 更通用,支持任意设备;.cuda() 是简写,仅限 CUDA。

推荐使用 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu'),然后 model.to(device),代码更灵活。

Q4. model.train() 和 model.eval() 的区别是什么?

一句话结论:训练模式 Dropout 生效、BatchNorm 使用 batch 统计量;推理模式则相反。

如果忘记切换到 eval(),Dropout 不会关闭,BatchNorm 不会使用固定的 running stats,导致推理结果随机。

Q5. torch.no_grad() 和 model.eval() 有什么关系?

一句话结论:model.eval() 切换模式,torch.no_grad() 禁用梯度。

两者常搭配使用:model.eval() 切换 BatchNorm/Dropout 行为,torch.no_grad() 禁用梯度计算以节省显存。

Q6. 为什么不直接调用 model.forward(x)?

一句话结论:因为 model(x) 会触发 __call__ 中的钩子函数和异常处理。

nn.Module.__call__ 会调用 forward,同时触发 _forward_hooks、_backward_hooks 等扩展机制。直接调用 forward 会绕过这些。

Q7. nn.Parameter 和 nn.Tensor 有什么区别?

一句话结论:nn.Parameter 是自动注册到模型的 nn.Tensor。

当你将 nn.Parameter 赋值给类属性时,它会自动注册为模型参数,被 parameters() 收集;而普通 Tensor 不会。

Q8. 如何查看模型参数数量?

一句话结论:使用 sum(p.numel() for p in model.parameters())。

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:,}")

Q9. nn.Sequential 和 nn.ModuleList 哪个更好?

一句话结论:顺序结构用 nn.Sequential,动态/索引结构用 nn.ModuleList。

如果网络结构是固定顺序的,用 nn.Sequential 更简洁;如果需要动态添加层或按索引访问,用 nn.ModuleList。

Q10. 为什么输入数据要调用 .to(device)?

一句话结论:因为模型和数据必须在同一设备上才能计算。

如果模型在 GPU 上,数据在 CPU 上,会报错。将数据也迁移到 GPU:inputs = inputs.to(device)。

Q11. nn.BatchNorm 需要设置 track_running_stats 吗?

一句话结论:训练时 track_running_stats=True(默认),推理时使用 running stats。

如果 batch size 很小(如 1),可以设 track_running_stats=False,这样训练时会使用 batch 统计量而非 running stats。

Q12. 模型的 state_dict 是什么?

一句话结论:一个有序字典,键是参数名,值是参数张量。

model.state_dict() 返回模型所有参数的快照,常用于保存和加载模型。

Q13. 如何加载部分预训练参数?

一句话结论:加载时设置 strict=False,然后手动过滤。

pretrained_dict = torch.load('pretrained.pth')
model_dict = model.state_dict()
# 只加载匹配的参数
pretrained_dict = {k: v for k, v in pretrained_dict.items()
                   if k in model_dict and v.shape == model_dict[k].shape}
model_dict.update(pretrained_dict)
model.load_state_dict(model_dict)

Q14. 为什么 ReLU 要用 inplace=True?

一句话结论:节省显存,不需要额外分配内存存储结果。

inplace=True 直接在原张量上修改,但注意在计算梯度时会有影响,某些场景(如求导顺序敏感)不建议使用。

Q15. nn.CrossEntropyLoss 和 nn.NLLLoss 有什么区别?

一句话结论:CrossEntropyLoss = Softmax + NLLLoss。

CrossEntropyLoss 输入是原始 logits,内部先做 Softmax 再算 NLL;NLLLoss 输入是 Softmax 后的 log 概率。

Q16. 如何实现自定义损失函数?

一句话结论:继承 nn.Module 并实现 forward。

import torch
import torch.nn as nn
import torch.nn.functional as F

class FocalLoss(nn.Module):
    def __init__(self, alpha=0.25, gamma=2.0):
        super().__init__()
        self.alpha = alpha
        self.gamma = gamma

    def forward(self, inputs, targets):
        ce_loss = F.cross_entropy(inputs, targets, reduction='none')
        pt = torch.exp(-ce_loss)
        focal_loss = self.alpha * (1-pt)**self.gamma * ce_loss
        return focal_loss.mean()

Q17. 为什么需要注册子模块?

一句话结论:注册后 PyTorch 会自动管理参数、梯度、设备迁移和序列化。

只有通过 self.xxx = nn.Module(...) 注册的子模块,其参数才会被 parameters() 收集、优化器绑定和 to() 迁移。

Q18. nn.Module 的 __init__ 中为什么要调用 super().__init__()?

一句话结论:初始化父类的内部状态,确保参数注册机制正常工作。

super().__init__() 会初始化 _parameters、_modules、_buffers 等字典,这些是参数管理的基石。

Q19. 如何实现模型权重初始化?

一句话结论:在 __init__ 或 reset_parameters 中使用 nn.init 模块。

def reset_parameters(self):
    nn.init.xavier_uniform_(self.weight)  # Xavier 初始化
    if self.bias is not None:
        nn.init.zeros_(self.bias)

Q20. 保存整个模型和只保存 state_dict 哪个更好?

一句话结论:推荐只保存 state_dict,更灵活、可移植。

保存整个模型会绑定模型类定义,更换代码后可能加载失败;保存 state_dict 只依赖参数名,兼容性更好。

FAQ 要点总结:

  • nn.Module vs nn.functional:有状态 vs 无状态
  • zero_grad() 必须调用,否则梯度累积
  • train()/eval() + no_grad() 配合使用
  • 优先使用 state_dict 保存模型

九、Roadmap 后续预告

后续学习路线:

  • 深入 Layer:深入理解各类卷积变体(Depthwise、Separable、Transposed)、注意力机制(Self-Attention)
  • 优化技巧:学习率调度(LR Scheduler)、梯度裁剪(Gradient Clipping)、混合精度训练(AMP)
  • 高级模块:torch.nn.utils(参数剪枝、Spectral Norm)、torch.nn.parallel(分布式训练)
  • 实战项目:图像分类(ResNet、EfficientNet)、NLP(BERT、GPT)、目标检测(YOLO)、语义分割(U-Net)
  • 自动微分机制:torch.autograd 原理、计算图构建、梯度计算
  • 优化器详解:torch.optim 中的 SGD、Adam、RMSprop 等
posted @ 2026-07-11 16:04  左扬  阅读(56)  评论(0)    收藏  举报