PyTorch 2.x 深度学习专题【左扬精讲】—— CNN 的正向传播:从输入图像到类别概率的完整链路

📅 2026/7/19 19:04:42 👁️ 阅读次数 📝 编程学习
PyTorch 2.x 深度学习专题【左扬精讲】—— CNN 的正向传播:从输入图像到类别概率的完整链路

PyTorch 2.x 深度学习专题【左扬精讲】—— CNN 的正向传播:从输入图像到类别概率的完整链路

卷积神经网络(Convolutional Neural Network,CNN)已经在图像分类、目标检测、语义分割等视觉任务中取得突破性成果。

无论网络结构如何演进(LeNet → VGG → ResNet → MobileNet),CNN 一次 完整的预测过程 都是同一条链路:从原始像素张量出发,依次经过若干 "Conv → Norm → ReLU → Pool" 基本单元,再通过全局池化与全连接层,输出每个类别的预测概率。这条链路在 PyTorch 中被称为 forward(正向传播)

本文聚焦 CNN 的正向传播:从单层算子(torch.nn.Conv2dtorch.nn.BatchNorm2dtorch.nn.ReLUtorch.nn.MaxPool2d)的张量形状变化,到完整 nn.Module.forward() 链路的实现方式(直接顺序 / nn.Sequential),再到激活可视化(register_forward_hook)和输出层(torch.softmaxtorch.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() 关闭梯度统计

目录

  1. 概述:CNN 正向传播是什么、为什么值得单独讲
  2. 单层基本单元:Conv → Norm → ReLU → Pool 的张量变化
  3. 完整 forward 链路:从像素到 logits 的形状追踪
  4. 用 nn.Sequential 组装网络:替代手写 forward
  5. train / eval 模式:BatchNorm 与 Dropout 的行为差异
  6. 输出层:logits → softmax → argmax 的语义
  7. Forward Hook:用 register_forward_hook 抓取中间激活
  8. 推理流程:no_grad + eval + 单张图像的端到端链路
  9. FAQ(20 组)
  10. 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 的链路逆推
CNN 正向传播链路全景图
输入图像: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 减半
标准写法 — Conv2d / BatchNorm2d / ReLU / MaxPool2d 的标准用法
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 各减半
把四步封装成 nn.Sequential 的标准块
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])

关键细节 — 每一层的「不变」与「变」

  • 不改变张量形状BatchNorm2dReLUDropout2d(只要不写在最后一步)
  • 只改变通道数 CConv2d(通过 in_channels ≠ out_channels
  • 只改变空间尺寸 H, WMaxPool2dAvgPool2dAdaptiveAvgPool2d,以及 stride > 1 / padding ≠ 0 的 Conv2d
  • 同时改变 C、H、W:跨 stage 的过渡层(如 ResNet 中 stride=2 的 1×1 卷积)
  • 不引入可学习参数ReLUMaxPool2dAvgPool2d

本节小结

  • 标准 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 方法的两条黄金规则

  1. __init__ 中只能放「带参数的 nn.Module 子类」或「会被 self.xxx = ... 持有」的层。例如 self.conv1 = nn.Conv2d(...)。直接放 x = x + 1 这种操作不会被 PyTorch 识别为「网络的一部分」
  2. forward(self, x) 中写「数据流动的计算图」。可以使用任何 PyTorch 操作(torch.catF.relu、索引、算术运算),但不要forward 中写 if self.training: 之外的「需要梯度的控制流」(即不要用未追踪的 Python 条件控制「参数创建」)
完整 forward 示例 — 一个简化的 VGG 风格 CNN
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__}")
forward 链路的张量形状追踪表
假设输入: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 可以显著减少样板代码。

两种写法对比

手写 forwardnn.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)」时会自动缩进展示每个子模块,便于调试。

nn.Sequential 的三种构造方式
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
一个用 nn.Sequential 完整定义的 CNN
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_varDropout 不失活

这两条规则只对某些特定层生效;对 Conv2dLinearReLUMaxPool2d 等无模式差异的层来说,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()
手动验证 BN 在 train/eval 下的不同输出
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)
标准写法 — logits / softmax / argmax 的等价与差异
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(用于修改前向行为)
标准用法 — 抓取所有 Conv 层的中间激活
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
诊断 NaN / Inf — 给网络加一层「异常探测器」
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.Conv2dnn.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 个标准步骤

  1. 加载模型权重model.load_state_dict(torch.load(...))
  2. 切到 eval 模式model.eval()
  3. 关闭梯度with torch.no_grad():
  4. 数据预处理transforms.ToTensor() + Normalize(mean, std) + Resize((224, 224))
  5. 添加 batch 维度x.unsqueeze(0),从 [3, H, W][1, 3, H, W]
  6. 前向传播logits = model(x)
  7. 后处理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
  • 关键 APImodel.eval()torch.no_grad()torch.softmaxtorch.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._parametersself._modulesself._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. BatchNorm2deval() 下用 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.Moduletrain(mode=True)eval() 会递归设置所有子模块的 training 属性。BN、Dropout 等模块在 forward 中通过 if self.training: 判断当前模式。可以直接读 model.trainingbn.training 来查看当前模式。

Q7. torch.no_grad()torch.inference_mode() 有什么区别?

inference_modeno_grad 的超集,额外禁用了 view-tracking,更激进地优化。no_grad 下创建的张量虽然不需要梯度,但仍会保留「是否需要梯度的视图信息」;在 inference_mode 下创建的张量是「纯推理张量」,与需要梯度的张量不兼容。建议:仅在纯推理时(不需要回退到训练)用 inference_mode,需要兼容训练代码时用 no_grad

Q8. register_forward_hookregister_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.xxxforward 中只调用、不创建。

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() 后输出 Falsenn.Module 子类的 __init__ 默认会把 self.training = True(即新建实例默认是 train 模式)。

Q14. forward 中能用 print 打印张量吗?会影响梯度吗?

可以用 print 调试张量,但建议打印 .shape / .dtype / .mean().item() 等元数据,避免打印大张量。 打印张量本身不会破坏梯度计算(print 是 Python 内置函数,不修改计算图),但会触发「从 GPU 同步数据到 CPU」,拖慢训练。如需频繁调试中间值,用 register_forward_hook 更高效。

Q15. nn.Sequentialnn.ModuleList 有什么区别?

Sequential 会自动按顺序调用每个子模块(自带 forward);ModuleList 只是「模块列表容器」,不实现 forward 使用场景:Sequential 用于「线性流」;ModuleList 用于「需要手动决定调用顺序 / 重复次数 / 条件调用」的场景(如循环神经网络、动态网络)。ModuleList 中的模块仍会被 parameters() 枚举到。

Q16. softmaxdim 参数怎么选?

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 总结

  • 核心 APImodel(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.CrossEntropyLossnn.BCEWithLogitsLossnn.MSELossnn.SmoothL1Loss 等在不同任务中的选择
  • 优化器与梯度更新:SGD → SGD + Momentum → Adam → AdamW,正向传播算出的 loss 如何驱动权重更新
  • 模型部署与推理优化:TorchScript / ONNX / TensorRT 导出,torch.compile 编译优化,移动端 / 边缘端部署

掌握本文的 forward 链路,你已经具备「读懂并设计任何 CNN 完整计算流程」的能力。继续探索反向传播与损失函数,会让你对训练闭环有更深的理解!