PyTorch 2.x 深度学习专题【左扬精讲】—— CNN 的正向传播:从输入图像到类别概率的完整链路
卷积神经网络(Convolutional Neural Network,CNN)已经在图像分类、目标检测、语义分割等视觉任务中取得突破性成果。
无论网络结构如何演进(LeNet → VGG → ResNet → MobileNet),CNN 一次 完整的预测过程 都是同一条链路:从原始像素张量出发,依次经过若干 "Conv → Norm → ReLU → Pool" 基本单元,再通过全局池化与全连接层,输出每个类别的预测概率。这条链路在 PyTorch 中被称为 forward(正向传播)。
本文聚焦 CNN 的正向传播:从单层算子(torch.nn.Conv2d、torch.nn.BatchNorm2d、torch.nn.ReLU、torch.nn.MaxPool2d)的张量形状变化,到完整 nn.Module.forward() 链路的实现方式(直接顺序 / nn.Sequential),再到激活可视化(register_forward_hook)和输出层(torch.softmax、torch.argmax)。理解正向传播是后续理解反向传播、模型部署、性能分析的前提。
torch.nn.Conv2d ← 二维卷积(特征提取)
torch.nn.BatchNorm2d ← 批归一化(训练稳定)
torch.nn.ReLU ← ReLU 激活(非线性)
torch.nn.MaxPool2d ← 最大池化(下采样)
torch.nn.AdaptiveAvgPool2d ← 全局平均池化(分类头)
torch.nn.Linear ← 全连接层(分类器)
torch.nn.Module.forward ← 用户实现的前向函数
torch.nn.Sequential ← 顺序容器(替代手写 forward)
PyTorchCNNForward正向传播Conv2dBatchNorm2dReLUMaxPool2dForward Hook
学习重点
- 必须掌握
- CNN 单层基本单元:Conv → Norm → ReLU → Pool 的张量形状变化
- torch.nn.Module.forward(self, x) 的写法与调用机制(model(x) ↔ model.forward(x))
- 用 nn.Sequential 容器把若干层组装成一条顺序链路
- 典型 CNN(VGG、ResNet)的完整 forward 链路与每一步形状追踪
- 训练 / 推理模式下 BatchNorm 与 Dropout 的行为差异(model.train() / model.eval())
- 输出层:torch.softmax(概率)与 torch.argmax(预测类别)
- 理解即可
- 用 register_forward_hook 抓取中间激活做可视化 / 诊断
- 用 torch.no_grad() 加速推理、model.eval() 关闭梯度统计
目录
- 概述:CNN 正向传播是什么、为什么值得单独讲
- 单层基本单元:Conv → Norm → ReLU → Pool 的张量变化
- 完整 forward 链路:从像素到 logits 的形状追踪
- 用 nn.Sequential 组装网络:替代手写 forward
- train / eval 模式:BatchNorm 与 Dropout 的行为差异
- 输出层:logits → softmax → argmax 的语义
- Forward Hook:用 register_forward_hook 抓取中间激活
- 推理流程:no_grad + eval + 单张图像的端到端链路
- FAQ(20 组)
- Roadmap 预告
一、概述:CNN 正向传播是什么、为什么值得单独讲
What — 什么是 CNN 的正向传播?
CNN 的正向传播(Forward Pass / Forward Propagation),是指输入图像张量 x 从网络的第一层逐层流动到最后一层,最终输出预测结果(logits 或概率)的完整计算链路。在 PyTorch 中,这条链路由用户实现的 nn.Module.forward(self, x) 方法定义;调用 model(x) 时,PyTorch 内部自动调用 __call__ → forward,沿途收集梯度(训练时)或直接返回输出(推理时)。
正向传播在深度学习中的位置
- 训练阶段:一次 forward 算出预测 → 与真实标签比较得到 loss → 反向传播求梯度 → 优化器更新权重。一个 epoch 内 forward + backward + step 反复循环
- 推理阶段:只有 forward,不需要梯度。输入一张图像,输出类别概率分布
- 特征可视化 / 调试:用 forward hook 在 forward 链路中途拦截中间激活(如每个 Conv 层的输出),用于 Grad-CAM、特征图可视化、网络诊断
为什么值得单独讲解?
- CNN 的「层」并不是简单的线性叠加:每一层都可能改变张量的 4 个维度(N, C, H, W),工程师必须能在脑中追踪每一步形状变化
- PyTorch 提供多种 forward 写法:nn.Sequential、手写 forward、嵌套子模块 —— 选择哪种写法影响可读性与可扩展性
- 理解 forward 的细节是后续理解 backward 的前提:backward 是 forward 的反向、自动微分会沿着 forward 的链路逆推
输入图像:x ∈ R^(N×C_in×H×W) (例如 [8, 3, 224, 224])│▼
┌─────────────────────────────────────────┐
│ Conv2d(3→64, 3×3, pad=1) │ ← 特征提取:边缘、纹理
│ + BatchNorm2d(64) + ReLU(inplace=True) │
│ 输出:[8, 64, 224, 224]
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ MaxPool2d(2, 2) │ ← 下采样:H/W 减半
│ 输出:[8, 64, 112, 112]
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ Conv2d(64→128, 3×3, pad=1) │ ← 特征提取:角点、图案
│ + BatchNorm2d(128) + ReLU(inplace=True) │
│ 输出:[8, 128, 112, 112]
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ MaxPool2d(2, 2) │
│ 输出:[8, 128, 56, 56] │
└─────────────────────────────────────────┘│ …(重复若干个 block)▼
┌─────────────────────────────────────────┐
│ AdaptiveAvgPool2d((1,1)) │ ← 全局平均池化
│ 输出:[8, 512, 1, 1]
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ Flatten() │ ← 扁平化:[8, 512]
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ Linear(512, num_classes) │ ← 分类器
│ 输出:[8, num_classes](logits) │
└─────────────────────────────────────────┘│▼
┌─────────────────────────────────────────┐
│ torch.softmax(dim=1) │ ← 概率化(可选)
│ 输出:[8, num_classes](概率) │
└─────────────────────────────────────────┘正向传播的本质:「按 forward() 链路把张量逐层变换」。
本节小结
- 正向传播定义:输入张量从网络第一层流动到最后一层,输出 logits / 概率
- PyTorch 实现:model(x) ↔ model.__call__(x) ↔ model.forward(x)
- 训练 vs 推理:训练同时需要 forward + backward;推理只需 forward
- 关键链路:Conv → Norm → ReLU → Pool → GAP → Linear → Softmax
二、单层基本单元:Conv → Norm → ReLU → Pool 的张量变化
What — 一个「标准 CNN 块」由哪些算子组成?
现代 CNN 的最小重复单元通常由四步组成:Conv2d(特征提取)→ BatchNorm2d(归一化)→ ReLU(非线性激活)→ MaxPool2d(下采样)。在 PyTorch 中,这四步既可以写成四个独立的 nn.Module 调用,也可以封装成一个 nn.Sequential。理解这四步的张量形状变化是掌握 CNN 正向传播的基础。
四步基本单元的功能分工
- Conv2d:用可学习卷积核扫描输入,提取局部模式。改变 C(输入通道 → 输出通道),可改变 H, W(由 padding / stride 决定)
- BatchNorm2d:对每个通道的 (N, H, W) 子张量做归一化(减均值、除标准差、缩放、平移)。不改任何形状
- ReLU:逐元素非线性 max(0, x)。不改任何形状
- MaxPool2d:窗口内取最大,按 stride 降采样。只改变 H, W
张量形状变化总览
| 步骤 | 输入形状 | 输出形状 | 变化 |
|---|---|---|---|
| Conv2d(3→64, 3×3, pad=1) | (N, 3, 224, 224) | (N, 64, 224, 224) | C: 3→64,HW 不变 |
| BatchNorm2d(64) | (N, 64, 224, 224) | (N, 64, 224, 224) | 形状不变 |
| ReLU(inplace=True) | (N, 64, 224, 224) | (N, 64, 224, 224) | 形状不变 |
| MaxPool2d(2, 2) | (N, 64, 224, 224) | (N, 64, 112, 112) | HW 减半 |
import torch
import torch.nn as nn# ===== 1. Conv2d:特征提取 =====
# 关键参数:
# in_channels:输入通道数
# out_channels:输出通道数(卷积核个数)
# kernel_size:卷积核大小(int 或 tuple)
# stride=1, padding=0, dilation=1, groups=1, bias=True
conv = nn.Conv2d(in_channels=3, out_channels=64,kernel_size=3, stride=1, padding=1, bias=False)
x = torch.randn(8, 3, 224, 224) # [N=8, C=3, H=224, W=224]
y = conv(x)
print(y.shape) # torch.Size([8, 64, 224, 224]) ← padding=1 保尺寸
print(conv.weight.shape) # torch.Size([64, 3, 3, 3]) ← [out_C, in_C, kH, kW]# ===== 2. BatchNorm2d:批归一化 =====
# 关键参数:
# num_features:输入通道数(与上一 Conv 的 out_channels 一致)
# eps=1e-5, momentum=0.1, affine=True, track_running_stats=True
bn = nn.BatchNorm2d(num_features=64)
y_bn = bn(y)
print(y_bn.shape) # torch.Size([8, 64, 224, 224]) ← 形状不变
# 训练时:使用当前 batch 的均值/方差,并更新 running_mean / running_var
# 推理时:使用累计的 running_mean / running_var# ===== 3. ReLU:逐元素非线性 =====
# 关键参数:inplace=False(默认)
relu = nn.ReLU(inplace=True) # inplace=True 节省显存
y_relu = relu(y_bn)
print(y_relu.shape) # torch.Size([8, 64, 224, 224]) ← 形状不变
# 等价函数式:torch.nn.functional.relu(x, inplace=True)# ===== 4. MaxPool2d:下采样 =====
# 关键参数:kernel_size, stride=None(=kernel_size), padding=0, dilation=1
pool = nn.MaxPool2d(kernel_size=2, stride=2)
y_pool = pool(y_relu)
print(y_pool.shape) # torch.Size([8, 64, 112, 112]) ← H/W 各减半
import torch
import torch.nn as nndef conv_bn_relu_pool(in_c, out_c):return nn.Sequential(nn.Conv2d(in_c, out_c, kernel_size=3, stride=1, padding=1, bias=False),nn.BatchNorm2d(out_c),nn.ReLU(inplace=True),nn.MaxPool2d(kernel_size=2, stride=2),)# 串联两个块:3 → 64 → 128
block1 = conv_bn_relu_pool(3, 64)
block2 = conv_bn_relu_pool(64, 128)x = torch.randn(8, 3, 224, 224)
x = block1(x) # [8, 64, 112, 112]
x = block2(x) # [8, 128, 56, 56]
print(x.shape) # torch.Size([8, 128, 56, 56])
关键细节 — 每一层的「不变」与「变」
- 不改变张量形状:BatchNorm2d、ReLU、Dropout2d(只要不写在最后一步)
- 只改变通道数 C:Conv2d(通过 in_channels ≠ out_channels)
- 只改变空间尺寸 H, W:MaxPool2d、AvgPool2d、AdaptiveAvgPool2d,以及 stride > 1 / padding ≠ 0 的 Conv2d
- 同时改变 C、H、W:跨 stage 的过渡层(如 ResNet 中 stride=2 的 1×1 卷积)
- 不引入可学习参数:ReLU、MaxPool2d、AvgPool2d
本节小结
- 标准 CNN 块:Conv2d → BatchNorm2d → ReLU → MaxPool2d
- Conv2d:改变通道数 C,可改变 HW
- BatchNorm2d / ReLU:不改任何形状
- MaxPool2d:按 stride 缩小 HW,不改变 C
- 工程建议:把一个完整块封装为 nn.Sequential,再在外层 forward() 中调用
三、完整 forward 链路:从像素到 logits 的形状追踪
What — 一个完整的 CNN forward 链路长什么样?
把若干「标准块」串起来,再加上「全局平均池化 → Flatten → Linear」分类头,就构成了一个完整的 CNN forward 链路。在 PyTorch 中,这通常通过继承 nn.Module 并实现 forward(self, x) 方法来定义;__init__ 中定义所有子模块,forward 中按顺序调用。
forward 方法的两条黄金规则
- __init__ 中只能放「带参数的 nn.Module 子类」或「会被 self.xxx = ... 持有」的层。例如 self.conv1 = nn.Conv2d(...)。直接放 x = x + 1 这种操作不会被 PyTorch 识别为「网络的一部分」
- forward(self, x) 中写「数据流动的计算图」。可以使用任何 PyTorch 操作(torch.cat、F.relu、索引、算术运算),但不要在 forward 中写 if self.training: 之外的「需要梯度的控制流」(即不要用未追踪的 Python 条件控制「参数创建」)
import torch
import torch.nn as nn
import torch.nn.functional as Fclass SimpleCNN(nn.Module):# Simplified VGG-style CNN for image classification# Input: (N, 3, 224, 224)# Output: (N, num_classes) logitsdef __init__(self, num_classes=10):super().__init__()# Feature extractor (convolutional backbone)# Block 1: 3 -> 64, 224 -> 112self.block1 = nn.Sequential(nn.Conv2d(3, 64, kernel_size=3, padding=1, bias=False),nn.BatchNorm2d(64),nn.ReLU(inplace=True),nn.Conv2d(64, 64, kernel_size=3, padding=1, bias=False),nn.BatchNorm2d(64),nn.ReLU(inplace=True),nn.MaxPool2d(kernel_size=2, stride=2),)# Block 2: 64 -> 128, 112 -> 56self.block2 = nn.Sequential(nn.Conv2d(64, 128, kernel_size=3, padding=1, bias=False),nn.BatchNorm2d(128),nn.ReLU(inplace=True),nn.Conv2d(128, 128, kernel_size=3, padding=1, bias=False),nn.BatchNorm2d(128),nn.ReLU(inplace=True),nn.MaxPool2d(kernel_size=2, stride=2),)# Block 3: 128 -> 256, 56 -> 28self.block3 = nn.Sequential(nn.Conv2d(128, 256, kernel_size=3, padding=1, bias=False),nn.BatchNorm2d(256),nn.ReLU(inplace=True),nn.MaxPool2d(kernel_size=2, stride=2),)# Classifier headself.gap = nn.AdaptiveAvgPool2d(output_size=(1, 1))self.flatten = nn.Flatten(1)self.dropout = nn.Dropout(p=0.5)self.fc = nn.Linear(256, num_classes)def forward(self, x):# Feature extractionx = self.block1(x) # [N, 3, 224, 224] -> [N, 64, 112, 112]x = self.block2(x) # [N, 64, 112, 112] -> [N, 128, 56, 56]x = self.block3(x) # [N, 128, 56, 56] -> [N, 256, 28, 28]# Classifier headx = self.gap(x) # [N, 256, 28, 28] -> [N, 256, 1, 1]x = self.flatten(x) # [N, 256, 1, 1] -> [N, 256]x = self.dropout(x) # [N, 256] -> [N, 256]x = self.fc(x) # [N, 256] -> [N, num_classes]return x # logits (no softmax)# Verify forward pipeline
model = SimpleCNN(num_classes=10)
x = torch.randn(2, 3, 224, 224)
y = model(x)
print(y.shape) # torch.Size([2, 10])# Print each submodule name
for name, module in model.named_children():print(f"{name}: {type(module).__name__}")
假设输入:x.shape = (N=2, C=3, H=224, W=224)步骤 输出形状 备注
─────────────────────────────────────────────────────────────────────────────
x = 输入图像 (2, 3, 224, 224) 像素
x = self.block1(x) (2, 64, 112, 112) 两次 Conv + 一次 Pool
x = self.block2(x) (2, 128, 56, 56) 两次 Conv + 一次 Pool
x = self.block3(x) (2, 256, 28, 28) 一次 Conv + 一次 Pool
x = self.gap(x) (2, 256, 1, 1) 自适应全局平均池化
x = self.flatten(x) (2, 256) 扁平化
x = self.dropout(x) (2, 256) 随机失活
x = self.fc(x) (2, 10) 全连接分类
return x (2, 10) logits总可学习参数(num_classes=10 时):Conv + BN:~ 1.1M Dropout + Linear:~ 2.5K 合计约 1.1M
常见陷阱
- forward 不要写 return self.net(x) 之外的「重复调用同一个模块」:同一个模块被多次使用在 forward 里时,它的权重会被多个分支共享(语义上等价于「跨层权重共享」)
- forward 中不能用 if … else 创建新的 nn.Module:必须把所有层都在 __init__ 中创建
- 不要在 forward 中使用 print 调试梯度:用 register_forward_hook(第七节讲解)
- forward 返回值:分类任务通常返回 logits(不 softmax),损失函数 nn.CrossEntropyLoss 内部会自动 softmax;如果先手动 softmax 反而会导致数值不稳定
本节小结
- forward 链路:__init__ 中定义所有子模块,forward 中串联调用
- 典型链路:若干 Conv block → AdaptiveAvgPool2d → Flatten → Dropout → Linear
- 输出:logits(不 softmax),由 nn.CrossEntropyLoss 内部处理
- 形状追踪:在脑中画出每一步 (N, C, H, W) 的变化,是设计 CNN 的基本功
四、用 nn.Sequential 组装网络:替代手写 forward
What — nn.Sequential 是什么?
torch.nn.Sequential 是 PyTorch 提供的一个顺序容器:把若干 nn.Module 子类按顺序放进一个列表中,Sequential 内部按顺序依次调用每个子模块,等价于手写 x = self.layer1(x); x = self.layer2(x); ...。当网络链路是「严格的线性流」(没有分支、没有跨层跳跃)时,用 nn.Sequential 替代手写 forward 可以显著减少样板代码。
两种写法对比
| 手写 forward | nn.Sequential |
|---|---|
class Net(nn.Module):def __init__(self):super().__init__()self.conv1 = nn.Conv2d(...)self.bn1 = nn.BatchNorm2d(...)self.relu1 = nn.ReLU()self.pool1 = nn.MaxPool2d(...)def forward(self, x):x = self.conv1(x)x = self.bn1(x)x = self.relu1(x)x = self.pool1(x)return x |
class Net(nn.Module):def __init__(self):super().__init__()self.block = nn.Sequential(nn.Conv2d(...),nn.BatchNorm2d(...),nn.ReLU(),nn.MaxPool2d(...),)def forward(self, x):return self.block(x) |
两种写法在数学上完全等价。但 nn.Sequential 在「print(model)」时会自动缩进展示每个子模块,便于调试。
import torch.nn as nn# ===== 方式 1:直接传位置参数(最常见)=====
seq1 = nn.Sequential(nn.Conv2d(3, 64, 3, padding=1),nn.BatchNorm2d(64),nn.ReLU(inplace=True),nn.MaxPool2d(2, 2),
)# ===== 方式 2:用 OrderedDict 给每层命名(打印模型时名字更清晰)=====
from collections import OrderedDict
seq2 = nn.Sequential(OrderedDict([("conv1", nn.Conv2d(3, 64, 3, padding=1)),("bn1", nn.BatchNorm2d(64)),("relu1", nn.ReLU(inplace=True)),("pool1", nn.MaxPool2d(2, 2)),
]))# ===== 方式 3:用 add_module 动态添加 =====
seq3 = nn.Sequential()
seq3.add_module("conv1", nn.Conv2d(3, 64, 3, padding=1))
seq3.add_module("bn1", nn.BatchNorm2d(64))
seq3.add_module("relu1", nn.ReLU(inplace=True))
seq3.add_module("pool1", nn.MaxPool2d(2, 2))# ===== 三种方式打印对比 =====
print("--- seq1 ---")
print(seq1)
print("--- seq2 ---")
print(seq2)
# seq2 will display: 0, 1, 2, 3
# seq2 with OrderedDict will display: conv1, bn1, relu1, pool1
import torch
import torch.nn as nnclass SimpleCNN_v2(nn.Module):# Whole network defined with Sequential; forward is one-linerdef __init__(self, num_classes=10):super().__init__()self.features = nn.Sequential(# Block 1: 3 -> 64, 224 -> 112nn.Conv2d(3, 64, 3, padding=1, bias=False),nn.BatchNorm2d(64),nn.ReLU(inplace=True),nn.Conv2d(64, 64, 3, padding=1, bias=False),nn.BatchNorm2d(64),nn.ReLU(inplace=True),nn.MaxPool2d(2, 2),# Block 2: 64 -> 128, 112 -> 56nn.Conv2d(64, 128, 3, padding=1, bias=False),nn.BatchNorm2d(128),nn.ReLU(inplace=True),nn.Conv2d(128, 128, 3, padding=1, bias=False),nn.BatchNorm2d(128),nn.ReLU(inplace=True),nn.MaxPool2d(2, 2),# Block 3: 128 -> 256, 56 -> 28nn.Conv2d(128, 256, 3, padding=1, bias=False),nn.BatchNorm2d(256),nn.ReLU(inplace=True),nn.MaxPool2d(2, 2),)self.classifier = nn.Sequential(nn.AdaptiveAvgPool2d(1), # -> (N, 256, 1, 1)nn.Flatten(1), # -> (N, 256)nn.Dropout(0.5),nn.Linear(256, num_classes),)def forward(self, x):x = self.features(x)x = self.classifier(x)return x# Usage
model = SimpleCNN_v2(num_classes=10)
x = torch.randn(2, 3, 224, 224)
y = model(x)
print(y.shape) # torch.Size([2, 10])
什么时候用 nn.Sequential,什么时候必须手写 forward?
- 用 nn.Sequential:线性流,无分支(如 VGG、AlexNet 的 backbone 部分)
- 必须手写 forward:存在分支 / 跨层跳跃(如 ResNet 的残差加法、U-Net 的 skip connection)、多任务输出、条件分支
- 最佳实践:大结构用 nn.Sequential 简化代码,需要特殊逻辑的部分抽成独立子模块 + 手写 forward
本节小结
- nn.Sequential:顺序容器,自动按顺序调用每个子模块
- 三种构造方式:位置参数 / OrderedDict / add_module
- 适用场景:线性流网络;带分支的网络仍需手写 forward
- 工程建议:特征提取器常用 nn.Sequential,分类头也常一起放在 nn.Sequential
五、train / eval 模式:BatchNorm 与 Dropout 的行为差异
What — model.train() 与 model.eval() 改变了什么?
PyTorch 的 nn.Module 有两种运行模式,由 self.training 布尔属性控制(默认 True):
- model.train():训练模式。BatchNorm 用当前 batch 的均值/方差,Dropout 随机失活
- model.eval():推理模式。BatchNorm 用累计的 running_mean/running_var,Dropout 不失活
这两条规则只对某些特定层生效;对 Conv2d、Linear、ReLU、MaxPool2d 等无模式差异的层来说,train/eval 行为完全一致。
BatchNorm2d 的两种行为
| 维度 | train() | eval() |
|---|---|---|
| 归一化使用的统计量 | 当前 batch 的均值 / 方差(按 N, H, W 维度计算) | 累计的 running_mean / running_var |
| running_mean / running_var 是否更新 | 是(按 momentum 平滑更新) | 否 |
| 反向传播的梯度 | 梯度流过当前 batch 的统计量 | running_mean / running_var 不参与梯度 |
Dropout 的两种行为
- train():按 p 概率将输入元素随机置 0,并将剩余元素除以 (1 - p)(保证均值不变)
- eval():直接返回输入,不做任何随机
import torch
import torch.nn as nnmodel = SimpleCNN(num_classes=10)
optimizer = torch.optim.SGD(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()# ===== Training loop =====
model.train() # Switch to train mode (default is True, optional)
for x, y in train_loader:optimizer.zero_grad()logits = model(x) # forward: BN uses batch stats, Dropout randomloss = criterion(logits, y) # CrossEntropyLoss = softmax + NLLLossloss.backward() # backpropoptimizer.step() # gradient descent# ===== Validation / inference =====
model.eval() # Switch to eval mode
with torch.no_grad(): # Disable gradient computationfor x, y in val_loader:logits = model(x) # forward: BN uses running stats, Dropout offpreds = logits.argmax(dim=1) # pick the class with max logitacc = (preds == y).float().mean()
import torch
import torch.nn as nntorch.manual_seed(0)
bn = nn.BatchNorm2d(num_features=3)# batch A: mean ~ 0
x_a = torch.randn(4, 3, 8, 8) * 1.0 + 0.0
# batch B: mean ~ 10
x_b = torch.randn(4, 3, 8, 8) * 1.0 + 10.0# ===== train mode: uses current batch statistics each time =====
bn.train()
y_a_train = bn(x_a)
y_b_train = bn(x_b)
print("train mode x_a mean:", y_a_train.mean().item()) # ~ 0
print("train mode x_b mean:", y_b_train.mean().item()) # ~ 0
print("running_mean after train:", bn.running_mean) # has been updated# ===== eval mode: uses accumulated running statistics =====
bn.eval()
y_a_eval = bn(x_a)
y_b_eval = bn(x_b)
print("eval mode x_a mean:", y_a_eval.mean().item()) # NOT ~ 0
print("eval mode x_b mean:", y_b_eval.mean().item()) # NOT ~ 0
关键注意事项
- model.eval() 必须与 torch.no_grad() 配合使用:eval 只切换 BN/Dropout 行为,不关闭梯度计算;要用 with torch.no_grad(): 真正关闭梯度
- 不要在 train 模式下做验证:BN 会用 batch 统计量,结果不稳定且不可复现
- 不要在 eval 模式下做训练:BN 不会更新 running_mean/running_var,running_mean 永远是初始的 0 / 1,导致 BN 失效
- 子模块的训练状态会递归传递:调用 model.eval() 会把所有子模块(包括 nn.Sequential 内的层)的 training 属性都设为 False
本节小结
- 两种模式:model.train()(训练)与 model.eval()(推理)
- BatchNorm 行为差异:train 用 batch 统计量 + 更新 running;eval 用 running 统计量 + 不更新
- Dropout 行为差异:train 随机失活;eval 不失活
- 标准范式:训练时 model.train() + 梯度计算;推理时 model.eval() + torch.no_grad()
六、输出层:logits → softmax → argmax 的语义
What — CNN 的输出到底是什么?
典型分类 CNN 的最后一层是 nn.Linear(in_features, num_classes),输出形状 (N, num_classes)。这个输出在概念上有三个不同名字、不同语义的层次:
- logits:原始输出,未经任何归一化的实数向量,值域为 (-∞, +∞)
- softmax 后:每个元素是「该类别的预测概率」,所有元素之和为 1
- argmax:取概率最大(logits 也可直接 argmax)的那一类的索引 [0, num_classes)
logits / softmax / argmax 的关系
给定 logits z = (z1, z2, …, zK):
| 操作 | 公式 | 值域 | PyTorch 调用 |
|---|---|---|---|
| softmax | p_i = exp(z_i) / Σ_j exp(z_j) | [0, 1],且 Σ p_i = 1 | torch.softmax(z, dim=1) |
| log_softmax | log(p_i) | (-∞, 0] | torch.log_softmax(z, dim=1) |
| argmax | k = argmax_i z_i | 整数 [0, K) | z.argmax(dim=1) |
| top-k | (values, indices) = topk(z, k) | 前 k 大的值与索引 | torch.topk(z, k, dim=1) |
import torch
import torch.nn.functional as F# Simulate model output: batch=2, num_classes=4
logits = torch.tensor([[2.0, 1.0, 0.5, -1.0],[0.1, 0.2, 0.3, 5.0]]) # shape (2, 4)# ===== softmax: convert logits to probability distribution =====
probs = torch.softmax(logits, dim=1)
print("softmax probs:", probs)
print("row sums:", probs.sum(dim=1)) # tensor([1., 1.]) per-row sum is 1# Numerically-stable equivalent (internally subtracts max):
probs_safe = F.softmax(logits, dim=1)
print("F.softmax == torch.softmax:", torch.equal(probs, probs_safe))# ===== argmax: directly predict class =====
pred = logits.argmax(dim=1) # argmax on logits == argmax on probs
print("pred (from logits):", pred) # tensor([0, 3])
pred_from_probs = probs.argmax(dim=1)
print("pred (from probs):", pred_from_probs) # same [0, 3]# ===== top-k: top-k values and indices =====
values, indices = torch.topk(probs, k=2, dim=1)
print("top-2 probs:", values)
print("top-2 indices:", indices)# ===== Relationship with CrossEntropyLoss =====
# nn.CrossEntropyLoss(logits, target) is equivalent to:
# log_softmax + NLLLoss == -log(p[target])
# Therefore: pass logits directly, DO NOT softmax manually
target = torch.tensor([0, 3])
ce_loss = F.cross_entropy(logits, target)
print("CrossEntropyLoss:", ce_loss.item()) # single scalar# Equivalent manual computation (for understanding)
manual = -logits.softmax(dim=1)[torch.arange(2), target].log().mean()
print("manual softmax+NLL:", manual.item()) # equals ce_loss
工程建议 — 什么时候用 softmax,什么时候用 logits?
- 训练时:把 logits 直接传给 nn.CrossEntropyLoss,不要手动 softmax(避免数值不稳定)
- 推理时:若需要概率(如 Top-5 准确率、置信度阈值),对 logits 做 torch.softmax(..., dim=1)
- 只需要预测类别:直接 logits.argmax(dim=1)(比 softmax 再 argmax 略快、且数值更稳定)
- 多标签 / 独立 sigmoid:用 torch.sigmoid(每类独立判断正负),与 softmax 互斥
本节小结
- logits:模型原始输出,未归一化
- softmax:把 logits 转成概率分布(每行和为 1)
- argmax:取最大值的索引作为预测类别
- CrossEntropyLoss:内部自动 softmax + NLL,传入 logits 即可
七、Forward Hook:用 register_forward_hook 抓取中间激活
What — 如何在 forward 链路中途拦截中间激活?
PyTorch 提供了 hook(钩子)机制:在不修改 forward 的前提下,把一段回调函数挂到任意子模块上,PyTorch 在执行该子模块的 forward 时自动调用回调。register_forward_hook 是三种 hook 中最常用的一种(前向 hook),常用于:
- 特征图可视化(如 Grad-CAM、卷积核响应)
- 诊断网络(检查某层输出是否为 NaN / Inf)
- 蒸馏 / 隐式监督(提取中间层特征作为辅助 loss)
hook 函数签名
register_forward_hook 接收一个函数 hook(module, input, output) → None | Tensor:
- module:被挂 hook 的那个子模块
- input:传给该模块的输入 tuple(长度为 1,除非模块接收多个参数)
- output:该模块的输出
- 返回值:若返回 None 则不修改 output;若返回 Tensor,则替换原 output(用于修改前向行为)
import torch
import torch.nn as nnmodel = SimpleCNN(num_classes=10)
model.eval()# Dictionary to store activations
activations = {}def save_activation(name):def hook(module, input, output):# Only save output, do not modifyactivations[name] = output.detach()return hook# Register forward hook on every nn.Conv2d
for name, module in model.named_modules():if isinstance(module, nn.Conv2d):module.register_forward_hook(save_activation(name))# Run forward once
x = torch.randn(1, 3, 224, 224)
with torch.no_grad():y = model(x)# Inspect captured activations
for name, act in activations.items():print(f"{name:20s} output shape: {tuple(act.shape)}")
# Expected:
# block1.0 output shape: (1, 64, 224, 224)
# block1.3 output shape: (1, 64, 224, 224)
# block2.0 output shape: (1, 128, 112, 112)
# ...# Key: act.detach() detaches the tensor from autograd, safe for visualization
import torch
import torch.nn as nnmodel = SimpleCNN(num_classes=10)def nan_detector_hook(module, input, output):# If a layer's output contains NaN or Inf, print the module name and statsif isinstance(output, torch.Tensor):if torch.isnan(output).any():print(f"[NaN] in {type(module).__name__}: "f"min={output.min().item():.3e}, max={output.max().item():.3e}")elif torch.isinf(output).any():print(f"[Inf] in {type(module).__name__}: "f"min={output.min().item():.3e}, max={output.max().item():.3e}")# Attach detector to every layer
for module in model.modules():module.register_forward_hook(nan_detector_hook)# Test with a possibly unstable input
x = torch.randn(2, 3, 224, 224) * 1e3 # amplify input
y = model(x)
注意事项
- register_forward_hook 返回一个 handle:调用 handle.remove() 可注销该 hook;不主动注销会一直生效
- 捕获的张量需要 .detach():否则会保留梯度计算图,导致显存增长
- 不要在 hook 中保存 input:input 的梯度计算图通常更复杂,会显著占用显存
- hook 不支持 nn.Sequential 整层挂载:必须挂到具体的子模块(nn.Conv2d、nn.BatchNorm2d 等)
本节小结
- forward hook:在不修改 forward 的前提下拦截中间激活
- 典型用法:特征可视化、NaN/Inf 诊断、知识蒸馏
- 关键点:register_forward_hook(hook),hook 签名 (module, input, output)
- 善后:handle.remove() 注销;捕获的 tensor 要 .detach()
八、推理流程:no_grad + eval + 单张图像的端到端链路
What — 一个完整的「单张图像推理」流程是怎样的?
把本章所有知识点串起来:从加载预训练权重开始,对一张 [3, 224, 224] 的图像做完整推理,最终输出 Top-5 类别概率分布。整个流程涉及:model.eval()、torch.no_grad()、数据预处理(标准化 / resize)、batch 维度添加、softmax / topk。
推理流程的 7 个标准步骤
- 加载模型权重:model.load_state_dict(torch.load(...))
- 切到 eval 模式:model.eval()
- 关闭梯度:with torch.no_grad():
- 数据预处理:transforms.ToTensor() + Normalize(mean, std) + Resize((224, 224))
- 添加 batch 维度:x.unsqueeze(0),从 [3, H, W] 到 [1, 3, H, W]
- 前向传播:logits = model(x)
- 后处理:torch.softmax + torch.topk 得到 Top-k 类别与概率
import torch
import torch.nn as nn
import torchvision.transforms as T
from PIL import Image# ===== 1. Load model (assumed pre-trained) =====
model = SimpleCNN(num_classes=1000)
model.load_state_dict(torch.load("simple_cnn.pth", map_location="cpu"))
model.eval() # switch to eval mode# ===== 2. Preprocessing (must match training) =====
preprocess = T.Compose([T.Resize(256),T.CenterCrop(224),T.ToTensor(), # PIL -> [0,1] tensor, shape (3, H, W)T.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet statisticsstd=[0.229, 0.224, 0.225]),
])# ===== 3. Load single image =====
img = Image.open("cat.jpg").convert("RGB")
x = preprocess(img) # (3, 224, 224)
x = x.unsqueeze(0) # -> (1, 3, 224, 224) add batch dim# ===== 4. Inference =====
with torch.no_grad(): # disable autogradlogits = model(x) # (1, 1000)probs = torch.softmax(logits, dim=1) # (1, 1000) probability distributiontop5_probs, top5_indices = torch.topk(probs, k=5, dim=1)# ===== 5. Post-processing & output =====
top5_probs = top5_probs.squeeze(0).tolist() # drop batch dim
top5_indices = top5_indices.squeeze(0).tolist() # list of class indices# Assuming ImageNet class mapping exists
class_names = load_imagenet_classes() # user-defined helper
for rank, (idx, p) in enumerate(zip(top5_indices, top5_probs), start=1):print(f"Top-{rank}: {class_names[idx]:25s} prob = {p:.4f}")
# Expected output (for a cat image):
# Top-1: tabby prob = 0.6214
# Top-2: tiger_cat prob = 0.2135
# Top-3: Egyptian_cat prob = 0.1142
# Top-4: Persian_cat prob = 0.0245
# Top-5: lynx prob = 0.0089
import torchmodel.eval()# A batch of 8 pre-processed images
batch_x = torch.randn(8, 3, 224, 224)with torch.no_grad():logits = model(batch_x) # (8, 1000) forward 8 images at onceprobs = torch.softmax(logits, dim=1) # (8, 1000)preds = probs.argmax(dim=1) # (8,) per-image predicted classprint("preds:", preds)
# Compared with per-image inference, batch inference fully utilizes GPU parallelism
推理优化的常用技巧
- torch.no_grad():关闭 autograd,节省约 50% 显存 + 提速 1.2~2 倍
- torch.inference_mode()(PyTorch 1.9+):比 no_grad 更激进地禁用 view-tracking,进一步加速(仅推理时用)
- 半精度推理:model.half() + x.half(),用 FP16 替代 FP32,速度翻倍 + 显存减半
- model.to("cuda"):模型移到 GPU;输入也要 x.to("cuda")
- torch.compile(PyTorch 2.0+):model = torch.compile(model),JIT 编译整张图为优化后的 CUDA 代码
本节小结
- 推理七步:加载权重 → eval → no_grad → 预处理 → 加 batch 维 → forward → softmax + topk
- 关键 API:model.eval()、torch.no_grad()、torch.softmax、torch.topk
- 优化技巧:torch.inference_mode()、FP16、torch.compile、GPU 加速
- 批量推理:一次 forward 处理 N 张图像,并行度高
九、FAQ(20 组)
FAQ — 精选 20 问,深入理解 CNN 正向传播
Q1. model(x) 和 model.forward(x) 有什么区别?
二者效果相同(都执行前向传播),但 model(x) 是 PyTorch 推荐的入口,等价于 model.__call__(x)。 __call__ 内部会做若干额外工作:处理 hooks(前向 / 反向)、调用 _global_forward_pre_hooks、确保子模块递归处理等。如果直接调用 model.forward(x),这些机制会被绕过,hook 不生效且行为可能不一致。经验法则:始终用 model(x),不要直接调 model.forward(x)。
Q2. nn.Module.__init__ 中必须调用 super().__init__() 吗?
必须调用,否则子模块注册机制会失效。 super().__init__() 会初始化父类 nn.Module 内部的 self._parameters、self._modules、self._buffers 三个有序字典,这三个字典是「self.conv = nn.Conv2d(…)」能正确注册到 model.parameters()、model.children() 的前提。漏写 super().__init__() 会导致 model.parameters() 返回空列表,model.to(device) 也不工作。
Q3. forward 中能用 torch.cat / 索引 / 算术运算吗?
可以,所有 PyTorch 张量操作都允许;只是要确保被操作的张量都来自 self.xxx = ... 持有的子模块的输出。 PyTorch 通过「所有需要梯度的张量都会构造计算图」自动追踪 forward 中的操作;不需要梯度的常量(普通 Python 数 / list)则不会被追踪。常见操作:torch.cat(U-Net 跳跃连接)、x[:, :64](通道切片)、x + residual(残差加法)等。
Q4. 为什么 nn.CrossEntropyLoss 输入是 logits 而不是概率?
因为 nn.CrossEntropyLoss 内部等价于 log_softmax + NLLLoss,输入 logits 直接计算数值更稳定(避免先 softmax 再 log 出现的 log(0) 数值问题)。 PyTorch 源码中,F.cross_entropy 先做 log_softmax(内部用减最大值技巧保证数值稳定),再取目标类别对应的负对数概率作为损失。如果传入已经 softmax 过的概率,相当于把 softmax 应用了两次,得到错误的损失。
Q5. BatchNorm2d 在 eval() 下用 running_mean,那训练初始阶段 running_mean 是多少?
初始化为 0,running_var 初始化为 1。 因此在训练的第一个 batch 之前调用 model.eval() 做推理时,BN 实际等价于「(x - 0) / sqrt(1 + eps) ≈ x」(gamma=1、beta=0 的初始化)。这意味着训练的第一个 epoch 之前,BN 几乎不做归一化;只有经过若干个 batch 之后,running_mean / running_var 才会收敛到合理的统计量。
Q6. model.train() 和 model.eval() 是 PyTorch 的方法还是属性?
是方法(model.train()、model.eval()),但内部修改的是布尔属性 self.training。 nn.Module 的 train(mode=True) 和 eval() 会递归设置所有子模块的 training 属性。BN、Dropout 等模块在 forward 中通过 if self.training: 判断当前模式。可以直接读 model.training 或 bn.training 来查看当前模式。
Q7. torch.no_grad() 和 torch.inference_mode() 有什么区别?
inference_mode 是 no_grad 的超集,额外禁用了 view-tracking,更激进地优化。 在 no_grad 下创建的张量虽然不需要梯度,但仍会保留「是否需要梯度的视图信息」;在 inference_mode 下创建的张量是「纯推理张量」,与需要梯度的张量不兼容。建议:仅在纯推理时(不需要回退到训练)用 inference_mode,需要兼容训练代码时用 no_grad。
Q8. register_forward_hook 与 register_forward_pre_hook 有什么区别?
register_forward_hook 在模块 forward 之后触发,register_forward_pre_hook 在模块 forward 之前触发。 前者签名是 hook(module, input, output),可以读取或替换 output;后者签名是 hook(module, input),可以替换 input。常见用法:pre_hook 用于「在某一层前对输入做归一化」、hook 用于「抓取中间特征做可视化」。
Q9. forward 中能调用其他模块的 forward 吗?
可以调用子模块 self.submodule(x),但不要在 forward 里临时实例化 nn.Module。 临时实例化会导致该模块不在 model._modules 中,model.parameters() 不会枚举到它,optimizer.step() 也不会更新它的权重。正确做法:所有模块都在 __init__ 中实例化并赋给 self.xxx,forward 中只调用、不创建。
Q10. CNN 输出的 logits 数值范围是多少?需要做归一化吗?
logits 是未经任何归一化的实数,值域为 (-∞, +∞),不需要归一化(也不应该归一化)。 nn.CrossEntropyLoss 内部会自动 softmax;只有当显式调用 torch.softmax(logits, dim=1) 时才需要 logits 的数值在合理范围。常见误区:手动把 logits 做 min-max / z-score 归一化再 softmax,会破坏概率分布的语义。
Q11. 推理时用 FP16(半精度)会损失精度吗?
对分类 / 检测 / 分割等常见视觉任务,影响通常在 0.1% Top-1 以内,几乎可以忽略。 FP16 的有效位数约为 FP32 的 1/8,对于「只关心 argmax 的类别预测」足够稳健。但在「需要高精度数值结果」的场合(如需要算 loss、梯度累积、概率分布的高位精度)仍用 FP32。建议:推理用 FP16,训练用 FP32 或混合精度(AMP)。
Q12. forward 中可以用 Python 的 if / for 吗?
可以用,但需要注意梯度不会被追踪到「条件分支创建的参数」上。 安全做法:所有 nn.Module 都在 __init__ 创建;forward 中的 if / for 只控制「如何调用已存在的子模块」或「如何索引张量」。如果 if 内部创建了新的 nn.Module(如 if cond: layer = nn.Linear(…)),该层不在计算图中,会导致梯度丢失。
Q13. 怎么判断一个 nn.Module 当前是 train 还是 eval?
读 module.training 属性即可,返回 Python 布尔值。 例如 print(model.training) 在 model.train() 后输出 True,在 model.eval() 后输出 False。nn.Module 子类的 __init__ 默认会把 self.training = True(即新建实例默认是 train 模式)。
Q14. forward 中能用 print 打印张量吗?会影响梯度吗?
可以用 print 调试张量,但建议打印 .shape / .dtype / .mean().item() 等元数据,避免打印大张量。 打印张量本身不会破坏梯度计算(print 是 Python 内置函数,不修改计算图),但会触发「从 GPU 同步数据到 CPU」,拖慢训练。如需频繁调试中间值,用 register_forward_hook 更高效。
Q15. nn.Sequential 和 nn.ModuleList 有什么区别?
Sequential 会自动按顺序调用每个子模块(自带 forward);ModuleList 只是「模块列表容器」,不实现 forward。 使用场景:Sequential 用于「线性流」;ModuleList 用于「需要手动决定调用顺序 / 重复次数 / 条件调用」的场景(如循环神经网络、动态网络)。ModuleList 中的模块仍会被 parameters() 枚举到。
Q16. softmax 的 dim 参数怎么选?
dim 选「类别所在的维度」。对于形状为 (N, num_classes) 的 logits,dim=1(对每一行做 softmax,使每行概率和为 1);对于形状为 (N, C, H, W) 的分割输出,dim=1(对每个像素的 C 个类别做 softmax)。 错误例子:对 (N, num_classes) 的 logits 用 dim=0,会把 N 个样本的概率加和为 1,得到完全错误的语义。
Q17. CNN 的 forward 输出一定是 logits 吗?能不能直接输出概率?
都可以:让最后一层 Linear 后接 torch.softmax 即可直接输出概率。但训练时推荐输出 logits(传给 nn.CrossEntropyLoss 内部做 log_softmax 更稳定),推理时再手动 softmax 转概率。常见工程模式:模型类只输出 logits;训练代码用 nn.CrossEntropyLoss(logits, target);推理代码对 logits 做 softmax + argmax。
Q18. 怎么把单张图像 (3, H, W) 转成 batch 维度 (1, 3, H, W)?
用 x.unsqueeze(0) 或 x[None, :, :, :]。PyTorch 的所有层都要求输入以 batch 维度开头。 这是工程上最常踩的坑之一:单张图像缺少 batch 维,直接送进模型会报 RuntimeError: Expected 4-dimensional input for 4-dimensional weight。常见做法是写一个 wrapper 函数统一处理:def predict(img): return model(preprocess(img)[None]).argmax(dim=1)。
Q19. forward 链路过长,怎么快速定位「形状出错」那一层?
在 forward 中每一步后打印 x.shape,或用 register_forward_hook 抓取所有层的输出形状。 经验法则:先确保每个 Conv / Pool 层的输入输出形状都在脑中能算出来,再写代码。如果 RuntimeError: size mismatch,99% 是某一层 in_channels 与上一层 out_channels 不一致。
Q20. model(x) 在 batch 维度为 1 时和 batch 维度大时行为一致吗?
完全一致:CNN 的卷积 / 池化 / 全连接等所有算子对 batch 维度都做相同处理。 Conv2d 的卷积核在 (N, C, H, W) 上对每个样本独立运算;BatchNorm2d 在 train 模式下按 (N, H, W) 计算统计量(N=1 时统计量退化为单样本的方差,可能不稳定)。所以 batch=1 的推理本身没问题,但 batch=1 的训练会导致 BN 失效。
FAQ 总结
- 核心 API:model(x)、model.train()、model.eval()、torch.no_grad()、register_forward_hook
- 输出层:logits(未 softmax)直接给 nn.CrossEntropyLoss;推理时手动 softmax + argmax
- 关键规则:所有 nn.Module 在 __init__ 创建;forward 只调用不创建;batch=1 推理 OK,batch=1 训练 BN 会失效
- 优化:torch.inference_mode()、FP16、torch.compile、GPU 加速
十、Roadmap 预告
从「单层算子」到「完整 forward 链路」——正向传播是 CNN 一切推理与训练的入口
本文系统讲解了 CNN 的正向传播:单层基本单元(Conv → BN → ReLU → Pool)的张量形状变化、完整 forward 链路的手写与 nn.Sequential 两种写法、train/eval 模式下 BN 与 Dropout 的差异、输出层 logits → softmax → argmax 的语义、用 forward hook 抓取中间激活、以及端到端的推理流程。掌握正向传播是后续理解反向传播、模型部署、性能分析的必经之路。
CNN 的正向传播已经讲完。下一个值得深入的关键主题:
- CNN 的反向传播:理解 conv / pool / linear 各算子的梯度是如何沿 forward 链路反传的,以及 autograd 是如何自动追踪的
- 损失函数全解:nn.CrossEntropyLoss、nn.BCEWithLogitsLoss、nn.MSELoss、nn.SmoothL1Loss 等在不同任务中的选择
- 优化器与梯度更新:SGD → SGD + Momentum → Adam → AdamW,正向传播算出的 loss 如何驱动权重更新
- 模型部署与推理优化:TorchScript / ONNX / TensorRT 导出,torch.compile 编译优化,移动端 / 边缘端部署
掌握本文的 forward 链路,你已经具备「读懂并设计任何 CNN 完整计算流程」的能力。继续探索反向传播与损失函数,会让你对训练闭环有更深的理解!