PyTorch04-PyTorch基础实战

四、PyTorch基础实战

4.1 ResNet

本节学习的内容有:

  • 梯度消失/爆炸的简介
  • 代码里面为什么要设计BasicBlock和Bottleneck两种结构
  • 代码里面的expansion作用

残差神经网络(ResNet)由 何恺明、张祥雨、任少卿、孙剑 等人在 2015 年(发表于 CVPR 2016)代表微软亚洲研究院(MSRA)团队提出。

核心解决的问题:网络深层化时的“退化问题”(Degradation Problem)

  • 背景痛点:在 ResNet 出现前,传统的神经网络(如 AlexNet、VGG)虽然通过增加层数能提升特征提取能力,但当网络加深到一定程度(如 20 层以上)时,训练集上的准确率反而开始下降。
  • 现象本质:这种现象并非由过拟合引起(因为训练集误差也在增大),而是因为网络过深导致梯度在反向传播中难以有效传递,使得优化算法极难收敛到优秀解。
  • ResNet 的解决方案:提出了残差学习(Residual Learning)机制。通过引入“跳跃连接(Skip Connection)”(原文中叫快捷连接(Shortcut Connection)),将网络原本需要直接拟合的目标映射 \(H(x)\) 转化为学习残差 \(F(x) = H(x) - x\) 。这种设计使得信息与梯度能够跨层无损传递,从根本上攻克了极深网络难以训练的瓶颈,成功将神经网络的深度从十几层提升到了上百层甚至上千层(如 ResNet-50, ResNet-101, ResNet-152)。

4.1.1 基本介绍

随着卷积神经网络的出现,人们发现多层卷积或全连接网络的效果通常优于单层网络。因此,很多人潜意识里认为网络的层数越多,提取特征的能力越强,效果就会越好。然而,微软研究院的何恺明、张祥雨、任少卿、孙剑等人发现,当网络加深到一定程度后,整个网络的效果反而变差了,这种现象被称为“退化现象”(Degradation Problem)。值得注意的是,通过批归一化(Batch Normalization)和恰当的权重初始化,梯度消失和梯度爆炸在很大程度上已经被缓解,但深层网络依然极难训练。他们指出,退化的本质并不是过拟合,也不是简单的梯度消失,而是极深的网络结构使得优化算法(如 SGD)难以对特征进行有效优化——让深层网络直接学习“什么都不做(恒等映射)”其实非常困难。针对这一优化难题,他们提出了 ResNet(残差神经网络)。ResNet 最核心的贡献在于引入了快捷连接(Shortcut Connection,也称跳跃连接),将输入 \(x\) 直接跨层传递并与残差部分叠加(\(F(x) + x\)​)。这相当于为信息与梯度搭建了一条“无损高速公路”,不仅让网络极易学习恒等变换,也极大地简化了深层神经网络的优化过程,真正推动了神经网络迈向数百层乃至上千层的超深时代。

4.1.1.1 残差连接

以下是对残差连接过程中相关概念与过程中精简描述。

4.1.1.1.1 核心概念拆解

在残差网络结构中,我们可以将一个基础的残差块(Residual Block)拆解为三个要素:

  • \(x\):输入数据(即进入这个残差块之前的特征图)。
  • \(H(x)\):我们最终期望这个残差块学到的目标函数/目标映射(即期望输出)。
  • \(F(x)\):卷积层/全连接层经过权重计算后实际学出的映射,即残差(Residual)。

其核心公式为:

\[H(x) = F(x) + x \quad \implies \quad F(x) = H(x) - x\]

这里的 \(F(x) = H(x) - x\) 在数学上就叫作残差(目标输出与输入的差值)。

4.1.1.1.2 详细解释:为何叠加 \(F(x) + x\) 是 ResNet 的核心突破?

要理解这个设计的精妙之处,可以从“传统的困难”和“残差的解决思路”两个角度来看:

1. 传统网络(没有 Shortcut):直接硬刚 \(H(x)\)

在传统的深层网络(如 VGG)中,数据 \(x\) 依次经过多个卷积层、激活层,网络被强行要求直接去拟合最终的目标映射 \(H(x)\)。

  • 致命痛点:假设网络加深到一定程度后,新增的这几层其实不需要改变任何特征(即理想的目标是“恒等映射” \(H(x) = x\))。
  • 现实难题:要让多个含有非线性激活函数(如 ReLU)的复杂卷积层去从 0 刚好学习出 \(H(x) = x\) 这个恒等变换,在数学和优化上是极其困难的。优化器(如 SGD)很容易在层层复杂的参数空间中迷失,导致特征表达被“搅乱”,训练误差反而上升(即退化现象)。
2. 残差网络(引入 Shortcut):拆解为 \(F(x) + x\)

ResNet 改变了思路:它不让卷积层直接去猜 \(H(x)\),而是把路径拆成两条:

  1. 主干路径(卷积层):负责学习残差 \(F(x)\)。
  2. 快捷路径(Shortcut):负责将输入 \(x\) 原封不动地直接“打横拉过去”。

最后在输出端将两者相加,得到 \(H(x) = F(x) + x\)。

3. 为什么这样设计极易优化?
  • 极致的退化兜底(学习 \(F(x) = 0\) 极其简单)

    如果新增的这几层没有任何用处,优化器只需要把这些卷积层的权重调整为 0(或者非常接近 0),此时 \(F(x) = 0\)。

    整个模块的输出就变成了:

    \[H(x) = 0 + x = x\]

    网络瞬间自动退化为一个无损传递的恒等映射!这意味着,增加残差层至少绝对不会让模型的效果变差,彻底消除了深层网络退化的风险。

  • 搭建梯度的“无损高速公路”

    在反向传播计算梯度时,根据求导法则:

    \[\frac{\partial H(x)}{\partial x} = \frac{\partial F(x)}{\partial x} + 1\]

    注意公式中的 \(+ 1\)!这意味着,哪怕主干路径的梯度 \(\frac{\partial F(x)}{\partial x}\) 因为层数太深而趋近于 0(梯度消失),总梯度项中依然保留了一个常数项 \(1\)。

    梯度可以顺着 Shortcut 这条“高速公路”直达前方的浅层网络,保证了前方的网络层永远能接收到足够的更新信号。


4.1.1.2 梯度消失与梯度爆炸

尽管何恺明等人证实了“退化现象”并非由梯度消失或梯度爆炸直接引起,但在ResNet诞生之前,梯度消失与梯度爆炸确实是阻碍神经网络加深的主要瓶颈之一。以下是对这二者的精简描述:梯度消失与梯度爆炸,本质都是在神经网络反向传播中,连乘效应被网络层数无限放大导致的数值异常现象。

  • 梯度消失(Gradient Vanishing)
    • 现象:网络前期的梯度极小,接近于 \(0\)。
    • 成因:反向传播基于链式法则求导,当每层的导数或权重小于 \(1\) 时,多层相乘会导致梯度呈指数级衰减。
    • 后果:靠近输入端的浅层网络权重无法更新,模型训练停滞。
  • 梯度爆炸(Gradient Exploding)
    • 现象:网络中的梯度极大,甚至变为无穷大(NaN / Inf)。
    • 成因:当每层的导数或权重大于 \(1\) 时,多层连乘导致梯度呈指数级暴增。
    • 后果:权重更新幅度过大,引发数值溢出,模型震荡发散乃至崩塌。

4.1.2 源码解读

以下根据torchvision的ResNet源码来解读相关概念与设计思想。

4.1.2.1 卷积核的封装

以下是ResNet的源码(vision/torchvision/models/resnet.py)文件中开头处的部分内容,其中将3x3和1x1的卷积核进行了封装,提升了代码的可读性与工程可扩展性。基于类似设计理念与目的,其他深度学习代码实现中可能会将卷积层、激活函数层和BN层封装在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
def conv3x3(in_planes: int, out_planes: int, stride: int = 1, groups: int = 1, dilation: int = 1) -> nn.Conv2d:
"""3x3 convolution with padding"""
return nn.Conv2d(
in_planes,
out_planes,
kernel_size=3,
stride=stride,
padding=dilation,
groups=groups,
bias=False,
dilation=dilation,
)


def conv1x1(in_planes: int, out_planes: int, stride: int = 1) -> nn.Conv2d:
"""1x1 convolution"""
return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False)
...

4.1.2.2 基本模块的设计

4.1.2.2.1 ImageNet 架构设计

ResNet 网络是由结构高度统一的模块重复堆叠而成的(如附图中的 \([\dots] \times 3\),表示将该残差块连续堆叠 3 次)。为了提升代码的可读性与工程可扩展性,ResNet 采用了模块化设计思想:针对不同深度的模型,分别抽象封装出 BasicBlock(基础残差块) 和 Bottleneck(瓶颈残差块) 两个核心类。在构建不同规模的网络(如 ResNet-18 至 ResNet-152)时,只需灵活调整传入的模块类型以及各阶段(Stage)的堆叠次数即可实现高效复用。

ResNet常见的大小有下图的ResNet-18,ResNet-34,ResNet-50、ResNet-101和ResNet-152,其中网络后面的数字代表的是网络的层数。

image-20260828175030500

这张图来自于 ResNet 原论文(Deep Residual Learning for Image Recognition),展示了 ResNet-18、34、50、101 和 152 这五种经典残差网络架构的具体层数配置与计算量对比。

4.1.2.2.1.1 表格列与行的含义
  • layer name(层名称):网络从输入到输出划分为 conv1 以及 conv2_x ~ conv5_x 共 5 个阶段(Stage)。
  • output size(特征图输出尺寸):输入图片假设为 \(224 \times 224\)。随着网络深入,特征图分辨率逐步下采样为 \(112 \times 112 \to 56 \times 56 \to 28 \times 28 \to 14 \times 14 \to 7 \times 7\)。
  • 各网络列(18/34/50/101/152-layer):方括号中代表残差块(Residual Block)的内部结构,右侧的 ×N 代表该残差块在当前阶段重复堆叠的次数。
  • FLOPs(浮点运算量):衡量模型计算复杂度的指标。层数越深,计算量越大(从 \(1.8 \times 10^9\) 增加至 \(11.3 \times 10^9\))。
4.1.2.2.1.2 核心架构区别:基础残差块 vs 瓶颈结构(Bottleneck)

表格中将 ResNet 明显分为了两大类别:

  1. 浅层网络(ResNet-18 / ResNet-34):使用基础块(Basic Block)
  • 结构:每一个残差块由两个 \(3 \times 3\) 的卷积层组成,如 \(\begin{bmatrix} 3\times 3, 64 \\ 3\times 3, 64 \end{bmatrix}\)。
  • 特点:输入通道数与输出通道数保持一致(例如均为 64),结构简单直接。
  1. 深层网络(ResNet-50 / ResNet-101 / ResNet-152):使用瓶颈块(Bottleneck Block)
  • 结构:每一个残差块改用三个卷积层组合,如 \(\begin{bmatrix} 1\times 1, 64 \\ 3\times 3, 64 \\ 1\times 1, 256 \end{bmatrix}\)。
    1. 第一层 \(1 \times 1\):降维(例如将 256 维降到 64 维。其中64就是卷积核的输出通道大小),减少后续 \(3 \times 3\) 卷积的计算量。
    2. 第二层 \(3 \times 3\):在低维空间进行空间特征提取。
    3. 第三层 \(1 \times 1\):升维(将 64 维恢复/拓展到 256 维)。
  • 目的:在层数急剧增加时控制参数量和计算复杂度,使得 ResNet-50 虽然层数是 ResNet-34 的 1.5 倍,但 FLOPs(\(3.8 \times 10^9\))与 ResNet-34(\(3.6 \times 10^9\))非常接近。
4.1.2.2.1.3 层数是如何计算出来的?(以 ResNet-50 为例)

所谓“ResNet-50”中的 50 层,指的是带可学习权重参数的卷积层和全连接层的总和(不计入 Pooling 层与 Activation 层):

  • conv1:1 层(\(7 \times 7\) 卷积)
  • conv2_x:\(3 \text{ 个 Bottleneck} \times 3 \text{ 层} = 9\) 层
  • conv3_x:\(4 \text{ 个 Bottleneck} \times 3 \text{ 层} = 12\) 层
  • conv4_x:\(6 \text{ 个 Bottleneck} \times 3 \text{ 层} = 18\) 层
  • conv5_x:\(3 \text{ 个 Bottleneck} \times 3 \text{ 层} = 9\) 层
  • fc:1 层(全连接分类层,1000-d fc)

\[\text{总层数} = 1 + 9 + 12 + 18 + 9 + 1 = 50 \text{ 层}\]

同理,ResNet-101 和 ResNet-152 主要是在 conv4_x 阶段大幅增加了残差块的重复次数(分别重复 23 次和 36 次),用来抽取更高阶的语义特征。

4.1.2.2.1.4 附:图表中瓶颈块是指什么

瓶颈块(Bottleneck Block) 在图表中指的就是 ResNet-50、101 和 152 这三列中,所有由三个卷积层组合构成的方括号结构整体。

  1. 图表中的具体位置与形态

在图表右侧的 50-layer、101-layer 和 152-layer 列中,每个 Stage 里面的一个方括号单元就是一个瓶颈块。

例如以 50-layer 的 conv2_x 阶段为例:

\[\begin{bmatrix} 1\times 1, 64 \\ 3\times 3, 64 \\ 1\times 1, 256 \end{bmatrix} \times 3\]

这整组方括号括起来的内容,就是一个标准的瓶颈模块:

  • 括号内部的 3 行:代表这个瓶颈块内部顺序连接的 3 个卷积层。
    1. \(1 \times 1, 64\)(降维层:把输入通道压缩到 64)
    2. \(3 \times 3, 64\)(空间特征提取层:在 64 通道的低维空间运算)
    3. \(1 \times 1, 256\)(升维层:把通道重新恢复/扩充到 256)
  • 右侧的 \(\times 3\):代表将这一个瓶颈模块按顺序连续堆叠 3 次。
  1. 为什么叫“瓶颈(Bottleneck)”?

它被称为“瓶颈”是因为其通道数呈现出 “大 \(\to\) 小 \(\to\) 大” 的两头宽、中间窄的物理形状:

1
2
3
4
5
6
7
8
9
输入通道 (256) 
│
[1x1 卷积] ──> 降维到 64 ──────┐
├─> 【瓶颈区域】:中间的 3x3 卷积只在 64 维低维处理
[3x3 卷积] ──> 保持 64 ──────┘ 计算量被极大地限制住了!
│
[1x1 卷积] ──> 恢复到 256
│
输出通道 (256)

数据流经过中间的 \(3 \times 3\) 卷积层时,特征通道被压缩成了一个“窄瓶颈”,以此大幅降低了参数量和计算开销。

4.1.2.3 downsample操作

下采样的核心定义

在图像处理与深度学习中,下采样(Downsampling / Spatial Downsampling) 指的是降低特征图(Feature Map)的空间尺寸(即高度 \(H\) 和宽度 \(W\))。

例如,将 \(56 \times 56\) 的特征图缩小为 \(28 \times 28\),或者将 \(224 \times 224\) 的输入图片缩小为 \(112 \times 112\)。其主要目的有两个:

  • 扩大感受野(Receptive Field):随着空间尺寸缩小,后续卷积核在输入图像上“看”到的相对区域变大,从而能够捕获更高层级的全局语义特征。
  • 降低计算量与内存占用:高宽减半后,特征图的空间面积变为原来的 \(\frac{1}{4}\),大幅减少了后续各层卷积的浮点运算量(FLOPs)和显存开销。

ResNet 中实现下采样的两种方式

在传统的 CNN(如 VGG)中,下采样通常是由独立的 最大池化层(MaxPool) 完成的。而在 ResNet 中,除了输入层紧跟的 MaxPool 外,大部分下采样均采用 带步长的卷积(Strided Convolution) 实现:

  1. 主干路径(Main Branch):在每个 Stage(如 conv3_x, conv4_x, conv5_x)的第一个残差块中,将卷积层的步长设置为 stride = 2。
    • 特征图尺寸计算:\(H_{out} = \lfloor \frac{H_{in}}{2} \rfloor\),直接实现高宽减半。
  2. 快捷旁路(Shortcut Branch / downsample 模块):由于主干路径输出了高宽减半的特征图,旁路为了能与主干输出执行相加操作,也必须同步将输入的空间尺寸减半。因此,旁路的 \(1 \times 1\) 卷积也会配置 stride = 2。

“空间下采样”与“通道维度变换”的区别

在理解 ResNet 的 downsample 旁路模块时,极易混淆这两个概念:

  • 空间下采样(Spatial Downsampling):关注的是 高宽(\(H \times W\)) 的压缩,由 stride = 2 控制。
  • 通道维度变换(Channel Alignment / Expansion):关注的是 通道数(\(C\)) 的改变(如 64 \(\to\) 256),由 \(1 \times 1\) 卷积核的数量 控制。

这就是为什么你在 conv2_x 中看到的 downsample 旁路:

  • 它的 stride = 1:说明它没有发生空间下采样(高宽维持 \(56 \times 56\) 不变);
  • 它的 卷积核数量为 256:它只执行了通道升维与维度对齐。

而在 conv3_x 的开头,downsample 旁路的 stride = 2 且 卷积核数量为 512,此时它才同时兼具了“空间下采样”与“通道对齐”两大功能。

downsample 这个模块名在 ResNet 代码里其实是个"双功能"模块:当 stride=2 时它同时做空间下采样 + 通道对齐;当 stride=1 时只做通道对齐。conv2_x 属于后一种情况(stride=1),所以它只是"对齐通道",不是"下采样"。conv3_x / conv4_x / conv5_x 中其实还存在利用downsample 模块实现只下采样、未对齐通道的操作。所以说downsample 模块可以实现如下功能:“空间下采样” 或 “通道对齐”。

画图分析

正如上面图表中内容所示,根据残差块结构的特征差异(是否包含瓶颈层结构、convN_x的输入通道数与最终输出通道数是否相同),可以将ResNet-18,ResNet-34,ResNet-50、ResNet-101和ResNet-152划分为两大类:ResNet-18、ResNet-34只包含浅层网络属于一类,ResNet-50、ResNet-101和ResNet-152包含深层网络属于第二类。

torchvision 官方源码同样采用了这种模块化设计思想,分别封装了如下图所示的 BasicBlock(左)和 Bottleneck(右)两个核心模块。在源码实现中,残差块最终输出的通道数通过 expansion 参数进行控制:两个模块均接收通道数为 in_planes 的特征图,并统一输出通道数为 planes * block.expansion 的特征图。其中,BasicBlock 的 expansion 设为 1(输出通道等于 planes),而 Bottleneck 的 expansion 设为 4(输出通道扩充为 planes 的 4 倍)。此外,右侧 Shortcut 支路上的 downsample 操作,正是用于在做残差叠加前解决输入 \(x\) 与主干输出 \(F(x)\) 空间尺寸或通道维度不匹配问题的线性映射分支(恒等映射或投影映射)。

677ca58e-1358-43c7-9709-1fc3ed2a3392

4.1.2.4 Shortcut Connection

下图展示了shortcut connection 的过程:

image-20260829225614504

Shortcut Connection(快捷连接) 是跨越卷积层将输入 \(x\) 直连至输出端的旁路结构,让网络只需拟合残差 \(\mathcal{F}(x)\) 以缓解深层退化与梯度消失;当输入 \(x\) 与主干输出 \(\mathcal{F}(x)\) 维度相同时,采用无参数的 恒等映射 直接相加(\(y = \mathcal{F}(x) + x\));当因下采样或通道变化导致维度不一致时,则采用 投影映射 ,通过 \(1 \times 1\) 卷积调整 \(x\) 的尺寸与通道数对齐后再行叠加(\(y = \mathcal{F}(x) + W_s x\))。关于调整x尺寸,其中情况可以大致描述如下:

这里的“尺寸”包含两个层面:通道尺寸(Channel) 和 空间尺寸(Height / Width)。

\(1 \times 1\) 卷积不仅能改变通道数,只要把步长(stride)设置为 2,就能同时将图像的高和宽缩小一半,实现空间尺寸的调整。

具体通过以下两种方式完成调整:

  1. 调整通道尺寸(Channel Dimension)

    • 原理:通过控制 \(1 \times 1\) 卷积核的数量(即 out_channels)来改变通道数。

    • 例子:输入特征图形状为 \([C_{\text{in}}=64, H=56, W=56]\),使用 256 个 \(1 \times 1\) 卷积核(stride=1),输出特征图就会变为 \([C_{\text{out}}=256, H=56, W=56]\)。

  2. 调整空间尺寸(Height & Width)

    • 原理:将 \(1 \times 1\) 卷积的步长设置为 stride=2。此时卷积核在图像上隔一个像素跳着滑动采样,从而使输出的高和宽变为原来的 \(\frac{1}{2}\)。

    • 代入公式:

      根据尺寸计算公式:

      \[H_{\text{out}} = \lfloor \frac{H_{\text{in}} + 2 \times \text{padding} - \text{kernel\_size}}{\text{stride}} \rfloor + 1\]

      当输入 \(H_{\text{in}} = 56\),kernel_size = 1, padding = 0, stride = 2 时:

      \[H_{\text{out}} = \lfloor \frac{56 + 0 - 1}{2} \rfloor + 1 = 27 + 1 = 28\]

    • 例子:输入形状为 \([64, 56, 56]\),使用步长为 2 的 \(1 \times 1\) 卷积(假设输出通道设为 128),输出特征图就会变为 \([128, 28, 28]\)。

  3. 总结(Shortcut 支路中的 Downsample)

在 ResNet 的 Shortcut 支路上,当需要进行 downsample 时,正是使用了 stride=2 且改变卷积核数量的 \(1 \times 1\) 卷积,一次性将输入 \(x\) 的通道数加倍并将其高宽减半,完美对齐了主干分支 \(F(x)\) 的维度与尺寸。

image-20260829225456318

上图展示了ImageNet的示例网络构架图。其中左图表示VGG-19模型的网络构架图($19.6 ^{9} $ FLOPs ),作为参考。中图表示一个有34个参数层的普通网络($3.6 ^{9} $ FLOPs )。右图表示一个有34个参数层的残差网络($3.6 ^{9} $ FLOPs )。

对比中图与右图可以看到,右图中添加了shortcut connection操作,且这种shortcut connection操作只发生在堆叠的残差模块之间。这种shortcut connection操作分为两种:恒等映射、投影映射,分别对应右图中最右侧的实曲线、虚曲线。只有当特征图的通道数或空间尺寸发生变化时,才会用到投影映射,保证块的输入与最终输出在通道维度与空间尺寸上同时完成对齐。比如跨 Stage 的 “3x3 conv, 128, /2”卷积层中指定通道数变为128、步长Stride=2,这样会将特征图的通道数变为原来的2倍、空间尺寸变为原来的1/2,此时,旁路(Shortcut 支路)通过施加一个步长为 2 的 \(1 \times 1\) 卷积(配置 128 个卷积核)进行线性映射,使输入 \(x\) 的空间尺寸与通道维度同时与主干输出对齐,从而确保两路特征图能够正常执行逐元素相加。

在 ResNet 网络结构中,无论是 BasicBlock 还是 BottleNeck 结构均包含了残差连接(Shortcut Connection)操作,确保块的输入与最终输出在通道维度上完成对齐。BasicBlock采用两次标准 \(3 \times 3\) 卷积直接进行特征提取,适用于浅层网络。BottleNeck对残差主干进行了模块化优化。首先通过 \(1 \times 1\) 卷积减少通道数(降维),接着在低维空间利用 \(3 \times 3\) 卷积提取空间特征,最后再用 \(1 \times 1\) 卷积恢复通道数(升维),最终将输入 \(x\) 与主干输出 \(F(x)\) 相加。这种“降维-卷积-升维”的设计在保持特征表达能力的同时,相比同等深度的纯 \(3 \times 3\) 卷积极大地压缩了参数量与计算量,使得 ResNet 能够在堆叠更多层数(如 50、101、152 层)时依然保持高效的计算性能与优异的准确率。

4.1.2.5 BasicBlock

前面也提到过,BasicBlock模块是用来构建resnet-18和resnet-34的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class BasicBlock(nn.Module):
"""基础残差块(BasicBlock),用于 ResNet-18 / ResNet-34。

结构: [3×3 conv → BN → ReLU → 3×3 conv → BN] + identity shortcut

当输入通道数与输出通道数不匹配时(stride≠1 或 inplanes≠planes),
跳跃分支通过 downsample(1×1 conv + BN)对齐维度。
"""

expansion: int = 1 # 输出通道扩展倍数,BasicBlock 不扩展通道(输出通道 = planes)

def __init__(
self,
# 输入通道数
inplanes: int,
# 基础输出通道数,实际输出通道 = planes * expansion = planes * 1 = planes
planes: int,
# 第一个 3×3 卷积的步长,控制是否下采样(stride=2 时高宽减半)
stride: int = 1,
# 下采样分支(Sequential),用于对齐跳跃分支与主分支的维度
# 当 stride≠1 或 inplanes≠planes 时传入,否则为 None
downsample: Optional[nn.Module] = None,
# ---------- 以下参数与基础 ResNet 无关,用于 ResNeXt / Wide ResNet 等变体 ----------
# 分组卷积的组数,BasicBlock 仅支持 groups=1(基础 ResNet 不使用分组卷积)
groups: int = 1,
# 每个组的基础通道数,BasicBlock 仅支持 base_width=64(基础 ResNet 不使用)
base_width: int = 64,
# 空洞卷积的空洞率,BasicBlock 不支持空洞卷积(dilation>1 会报错)
# 仅在分割等任务中使用空洞卷积时才需要,基础 ResNet 不使用
dilation: int = 1,
# --------------------------------------------------------------------------------
# 归一化层,默认使用 BatchNorm2d,可替换为 GroupNorm 等
norm_layer: Optional[Callable[..., nn.Module]] = None,
) -> None:
super().__init__()
if norm_layer is None:
norm_layer = nn.BatchNorm2d
# BasicBlock 不支持分组卷积和空洞卷积
if groups != 1 or base_width != 64:
raise ValueError("BasicBlock only supports groups=1 and base_width=64")
if dilation > 1:
raise NotImplementedError("Dilation > 1 not supported in BasicBlock")

# 主分支:两个 3×3 卷积
# 第一个 conv 的 stride 控制下采样,第二个 conv stride=1 保持尺寸
self.conv1 = conv3x3(inplanes, planes, stride)
self.bn1 = norm_layer(planes)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(planes, planes)
self.bn2 = norm_layer(planes)

# 跳跃分支:下采样模块(仅当维度不匹配时传入)
self.downsample = downsample
self.stride = stride

def forward(self, x: Tensor) -> Tensor:
"""前向传播:主分支输出 + 跳跃分支输出(残差连接)"""
identity = x

# ── 主分支 ──
out = self.conv1(x) # 3×3 conv(可能 stride=2 下采样)
out = self.bn1(out) # BN
out = self.relu(out) # ReLU

out = self.conv2(out) # 3×3 conv(stride=1)
out = self.bn2(out) # BN

# ── 跳跃分支(shortcut)──
# 如果 downsample 不为 None,说明维度不匹配,需要对 identity 做 1×1 conv + BN
if self.downsample is not None:
identity = self.downsample(x)

# 残差连接:逐元素相加
out += identity
out = self.relu(out) # 相加后再做一次 ReLU

return out

4.1.2.6 BottleNeck

BottleNeck模块则是用来构建resnet-50、resnet-101和resnet152的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Bottleneck(nn.Module):
# Bottleneck in torchvision places the stride for downsampling at 3x3 convolution(self.conv2)
# while original implementation places the stride at the first 1x1 convolution(self.conv1)
# according to "Deep residual learning for image recognition" https://arxiv.org/abs/1512.03385.
# This variant is also known as ResNet V1.5 and improves accuracy according to
# https://ngc.nvidia.com/catalog/model-scripts/nvidia:resnet_50_v1_5_for_pytorch.

"""瓶颈残差块(Bottleneck),用于 ResNet-50 / ResNet-101 / ResNet-152。

结构: [1×1 conv → BN → ReLU → 3×3 conv → BN → ReLU → 1×1 conv → BN] + identity shortcut

torchvision 实现将 stride 放在 3×3 卷积(conv2)上,而非原始论文的 1×1 卷积(conv1),
这种变体被称为 ResNet V1.5,可提升精度。
参考: https://arxiv.org/abs/1512.03385
参考: https://ngc.nvidia.com/catalog/model-scripts/nvidia:resnet_50_v1_5_for_pytorch
"""

expansion: int = 4 # 输出通道扩展倍数,Bottleneck 将通道扩展 4 倍(输出通道 = planes × 4)

def __init__(
self,
# 输入通道数
inplanes: int,
# 中间层基础通道数,实际输出通道 = planes * expansion = planes * 4
# 例如 planes=64 时,输出通道为 256
planes: int,
# 3×3 卷积(conv2)的步长,用于控制下采样
# 注意:在 torchvision 的 V1.5 变体中,stride 由 3×3 卷积控制,而非 1×1 卷积
stride: int = 1,
# 下采样分支(Sequential),用于对齐跳跃分支与主分支的维度
# 当 stride≠1 或 inplanes≠planes*expansion 时传入,否则为 None
downsample: Optional[nn.Module] = None,

# ---------- 以下参数与基础 ResNet 无关,用于 ResNeXt / Wide ResNet 等变体 ----------
# 分组卷积的组数,基础 ResNet 使用 groups=1,ResNeXt 使用 groups=32/64 等
groups: int = 1,
# 每个组的基础通道数,基础 ResNet 使用 base_width=64
# ResNeXt 使用 width_per_group=4(即 base_width=4),Wide ResNet 使用 128 等
base_width: int = 64,
# 空洞卷积的空洞率,基础 ResNet 不使用(dilation=1)
# 仅在分割等任务中使用空洞卷积时才需要
dilation: int = 1,
# --------------------------------------------------------------------------------

# 归一化层,默认使用 BatchNorm2d,可替换为 GroupNorm 等
norm_layer: Optional[Callable[..., nn.Module]] = None,
) -> None:
super().__init__()
if norm_layer is None:
norm_layer = nn.BatchNorm2d
# 计算中间层实际通道数:考虑分组卷积和宽度乘数
# 例如 ResNeXt-50 32x4d: width = 64 * (4/64) * 32 = 128
# 基础 Bottleneck (groups=1, base_width=64): width = planes * 1 * 1 = planes
width = int(planes * (base_width / 64.0)) * groups
# Both self.conv2 and self.downsample layers downsample the input when stride != 1

# ── 主分支:三个卷积层(1×1 → 3×3 → 1×1)──
# 1×1 卷积:降维/升维(inplanes → width),stride=1 保持空间尺寸
self.conv1 = conv1x1(inplanes, width)
self.bn1 = norm_layer(width)
# 3×3 卷积:特征提取(width → width),stride 控制下采样
# torchvision 采用 ResNet V1.5 布局:stride 放在 3×3 卷积(而非原论文的 1×1 卷积)
self.conv2 = conv3x3(width, width, stride, groups, dilation)
self.bn2 = norm_layer(width)
# 1×1 卷积:升维(width → planes * expansion),stride=1 保持空间尺寸
self.conv3 = conv1x1(width, planes * self.expansion)
self.bn3 = norm_layer(planes * self.expansion)
self.relu = nn.ReLU(inplace=True)

# 跳跃分支:下采样模块(仅当维度不匹配时传入)
self.downsample = downsample
self.stride = stride

def forward(self, x: Tensor) -> Tensor:
"""前向传播:主分支输出 + 跳跃分支输出(残差连接)"""
identity = x

# ── 主分支 ──
out = self.conv1(x) # 1×1 conv:通道压缩(inplanes → width)
out = self.bn1(out) # BN
out = self.relu(out) # ReLU

out = self.conv2(out) # 3×3 conv:特征提取(width → width),可能 stride=2 下采样
out = self.bn2(out) # BN
out = self.relu(out) # ReLU

out = self.conv3(out) # 1×1 conv:通道扩展(width → planes×4)
out = self.bn3(out) # BN

# ── 跳跃分支(shortcut)──
# 如果 downsample 不为 None,说明维度不匹配,需要对 identity 做 1×1 conv + BN
if self.downsample is not None:
identity = self.downsample(x)

# 残差连接:逐元素相加
out += identity
out = self.relu(out) # 相加后再做一次 ReLU

return out

4.1.3 网络整体结构

4.1.3.0 ResNet整体流程分析

ResNet的主体部分就是BasicBlock或Bottleneck,vision b0.21.0中其源码实现如下(其中已经添加了中文注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class ResNet(nn.Module):
def __init__(
self,
# 选择基本残差模块:BasicBlock(ResNet-18/34)或 Bottleneck(ResNet-50/101/152)
# 注意这里是传入类本身(class),而不是类的实例
block: Type[Union[BasicBlock, Bottleneck]],
# 每个 stage(layer)中残差块的数目,例如 [3, 4, 6, 3] 对应 ResNet-50
layers: List[int],
# 分类头输出类别数,ImageNet 上默认为 1000
num_classes: int = 1000,
# 是否对每个残差分支的最后一个 BN 层进行零初始化
# 使残差分支初始为恒等映射,有助于训练初期梯度传播,可提升 0.2~0.3% 精度
# 参考 https://arxiv.org/abs/1706.02677
zero_init_residual: bool = False,

# ---------- 以下参数与基础 ResNet 无关,用于 ResNeXt / Wide ResNet 等变体 ----------
groups: int = 1, # 分组卷积的组数(ResNeXt 使用)
width_per_group: int = 64, # 每个组的基础通道数(ResNeXt 使用)
# 是否用空洞卷积替代 stride=2 的下采样,用于空洞空间金字塔(ASPP)等场景
replace_stride_with_dilation: Optional[List[bool]] = None,
# --------------------------------------------------------------------------------

# 归一化层,默认使用 BatchNorm2d,可替换为 GroupNorm 等
norm_layer: Optional[Callable[..., nn.Module]] = None,
) -> None:
super().__init__()
_log_api_usage_once(self)
if norm_layer is None:
norm_layer = nn.BatchNorm2d
self._norm_layer = norm_layer

# inplanes 记录当前网络阶段需要的"输入通道数"(input planes),
# 用于 _make_layer 构建下一个 stage 时传入残差块
# 初始值为 64,恰好等于 conv1 的输出通道数,即 layer1 的输入通道数
self.inplanes = 64
# 空洞卷积的空洞率,初始为 1(即不空洞)
self.dilation = 1
if replace_stride_with_dilation is None:
# 每个元素表示是否将对应的 2x2 stride 替换为空洞卷积
# 默认全为 False,即不替换
replace_stride_with_dilation = [False, False, False]
if len(replace_stride_with_dilation) != 3:
raise ValueError(
"replace_stride_with_dilation should be None "
f"or a 3-element tuple, got {replace_stride_with_dilation}"
)
self.groups = groups
self.base_width = width_per_group

# ==================== 网络前几层(Stem) ====================
# 第一层卷积:将 3 通道 RGB 图像映射到 64 通道特征图
# kernel_size=7, stride=2, padding=3 -> 输出尺寸 = (H + 2*3 - 7) / 2 + 1 = H / 2
# 即 224x224 -> 112x112
self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = norm_layer(self.inplanes)
self.relu = nn.ReLU(inplace=True)
# 3x3 最大池化,stride=2 -> 112x112 -> 56x56
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

# 四个残差阶段(Stage),每个 _make_layer 构建一个 stage
# block 决定使用 BasicBlock 还是 Bottleneck
# planes 是该 stage 的基础通道数,实际输出通道数取决于 block 类型:
# BasicBlock (expansion=1): 输出通道 = planes
# Bottleneck (expansion=4): 输出通道 = planes * 4
# layers[i] 是该 stage 中残差块的个数
# stride=2 表示该 stage 的第一个块会做下采样(高宽减半)
self.layer1 = self._make_layer(block, 64, layers[0]) # 对应 conv2_x,输出 56x56
self.layer2 = self._make_layer(block, 128, layers[1], stride=2, dilate=replace_stride_with_dilation[0]) # 对应 conv3_x,输出 28x28
self.layer3 = self._make_layer(block, 256, layers[2], stride=2, dilate=replace_stride_with_dilation[1]) # 对应 conv4_x,输出 14x14
self.layer4 = self._make_layer(block, 512, layers[3], stride=2, dilate=replace_stride_with_dilation[2]) # 对应 conv5_x,输出 7x7

# ==================== 分类头(Head) ====================
# 自适应平均池化:将任意尺寸的特征图池化为 1x1
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
# 全连接分类层:输入维度 = 512 * block.expansion
# BasicBlock.expansion = 1(512 通道)
# Bottleneck.expansion = 4(2048 通道)
self.fc = nn.Linear(512 * block.expansion, num_classes)

# ==================== 权重初始化 ====================
# 遍历网络中所有模块进行初始化
for m in self.modules():
if isinstance(m, nn.Conv2d):
# 卷积层:使用 He 初始化(Kaiming Normal),适配 ReLU 激活函数
# mode="fan_out" 表示基于输出通道数计算方差,符合 ResNet 论文的做法
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)):
# BN/GN:权重初始化为 1,偏置初始化为 0
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)

# 对每个残差分支的最后一个 BN 层进行零初始化
# 使得训练初期残差分支输出为 0,整个残差块近似恒等映射
# 这有助于更深的网络训练,可提升 0.2~0.3% 精度
# 参考 https://arxiv.org/abs/1706.02677
if zero_init_residual:
for m in self.modules():
if isinstance(m, Bottleneck) and m.bn3.weight is not None:
# Bottleneck 的最后一个 BN 是 bn3(conv3 之后)
nn.init.constant_(m.bn3.weight, 0) # type: ignore[arg-type]
elif isinstance(m, BasicBlock) and m.bn2.weight is not None:
# BasicBlock 的最后一个 BN 是 bn2(conv2 之后)
nn.init.constant_(m.bn2.weight, 0) # type: ignore[arg-type]

def _make_layer(
self,
# 基本残差块类型:BasicBlock 或 Bottleneck
block: Type[Union[BasicBlock, Bottleneck]],
# 该 stage 的基础输出通道数
planes: int,
# 该 stage 中残差块的个数
blocks: int,
# 第一个残差块的步长,用于控制下采样
stride: int = 1,
# 是否使用空洞卷积(与基础 ResNet 无关,用于分割等任务)
dilate: bool = False,
) -> nn.Sequential:
"""构建一个残差阶段(stage),由多个残差块组成。"""

norm_layer = self._norm_layer
downsample = None # 下采样分支(当维度/尺寸不匹配时使用)

previous_dilation = self.dilation
if dilate:
# 使用空洞卷积时,增大空洞率并将 stride 设为 1
self.dilation *= stride
stride = 1

# 需要下采样的场景:stride != 1 或输入通道数与输出通道数不匹配
# Bottleneck 中 planes * expansion 是实际输出通道(planes×4),通常大于 self.inplanes
# BasicBlock 中 expansion=1,仅当 stride!=1 或层间 planes 变化时才需要下采样
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
# 1x1 卷积:匹配通道数(channel);当 stride≠1 时也同时做空间下采样(spatial)
conv1x1(self.inplanes, planes * block.expansion, stride),
norm_layer(planes * block.expansion),
)

# 构建该 stage 的所有残差块
layers = []
# 第一个残差块:处理 stage 的入口,可能需要下采样
layers.append(
block(
self.inplanes, planes, stride, downsample,
self.groups, self.base_width, previous_dilation, norm_layer
)
)
# 更新 inplanes,后续块使用更新后的通道数
# BasicBlock: planes * 1; Bottleneck: planes * 4
self.inplanes = planes * block.expansion
# 后续残差块(不含第一个):不再需要下采样,通道数已匹配
for _ in range(1, blocks):
layers.append(
block(
self.inplanes,
planes,
groups=self.groups,
base_width=self.base_width,
dilation=self.dilation,
norm_layer=norm_layer,
)
)

# 将所有残差块打包为一个 Sequential 模块
return nn.Sequential(*layers)

def _forward_impl(self, x: Tensor) -> Tensor:
# ==================== 前向传播流程 ====================
# 输入 x 的典型形状: [batch_size, 3, 224, 224]

# Stem 部分:卷积 -> BN -> ReLU -> MaxPool
x = self.conv1(x) # [B, 3, 224, 224] -> [B, 64, 112, 112]
x = self.bn1(x) # BatchNorm 归一化
x = self.relu(x) # ReLU 激活
x = self.maxpool(x) # 3x3 MaxPool -> [B, 64, 56, 56]

# 四个残差阶段,特征图逐级减小、通道逐级增多
x = self.layer1(x) # -> [B, 256, 56, 56] (Bottleneck) 或 [B, 64, 56, 56] (BasicBlock)
x = self.layer2(x) # -> [B, 512, 28, 28] (Bottleneck) 或 [B, 128, 28, 28] (BasicBlock)
x = self.layer3(x) # -> [B, 1024, 14, 14] (Bottleneck) 或 [B, 256, 14, 14] (BasicBlock)
x = self.layer4(x) # -> [B, 2048, 7, 7] (Bottleneck) 或 [B, 512, 7, 7] (BasicBlock)

# 全局平均池化:将 HxW 的特征图压缩为 1x1
x = self.avgpool(x) # -> [B, C, 1, 1]
# 展平:将 [B, C, 1, 1] 展平为 [B, C]
x = torch.flatten(x, 1)
# 全连接分类:映射到 num_classes 维
x = self.fc(x) # -> [B, num_classes]

return x

def forward(self, x: Tensor) -> Tensor:
return self._forward_impl(x)

可以看到,NesNet中已经实现了对BasicBlock与Bottleneck的抽象,只要把具体类型传递给block参数即可。整体的ResNet结构中实现的功能与流程如下:(1)首先是Stem阶段:创建一个7×7卷积作用到输入的3维特征图上,输出为64维、高宽减半的特征图,执行BN、ReLU操作,再做MaxPool2d,将特征图的高宽再次减半、维度不变。(2)四个残差阶段(Stage):连续4次调用ResNet的_make_layer(...)方法,创建4个阶段(Stage),在其中根据情况使用BasicBlock或Bottleneck,并可能在每个阶段的第一个块(block)中使用downsample模块,实现特征数据的维度升降或下采样。在每个每个阶段的后续块(block)的最后做一次升维。(3)分类头阶段:使用一个自适应平均池化AdaptiveAvgPool2d将任意尺寸的特征图池化为 1x1 大小,再做一个全连接操作将特征图最终映射到1000个分类上。

另外可以看到,在ResNet的初始化过程中,当其中网络结构(就是上述的(1)、(2)、(3)这3个部分)搭建起来后,再集中对其中的所有卷积层Conv2d、BN层BatchNorm2d、每个块的最后一个BN层(默认不会,只有当zero_init_residual=True 时才会对此部分BN的权重进行第二次赋值,因为ResNet中肯定会对BN层BatchNorm2d中所有权重与偏置项进行第一次赋值)。这些都是在ResNet的__init__方法中实现。

现在再单独说下“连续4次调用ResNet的_make_layer(...)方法”创建的“四个残差阶段(Stage)”。(1)第一个阶段conv2_x:对于使用BasicBlock的ResNet-18、ResNet-34而言,连续创建3个普通的BasicBlock实例即可,实现局部特征提取;对于使用Bottleneck的ResNet-50、ResNet-101、ResNet-152而言,因为块特征图的输入通道维度(self.inplanes)与期望输出通道维度(planes * block.expansion)不一致,需要使用downsample模块将特征图通道维度* 4(但特征图尺寸不变)以保证最后与此块(此阶段的第一个Bottleneck实例)的主干输出维度匹配、残差连接。然后创建一个使用此downsample模块的Bottleneck实例(也是此阶段的第一个Bottleneck实例),并马上更新self.inplanes=planes * block.expansion,再创建后续的Bottleneck实例。后续每个Bottleneck的输入输出通道维度与尺寸大小都是匹配的,不再需要downsample模块进行改变通道维度或尺寸空间。(2)第二个阶段conv3_x:使用BasicBlock的情形下,创建第一个BasicBlock实例时,因为stride=2且输入输出通道维度不匹配,会创建一个downsample模块执行下采样,维度翻倍、尺寸减半; 使用Bottleneck的情形下,创建第一个Bottleneck实例时,因为stride=2且输入输出通道维度不匹配,会创建一个downsample模块执行下采样,维度翻倍、尺寸减半与此阶段的最终输出的通道维度与尺寸空间匹配,且第一个Bottleneck实例本身依次会实现特征图通道维度/2(使用conv1x1)、空间尺寸减半(使用conv3x3)、通道维度* 4(使用conv1x1),此阶段后续Bottleneck实例都是实现特征图通道维度/4(使用conv1x1)、空间尺寸减半(使用conv3x3)、通道维度*4(使用conv1x1)。(3)第三个阶段conv4_x、第四个阶段conv5_x,其中的机制与第二个阶段conv3_x 中的类似。总得来说,每个阶段一般总是第一个block(BasicBlock类型或Bottleneck类型)需要进行特殊处理,后续block不需要。

以下给出了使用BasicBlock的ResNet-34、使用Bottleneck的ResNet-50的具体推演过程。

4.1.3.1 ResNet-34过程推演

下面以 ResNet-34(由 BasicBlock 构建,layers=[3, 4, 6, 3])为例,推演输入特征图在每一层中的变化过程。

推演约定:

  • 输入:标准 ImageNet 图像,尺寸为 3×224×224(通道×高×宽,即 C×H×W)
  • 输出尺寸计算公式:H_out = ⌊(H_in + 2×padding − kernel_size) / stride⌋ + 1
  • Stem 卷积和 BasicBlock 主分支的第一个卷积后均为 BN + ReLU;主分支第二个卷积及投影捷径卷积后仅接 BN,残差相加后再 ReLU。表中不再逐行列出,仅在备注中标注
  • 每个 BasicBlock 末尾做一次 残差相加(主分支输出 + 捷径输出)后再 ReLU
  • 层名称同时给出 torchvision 代码中的属性名(如 layer1.0.conv1)与论文图中的命名(如 conv2_x)

总览(stage 级):

阶段 层构成 尺寸变化(高×宽) 输出通道
conv1_x(Stem) 7×7 conv(s=2) + 3×3 maxpool(s=2) 224×224 → 112×112 → 56×56 64
conv2_x(layer1) 3 × BasicBlock(64) 56×56 → 56×56(不变) 64
conv3_x(layer2) 4 × BasicBlock(128),首块 stride=2 56×56 → 28×28 128
conv4_x(layer3) 6 × BasicBlock(256),首块 stride=2 28×28 → 14×14 256
conv5_x(layer4) 3 × BasicBlock(512),首块 stride=2 14×14 → 7×7 512
分类头 avgpool + FC 7×7 → 1×1 1000

逐层推演:

① conv1_x(Stem 部分,共 2 层)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
1 conv1 3×224×224 64 个 3×7×7 2/3 64×112×112 后接 BN+ReLU;高宽减半
2 maxpool 64×112×112 3×3 池化窗口 2/1 64×56×56 最大池化;高宽减半
  • ResNet中,不管是使用BasicBlock的ResNet-18、ResNet-34,还是使用Bottleneck的ResNet-50、ResNet-101、ResNet-152,conv 1 阶段,conv1层与maxpool层使用的卷积核的stride、padding分别都是固定的,conv1层中都是2/3, maxpool层中都是2/1。

② conv2_x(layer1,3 个 BasicBlock,通道数保持 64,尺寸保持 56×56)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
3 layer1.0.conv1 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU
4 layer1.0.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN,与输入恒等相加后 ReLU
5 layer1.1.conv1 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU
6 layer1.1.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN,残差相加后 ReLU
7 layer1.2.conv1 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU
8 layer1.2.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN,残差相加后 ReLU
  • conv2_x 中 3 个 block 的输入输出通道(64)与尺寸(56×56)均不变,捷径为恒等捷径(identity shortcut),直接逐元素相加,无需 1×1 卷积。
  • 使用BasicBlock的ResNet-18、ResNet-34中,conv2_x 阶段第一个块不做下采样,且此阶段中所有卷积核的stride、padding使用BasicBlock中的默认值,都是固定的,分别都是1/1。

③ conv3_x(layer2,4 个 BasicBlock,通道 64→128,尺寸 56→28)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
9 layer2.0.conv1 64×56×56 128 个 64×3×3 2/1 128×28×28 后接 BN+ReLU;stride=2(主分支下采样)
10 layer2.0.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN,与投影捷径相加后 ReLU
11 layer2.0.downsample.0 64×56×56 128 个 64×1×1 2/0 128×28×28 后接 BN;捷径 stride=2 下采样,对齐通道与尺寸
12 layer2.1.conv1 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
13 layer2.1.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN,残差相加后 ReLU
14 layer2.2.conv1 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
15 layer2.2.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN,残差相加后 ReLU
16 layer2.3.conv1 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
17 layer2.3.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN,残差相加后 ReLU
  • 第一个 block 的捷径为投影捷径(projection shortcut):由于通道数(64→128)和尺寸(56→28)都变了,需用128个 1×1 卷积(stride=2)对齐。后面 3 个 block 又是恒等捷径。
  • 使用BasicBlock的ResNet-18、ResNet-34中,conv3_x 阶段第一个块做下采样,第一个块中第一个3×3卷积核的stride、padding分别是2/1(conv3x3 默认 dilation=1,因此 BasicBlock.conv1.padding=1),第一个块中捷径分支中卷积核的stride、padding分别是2/0(conv1x1 返回的 nn.Conv2d 未指定 padding,默认值为0);第一个块以及后续所有块中剩余卷积核的stride、padding都是固定的,分别都是1/1。

④ conv4_x(layer3,6 个 BasicBlock,通道 128→256,尺寸 28→14)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
18 layer3.0.conv1 128×28×28 256 个 128×3×3 2/1 256×14×14 后接 BN+ReLU;stride=2(主分支下采样)
19 layer3.0.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,与投影捷径相加后 ReLU
20 layer3.0.downsample.0 128×28×28 256 个 128×1×1 2/0 256×14×14 后接 BN;捷径 stride=2 下采样
21 layer3.1.conv1 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
22 layer3.1.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,残差相加后 ReLU
23 layer3.2.conv1 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
24 layer3.2.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,残差相加后 ReLU
25 layer3.3.conv1 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
26 layer3.3.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,残差相加后 ReLU
27 layer3.4.conv1 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
28 layer3.4.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,残差相加后 ReLU
29 layer3.5.conv1 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
30 layer3.5.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN,残差相加后 ReLU
  • 使用BasicBlock的ResNet-18、ResNet-34中,conv4_x 阶段第一个块做下采样,所有卷积核的stride、padding值跟conv3_x 阶段一致。第一个块中第一个3×3卷积核的stride、padding分别是2/1(conv3x3 默认 dilation=1,因此 BasicBlock.conv1.padding=1),第一个块中捷径分支中卷积核的stride、padding分别是2/0(conv1x1 返回的 nn.Conv2d 未指定 padding,默认值为0);第一个块以及后续所有块中剩余卷积核的stride、padding都是固定的,分别都是1/1。

⑤ conv5_x(layer4,3 个 BasicBlock,通道 256→512,尺寸 14→7)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
31 layer4.0.conv1 256×14×14 512 个 256×3×3 2/1 512×7×7 后接 BN+ReLU;stride=2(主分支下采样)
32 layer4.0.conv2 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN,与投影捷径相加后 ReLU
33 layer4.0.downsample.0 256×14×14 512 个 256×1×1 2/0 512×7×7 后接 BN;捷径 stride=2 下采样
34 layer4.1.conv1 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN+ReLU
35 layer4.1.conv2 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN,残差相加后 ReLU
36 layer4.2.conv1 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN+ReLU
37 layer4.2.conv2 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN,残差相加后 ReLU
  • 使用BasicBlock的ResNet-18、ResNet-34中,conv5_x 阶段第一个块做下采样,所有卷积核的stride、padding值跟conv3_x 阶段一致。第一个块中第一个3×3卷积核的stride、padding分别是2/1(conv3x3 默认 dilation=1,因此 BasicBlock.conv1.padding=1),第一个块中捷径分支中卷积核的stride、padding分别是2/0(conv1x1 返回的 nn.Conv2d 未指定 padding,默认值为0);第一个块以及后续所有块中剩余卷积核的stride、padding都是固定的,分别都是1/1。

⑥ 分类头(共 2 层)

序号 层名称 输入 C×H×W 操作 — 输出 C×H×W 备注
38 avgpool 512×7×7 自适应平均池化(池化窗口 7×7) — 512×1×1 全局平均池化,每张特征图压缩为 1 个值
39 fc 512×1×1(展平为 512) 全连接,权重矩阵 1000×512 — 1000 实际输出形状为 (N, 1000);映射到 1000 类
  • avgpool中,使用自适应平均池化指定平均池化输出特征图的空间尺寸为 1×1,无需人工计算池化窗口大小、步长,可以接受任意空间尺寸与输入通道数的特征图。

关键点总结:

  1. 恒等捷径 vs 投影捷径:conv2_x 内通道与尺寸都不变,捷径是纯恒等映射;conv3_x / conv4_x / conv5_x 的第一个 block 通道翻倍且尺寸减半,捷径需用 1×1 卷积(stride=2)对齐,共 3 处投影捷径。

  2. 带权重层的层数:conv1(1)+ 32 个 3×3 卷积(16 个 block × 2)+ 3 个投影 1×1 卷积 + fc(1)= 37 层。论文口径的 "34 层" = 33 个卷积(不含 3 个投影卷积)+ 1 个全连接。

  3. 参数规模:约 21.8M(21797672 个参数,与 torchvision 官方 ResNet34_Weights 元数据一致),其中绝大部分集中在 conv5_x 的 512 通道卷积层。

  4. 尺寸变化规律:网络共做 5 次下采样(conv1、maxpool、conv3_x / conv4_x / conv5_x 首块),224 → 112 → 56 → 28 → 14 → 7;通道数逐级翻倍:3 → 64 → 128 → 256 → 512。

4.1.3.2 ResNet-50过程推演

下面以 ResNet-50(由 Bottleneck 构建,layers=[3, 4, 6, 3])为例,推演输入特征图在每一层中的变化过程。

推演约定:

  • 输入:标准 ImageNet 图像,尺寸为 3×224×224(通道×高×宽,即 C×H×W)
  • 输出尺寸计算公式:H_out = ⌊(H_in + 2×padding − dilation×(kernel_size−1) − 1) / stride⌋ + 1;标准 ResNet-50、ResNet-101 和 ResNet-152 均未启用空洞卷积,所有卷积均为 dilation=1
  • Stem 卷积和 Bottleneck 主分支的前两个卷积后均为 BN + ReLU;主分支第三个卷积及投影捷径卷积后仅接 BN,残差相加后再 ReLU。表中不再逐行列出,仅在备注中标注
  • 每个 Bottleneck 内部结构:1×1 conv(压缩到中间层 planes 通道)→ 3×3 conv(在低维空间做特征提取)→ 1×1 conv(升维到 planes×4),末尾做一次残差相加(主分支输出 + 捷径输出)后再 ReLU
  • 层名称同时给出 torchvision 代码中的属性名(如 layer1.0.conv1)与论文图中的命名(如 conv2_x)

总览(stage 级):

阶段 层构成 尺寸变化(高×宽) 输出通道
conv1_x(Stem) 7×7 conv(s=2) + 3×3 maxpool(s=2) 224×224 → 112×112 → 56×56 64
conv2_x(layer1) 3 × Bottleneck(中间层 64),首块升维 64→256 56×56 → 56×56(不变) 256
conv3_x(layer2) 4 × Bottleneck(中间层 128),首块 stride=2 56×56 → 28×28 512
conv4_x(layer3) 6 × Bottleneck(中间层 256),首块 stride=2 28×28 → 14×14 1024
conv5_x(layer4) 3 × Bottleneck(中间层 512),首块 stride=2 14×14 → 7×7 2048
分类头 avgpool + FC 7×7 → 1×1 1000

Bottleneck 的 expansion=4,因此每个 stage 的实际输出通道 = 中间层通道数 × 4(如中间层 64 → 输出 256)。

逐层推演:

① conv1_x(Stem 部分,共 2 层)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
1 conv1 3×224×224 64 个 3×7×7 2/3 64×112×112 后接 BN+ReLU;高宽减半
2 maxpool 64×112×112 3×3 池化窗口 2/1 64×56×56 最大池化;高宽减半
  • ResNet中,不管是使用BasicBlock的ResNet-18、ResNet-34,还是使用Bottleneck的ResNet-50、ResNet-101、ResNet-152,conv 1 阶段,conv1层与maxpool层使用的卷积核的stride、padding分别都是固定的,conv1层中都是2/3, maxpool层中都是2/1,它们在ResNet实现源码中是写死的。

② conv2_x(layer1,3 个 Bottleneck,通道 64→256,尺寸保持 56×56)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
3 layer1.0.conv1 64×56×56 64 个 64×1×1 1/0 64×56×56 后接 BN+ReLU;1×1 卷积映射至中间层 64(通道数不变)
4 layer1.0.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU;3×3 低维特征提取
5 layer1.0.conv3 64×56×56 256 个 64×1×1 1/0 256×56×56 后接 BN,与投影捷径相加后 ReLU
6 layer1.0.downsample.0 64×56×56 256 个 64×1×1 1/0 256×56×56 后接 BN;投影捷径(升维,尺寸不变)
7 layer1.1.conv1 256×56×56 64 个 256×1×1 1/0 64×56×56 后接 BN+ReLU;1×1 卷积压缩至中间层 64
8 layer1.1.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU
9 layer1.1.conv3 64×56×56 256 个 64×1×1 1/0 256×56×56 后接 BN,与输入恒等相加后 ReLU
10 layer1.2.conv1 256×56×56 64 个 256×1×1 1/0 64×56×56 后接 BN+ReLU;1×1 卷积压缩至中间层 64
11 layer1.2.conv2 64×56×56 64 个 64×3×3 1/1 64×56×56 后接 BN+ReLU
12 layer1.2.conv3 64×56×56 256 个 64×1×1 1/0 256×56×56 后接 BN,残差相加后 ReLU
  • 注意与 ResNet-34 的不同:Bottleneck 的 expansion=4 使 block 输出通道(256)是输入通道(64)的 4 倍,因此 conv2_x 的第一个 block 就需要投影捷径(用 256 个 1×1 卷积把 64 通道升维到 256 通道,stride=1,尺寸不变)。后面 2 个 block 输入输出均为 256 通道,是恒等捷径。
  • 使用Bottleneck的ResNet-50、ResNet-101、ResNet-152中,conv2_x 阶段第一个块不做下采样,第一个块中第一个1×1卷积核的stride、padding分别是1/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0);第一个块中第一个3×3卷积核的stride、padding分别是1/1(conv3x3方法返回nn.Conv2d对象,此处指定nn.Conv2d.padding值为1);第一个块中捷径分支中卷积核的stride、padding分别是1(此处只是复用downsample模块实现升维、不下采样,所以stride=1)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0); 第一个块中第二个1×1卷积核的stride、padding分别是1/0(实现输出通道升维为输入的4倍。另外padding使用nn.Conv2d.padding默认值0)。后续块的所有卷积与第一个块中对应卷积的stride、padding保持一致,只是后续块的第一个1×1卷积都是实现降维(256D->64D)、最后一个1×1卷积都会实现升维(64D->256D)。

③ conv3_x(layer2,4 个 Bottleneck,通道 256→512,尺寸 56→28)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
13 layer2.0.conv1 256×56×56 128 个 256×1×1 1/0 128×56×56 后接 BN+ReLU;1×1 卷积压缩至中间层 128
14 layer2.0.conv2 128×56×56 128 个 128×3×3 2/1 128×28×28 后接 BN+ReLU;stride=2(主分支下采样)
15 layer2.0.conv3 128×28×28 512 个 128×1×1 1/0 512×28×28 后接 BN,与投影捷径相加后 ReLU
16 layer2.0.downsample.0 256×56×56 512 个 256×1×1 2/0 512×28×28 后接 BN;捷径 stride=2 下采样,对齐通道与尺寸
17 layer2.1.conv1 512×28×28 128 个 512×1×1 1/0 128×28×28 后接 BN+ReLU;1×1 卷积压缩至中间层 128
18 layer2.1.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
19 layer2.1.conv3 128×28×28 512 个 128×1×1 1/0 512×28×28 后接 BN,残差相加后 ReLU
20 layer2.2.conv1 512×28×28 128 个 512×1×1 1/0 128×28×28 后接 BN+ReLU;1×1 卷积压缩至中间层 128
21 layer2.2.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
22 layer2.2.conv3 128×28×28 512 个 128×1×1 1/0 512×28×28 后接 BN,残差相加后 ReLU
23 layer2.3.conv1 512×28×28 128 个 512×1×1 1/0 128×28×28 后接 BN+ReLU;1×1 卷积压缩至中间层 128
24 layer2.3.conv2 128×28×28 128 个 128×3×3 1/1 128×28×28 后接 BN+ReLU
25 layer2.3.conv3 128×28×28 512 个 128×1×1 1/0 512×28×28 后接 BN,残差相加后 ReLU
  • 第一个 block 的捷径为投影捷径:通道数(256→512)和尺寸(56→28)都变了,需用 512 个 1×1 卷积(stride=2)对齐。注意 torchvision 采用 ResNet V1.5 布局,下采样的 stride=2 放在 3×3 卷积(conv2)上,而不是论文中的第一个 1×1 卷积(conv1)。后面 3 个 block 又是恒等捷径。
  • 使用Bottleneck的ResNet-50、ResNet-101、ResNet-152中,conv3_x 阶段第一个块做下采样,第一个块中第一个1×1卷积核的stride、padding分别是1(使用conv1x1方法默认的 stride=1 保持空间尺寸,但通道降维)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0);第一个块中第一个3×3卷积核的stride、padding分别是2(stride=2主分支下采样)/1(conv3x3方法返回nn.Conv2d对象,此处指定nn.Conv2d.padding值为1);第一个块中捷径分支中卷积核的stride、padding分别是2(512个卷积升维,stride=2下采样)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0); 第一个块中第二个1×1卷积核的stride、padding分别是1/0(实现输出通道升维为输入的4倍。另外padding使用nn.Conv2d.padding默认值0)。 后续每一个块中,第一个1×1卷积的stride、padding分别为1/0(只降维),第一个3×3卷积核的stride、padding分别为1/1(既不升维也不下采样,只提取局部特征),不再存在捷径分支,第二个1×1卷积核的stride、padding分别为1/0(实现升维:128D->512D)。

④ conv4_x(layer3,6 个 Bottleneck,通道 512→1024,尺寸 28→14)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
26 layer3.0.conv1 512×28×28 256 个 512×1×1 1/0 256×28×28 后接 BN+ReLU;1×1 卷积压缩至中间层 256
27 layer3.0.conv2 256×28×28 256 个 256×3×3 2/1 256×14×14 后接 BN+ReLU;stride=2(主分支下采样)
28 layer3.0.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,与投影捷径相加后 ReLU
29 layer3.0.downsample.0 512×28×28 1024 个 512×1×1 2/0 1024×14×14 后接 BN;捷径 stride=2 下采样,对齐通道与尺寸
30 layer3.1.conv1 1024×14×14 256 个 1024×1×1 1/0 256×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 256
31 layer3.1.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
32 layer3.1.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,残差相加后 ReLU
33 layer3.2.conv1 1024×14×14 256 个 1024×1×1 1/0 256×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 256
34 layer3.2.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
35 layer3.2.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,残差相加后 ReLU
36 layer3.3.conv1 1024×14×14 256 个 1024×1×1 1/0 256×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 256
37 layer3.3.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
38 layer3.3.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,残差相加后 ReLU
39 layer3.4.conv1 1024×14×14 256 个 1024×1×1 1/0 256×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 256
40 layer3.4.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
41 layer3.4.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,残差相加后 ReLU
42 layer3.5.conv1 1024×14×14 256 个 1024×1×1 1/0 256×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 256
43 layer3.5.conv2 256×14×14 256 个 256×3×3 1/1 256×14×14 后接 BN+ReLU
44 layer3.5.conv3 256×14×14 1024 个 256×1×1 1/0 1024×14×14 后接 BN,残差相加后 ReLU
  • 使用Bottleneck的ResNet-50、ResNet-101、ResNet-152中,conv4_x 阶段第一个块做下采样,第一个块中第一个1×1卷积核的stride、padding分别是1(使用conv1x1方法默认的 stride=1 保持空间尺寸,但通道降维)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0);第一个块中第一个3×3卷积核的stride、padding分别是2(stride=2主分支下采样)/1(conv3x3方法返回nn.Conv2d对象,此处指定nn.Conv2d.padding值为1);第一个块中捷径分支中卷积核的stride、padding分别是2(1024个卷积升维,stride=2下采样)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0); 第一个块中第二个1×1卷积核的stride、padding分别是1/0(实现输出通道升维为输入的4倍。另外padding使用nn.Conv2d.padding默认值0)。 后续每一个块中,第一个1×1卷积的stride、padding分别为1/0(只降维),第一个3×3卷积核的stride、padding分别为1/1(既不升维也不下采样,只提取局部特征),不再存在捷径分支,第二个1×1卷积核的stride、padding分别为1/0(实现升维:256D->1024D)。

⑤ conv5_x(layer4,3 个 Bottleneck,通道 1024→2048,尺寸 14→7)

序号 层名称 输入 C×H×W 卷积核规格 s/p 输出 C×H×W 备注
45 layer4.0.conv1 1024×14×14 512 个 1024×1×1 1/0 512×14×14 后接 BN+ReLU;1×1 卷积压缩至中间层 512
46 layer4.0.conv2 512×14×14 512 个 512×3×3 2/1 512×7×7 后接 BN+ReLU;stride=2(主分支下采样)
47 layer4.0.conv3 512×7×7 2048 个 512×1×1 1/0 2048×7×7 后接 BN,与投影捷径相加后 ReLU
48 layer4.0.downsample.0 1024×14×14 2048 个 1024×1×1 2/0 2048×7×7 后接 BN;捷径 stride=2 下采样,对齐通道与尺寸
49 layer4.1.conv1 2048×7×7 512 个 2048×1×1 1/0 512×7×7 后接 BN+ReLU;1×1 卷积压缩至中间层 512
50 layer4.1.conv2 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN+ReLU
51 layer4.1.conv3 512×7×7 2048 个 512×1×1 1/0 2048×7×7 后接 BN,残差相加后 ReLU
52 layer4.2.conv1 2048×7×7 512 个 2048×1×1 1/0 512×7×7 后接 BN+ReLU;1×1 卷积压缩至中间层 512
53 layer4.2.conv2 512×7×7 512 个 512×3×3 1/1 512×7×7 后接 BN+ReLU
54 layer4.2.conv3 512×7×7 2048 个 512×1×1 1/0 2048×7×7 后接 BN,残差相加后 ReLU
  • 使用Bottleneck的ResNet-50、ResNet-101、ResNet-152中,conv5_x 阶段第一个块做下采样,第一个块中第一个1×1卷积核的stride、padding分别是1(使用conv1x1方法默认的 stride=1 保持空间尺寸,但通道降维)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0);第一个块中第一个3×3卷积核的stride、padding分别是2(stride=2主分支下采样)/1(conv3x3方法返回nn.Conv2d对象,此处指定nn.Conv2d.padding值为1);第一个块中捷径分支中卷积核的stride、padding分别是2(2048个卷积升维,stride=2下采样)/0(conv1x1方法返回nn.Conv2d对象,nn.Conv2d.padding默认值为0); 第一个块中第二个1×1卷积核的stride、padding分别是1/0(实现输出通道升维为输入的4倍。另外padding使用nn.Conv2d.padding默认值0)。 后续每一个块中,第一个1×1卷积的stride、padding分别为1/0(只降维),第一个3×3卷积核的stride、padding分别为1/1(既不升维也不下采样,只提取局部特征),不再存在捷径分支,第二个1×1卷积核的stride、padding分别为1/0(实现升维:512D->2048D)。

⑥ 分类头(共 2 层)

序号 层名称 输入 C×H×W 操作 — 输出 C×H×W 备注
55 avgpool 2048×7×7 自适应平均池化(池化窗口 7×7) — 2048×1×1 全局平均池化,每张特征图压缩为 1 个值
56 fc 2048×1×1(展平为 2048) 全连接,权重矩阵 1000×2048 — 1000 实际输出形状为 (N, 1000);映射到 1000 类

关键点总结:

  1. 投影捷径共 4 处(比 ResNet-34 多 1 处):由于 Bottleneck 的 expansion=4,conv2_x 的第一个 block 输入 64、输出 256,也需要投影捷径(仅升维、不下采样);conv3_x / conv4_x / conv5_x 第一个 block 的投影捷径同时升维和下采样(stride=2)。其余 block 均为恒等捷径。

  2. 带权重层的层数:conv1(1)+ 48 个卷积(16 个 Bottleneck × 3)+ 4 个投影 1×1 卷积 + fc(1)= 54 层。论文口径的 "50 层" = conv1(1)+ conv2_x(9)+ conv3_x(12)+ conv4_x(18)+ conv5_x(9)+ fc(1),不含 4 个投影卷积。

  3. 参数规模:约 25.6M(25557032 个参数,与 torchvision 官方 ResNet50_Weights 元数据一致)。虽然 ResNet-50 的层数是 ResNet-34 的约 1.5 倍,但由于 Bottleneck 用 1×1 卷积降维后再做 3×3 卷积,FLOPs(约 \(4.1 \times 10^9\))与 ResNet-34(约 \(3.7 \times 10^9\))非常接近。

  4. 尺寸与通道变化规律:网络同样做 5 次下采样(conv1、maxpool、conv3_x / conv4_x / conv5_x 首块),224 → 112 → 56 → 28 → 14 → 7;通道数变化为 3 → 64 → 256 → 512 → 1024 → 2048(每个 stage 输出通道为中间层的 4 倍)。

4.1.4 附录:相关概念与机理分析

以下是关于全连接层的完整解释,以 ResNet-34 最后的 fc 层为例。你可以直接复制到文档里。


4.1.4.1 全连接层(Fully Connected Layer,FC)详解

1. 全连接层在 CNN 中的位置

回顾 ResNet-34 的最后几层(resnet.py 中的代码):

1
2
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))       # 全局平均池化
self.fc = nn.Linear(512 * block.expansion, num_classes) # 全连接分类层

前向传播:

1
2
3
x = self.avgpool(x)        # 512×7×7 → 512×1×1
x = torch.flatten(x, 1) # 512×1×1 → [B, 512](展平成一维向量)
x = self.fc(x) # [B, 512] → [B, 1000]

一个完整的 CNN 分类网络通常由三部分组成:

部分 作用 输出形态
卷积层 + 池化层(特征提取) 从原始图像中提取高层次特征 三维张量 C×H×W(如 512×7×7)
全局平均池化 把每张特征图压缩成 1 个值 一维向量(512 维)
全连接层(分类器) 把特征向量映射到各个类别的得分 一维向量(1000 维)

也就是说,卷积/池化负责"看",全连接负责"决策"——把提取到的 512 个特征值综合起来,判断这张图属于 1000 个类别中的哪一个。


2. 为什么需要"展平"(flatten)?

卷积和池化的输出是三维张量,形状为 C×H×W(通道 × 高 × 宽)。而全连接层 nn.Linear 只能处理一维向量。

以 ResNet-34 为例:

  • avgpool 之后输出是 512×1×1(512 个通道,每个通道 1×1),此时虽然高宽都是 1,但数据仍是三维结构;
  • 经过 torch.flatten(x, 1) 后,变成一维向量 512 维,即:

\[x = [x_1, x_2, x_3, \dots, x_{512}]\]

这 512 个数,就是前面 33 个卷积层辛苦提取出来的、用于分类的最终特征。


3. 全连接层的数学本质

全连接层的计算其实就是线性变换:

\[y = Wx + b\]

其中:

符号 含义 形状(以 ResNet-34 为例)
\(x\) 输入特征向量 512 维
\(W\) 权重矩阵 1000 × 512
\(b\) 偏置向量 1000 维
\(y\) 输出(各类别得分,logits) 1000 维

展开来看,第 \(j\) 个输出(第 \(j\) 类的得分)是:

\[y_j = w_{j1}x_1 + w_{j2}x_2 + \cdots + w_{j,512}x_{512} + b_j\]

即每个输出神经元,都是"所有 512 个输入"的加权求和,再加上一个偏置。


4. 为什么叫"全连接"?

因为每一个输出神经元都与每一个输入神经元相连,连接之间各有一个独立的权重参数。

以 ResNet-34 的 fc 层为例,画成示意图:

1
2
3
4
5
6
输入层(512 个神经元)          输出层(1000 个神经元)
x₁ ──────────────┬──────────→ y₁(类别1得分)
x₂ ──────────────┤──────────→ y₂(类别2得分)
x₃ ──────────────┤──────────→ y₃(类别3得分)
⋮ │ ⋮
x₅₁₂ ────────────┘──────────→ y₁₀₀₀(类别1000得分)
  • 输入 512 个神经元,输出 1000 个神经元
  • 两两之间都有连接,连接总数 = 512 × 1000 = 512,000 条
  • 每条连接对应权重矩阵 \(W\) 中的一个元素,因此 \(W\) 的形状是 1000×512

5. 具体数字推演(以单张图像为例)

假设一张图片经过前面所有卷积和池化后,avgpool 输出 512 个特征值:

\[x = [0.3,\ 0.8,\ -0.1,\ \dots,\ 0.5] \quad (\text{共 512 个})\]

全连接层用权重矩阵 \(W\) 和偏置 \(b\) 计算 1000 个类别的得分:

1
2
3
4
类别 1 的得分: y₁ = w₁₁·0.3 + w₁₂·0.8 + w₁₃·(-0.1) + ... + w₁,₅₁₂·0.5 + b₁
类别 2 的得分: y₂ = w₂₁·0.3 + w₂₂·0.8 + w₂₃·(-0.1) + ... + w₂,₅₁₂·0.5 + b₂
...
类别 1000 的得分: y₁₀₀₀ = ...

得分最高的那个类别,就是模型预测的结果(配合 softmax 可以转化为概率)。


6. 参数规模

全连接层的参数量:

\[\text{参数量} = \text{输入维度} \times \text{输出维度} + \text{输出维度} = 512 \times 1000 + 1000 = 513{,}000\]

这 51.3 万个参数,是 ResNet-34 里单一层中参数量最大的一层(其余都是卷积层),但仅占整个网络 21.8M 参数的约 2.3%。


7. 与卷积层的对比
维度 卷积层 全连接层
连接方式 局部连接:每个输出只连接输入的局部区域 全局连接:每个输出连接所有输入
权重共享 有(同一个卷积核滑过整张图) 无(每个连接各有一个独立权重)
输入形状 三维张量 C×H×W 一维向量
输出含义 特征图(保留空间结构) 类别得分(无空间结构)
参数量 较少(如 3×3×64 个核) 较多(全连接往往最占参数)

一句话总结:卷积层负责局部提取特征(保留空间信息),全连接层负责全局整合所有特征(丢弃空间信息),最终输出分类决策。


8. 延伸:1×1 卷积 ≈ 通道维度上的"全连接"

你可能已经发现,Bottleneck 里的 1×1 卷积其实和全连接很像——它把每个空间位置上、所有通道的值做一次加权求和。区别在于:

  • 1×1 卷积:在通道维度上做全连接,但空间维度上共享权重(对每个像素位置用同一组参数);
  • 全连接层:在所有维度(包括展平后的所有位置)上做全连接,权重不共享。

这也是为什么有些现代网络(如全卷积网络)可以用 1×1 卷积或全局平均池化来替代全连接层,从而大幅减少参数。

4.1.4.2 (1*1)卷积中空间维度上共享权重

"空间维度上共享权重"这句话的关键,在于理解卷积核是在整张特征图上滑动的,同一个卷积核走到哪个位置都用同一组参数。我用一个具体例子拆开讲。


先明确"空间维度"是什么

特征图是三维的 C×H×W(通道 × 高 × 宽)。其中 H 和 W 合起来叫"空间维度"(spatial),描述的是"这张图上有多少行、多少列像素位置"。

以 ResNet 里一个典型输入为例:64 × 56 × 56

  • 通道维度:64 个通道
  • 空间维度:56 × 56 = 3136 个空间位置

1×1 卷积在干什么

用一个 1×1 卷积,把 64 通道变成 256 通道,卷积核的形状是:

\[1\times1\times 64\]

(1×1 是空间大小,64 是通道数)。256 个输出通道,所以有 256 个这样的核,总参数量:

\[1 \times 1 \times 64 \times 256 = 16{,}384 \ \text{个权重}\]

计算过程:在每一个空间位置 (i, j) 上,取该位置的 64 个通道值 [x₁, x₂, …, x₆₄],与核做加权求和,得到该位置的一个输出值。

重点来了:对位置 (0,0)、位置 (0,1)、……一直到位置 (55,55),这 3136 个位置用的是同一套 256 个核。

打个比方:你有 256 个"公式"(核),你拿着这 256 个公式,依次套用到整张图的 3136 个位置上。公式本身不变,只是每次输入的值(不同位置的像素)变了。

这就是"空间维度上共享权重"——在 H、W 这两个维度上,所有位置共用同一组参数。


对比:全连接层就不共享

假设我们不用 1×1 卷积,而是把这 64×56×56 = 200704 个值全部展平成一个长向量,然后全连接到一个 256 维输出:

\[y = Wx + b, \quad W \text{ 的形状是 } 256 \times 200704\]

参数量:

\[256 \times 200704 = 51{,}380{,}224 \approx 5138 \text{ 万}\]

对比一下:

方式 参数量 权重是否在空间位置间共享
1×1 卷积(64→256) 16,384 是(3136 个位置用同一组核)
展平后全连接(200704→256) 51,380,224 否(每个空间位置的每个通道都有独立权重)

差了几千倍。


一句话总结
  • 卷积(含 1×1 卷积):一个卷积核是一组固定参数,它在整张图上滑动,每个位置都拿这同一组参数去算 → 空间位置之间共享权重。
  • 全连接层:输入每个元素(包括每个空间位置的每个通道)都有一条独立的连接和独立的权重 → 空间位置之间不共享权重。

所以我之前那句"1×1 卷积 ≈ 通道维度上的全连接、空间维度上共享权重",意思就是:

在通道维度上,1×1 卷积和全连接一样——每个输出都综合了所有 64 个通道("全"连接);但在空间维度上,它又保留了卷积的特点——3136 个位置共用同一组参数("共享")。

这也解释了为什么 1×1 卷积能"既能像全连接一样融合通道信息,又不会像全连接那样参数爆炸"。

4.1.5 总结

ResNet 中引入的残差连接,前向是将输入特征图直接与主干输出相加,使得后面的网络层最坏也学习与保持原输入信息,而从反向来看,就是反向传播时可以保证至少有一个梯度1能传播到下一层,从而保证梯度不会消失。

ResNet 创新性屯提出了捷径分支(Shortcut / Skip Connection)与残差连接,主要解决了深度神经网络难以训练与优化的核心痛点。

  • (1)解决的核心问题

    • 梯度消失(Vanishing Gradient):在传统网络中,反向传播的梯度需要通过多层连乘传递,随着网络层数加深,梯度会呈指数级衰减,导致浅层参数无法有效更新。

    • 网络退化(Degradation Problem):网络层数增加到一定程度后,训练集上的准确率反而下降,即深层网络的性能劣于浅层网络。

  • (2)解决原理(前向与反向机制)

    • 前向传播:残差拟合与保底机制

      • 降低拟合难度:网络无需从零学习复杂的完整映射 \(H(x)\),只需专注于学习输入与输出之间的差值(残差 \(F(x) = H(x) - x\))。当输入 \(x\) 已足够优秀时,只需将残差优化至 \(F(x) \to 0\)。

      • 恒等映射(Identity Mapping):最坏情况下,若主干未提取出有用特征,输出 \(y \approx x\),直接将输入无损透传至下一层,保证深层网络性能不低于浅层网络。

    • 反向传播:梯度高速公路(Gradient Highway)

      • 对 \(y = F(x) + x\) 求导可得 \(\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \left( \frac{\partial F(x)}{\partial x} + 1 \right) = \frac{\partial L}{\partial y} \cdot \frac{\partial F(x)}{\partial x} + \frac{\partial L}{\partial y}\)。
      • 展开式中的 \(+1\) 直通项 不经过主干权重的连乘衰减,使上层梯度 \(\frac{\partial L}{\partial y}\) 能无缝直接传递给浅层,即使主干梯度 \(\frac{\partial F(x)}{\partial x}\) 趋近于 0,总梯度依然保持非零,从根本上解决了梯度消失问题。

4.2 基础实战——FashionMNIST时装分类

4.2.1 章节说明

本章节学习的内容是:一个完整的深度学习训练与测试流程。 相关数据集文件下载:https://github.com/zalandoresearch/fashion-mnist、https://www.kaggle.com/zalando-research/fashionmnist。

在这个深度学习流程中,是对时装类别图像(FashionMNIST)进行分类。使用到的FashionMNIST数据集中包含60000个训练样本,10000个测试样本,每个样本拥有一个图像及对应的真实标签。且每张图像均为单通道灰度图像(每张图像在计算机中只由一个二维数值矩阵来表示,图像中的每一个像素点Pixel只需要一个数值来记录信息,每个像素点的数值代表该位置的亮度/ 灰度值,这个数值的取值范围是[0, 255],其中0表示纯黑,255表示纯白,中间的值表示不同深浅的灰度),大小为28*28pixel(即每张图像由28*28矩形形状的多个像素点组成),归属于10个类别(0-T恤/Top上衣,1-裤子,2-套头衫,3-连衣裙,4-外套,5-凉鞋,6-衬衫,7-运动鞋,8-包包,9-短靴)。

4.2.2 完整代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#!/usr/bin/env python
# coding: utf-8

# ============================================================
# PyTorch 基础实战:FashionMNIST 时装图像分类
# ============================================================
#
# 任务目标:对 10 个类别的"时装"图像进行分类。
# 数据集:FashionMNIST(zalandoresearch / fashion-mnist)
# - 训练集 60,000 张;测试集 10,000 张(官方已预先划分好)
# - 每张图:单通道(黑白)灰度图,28 x 28 像素
# - 共 10 个类别:
# 0=T恤/上衣 1=裤子 2=套头衫 3=连衣裙 4=外套
# 5=凉鞋 6=衬衫 7=运动鞋 8=手提包 9=短靴
#
# 本脚本走完一个完整深度学习流程,共 8 步:
# 一、导入必要的包
# 二、配置训练环境与超参数
# 三、数据读入与加载(含数据变换 / 数据可视化)
# 四、模型设计(手搭一个简单 CNN)
# 五、设定损失函数(CrossEntropyLoss)
# 六、设定优化器(Adam)
# 七、训练与测试(验证),计算损失与准确率
# 八、保存模型
#
# 【运行说明】本机为 Mac(CPU),无 CUDA,脚本已做兼容处理:
# - num_workers 必须为 0(详见"二"中的解释)
# - 全程用 .to(device),device 在无 GPU 时自动解析为 cpu
# ============================================================


# ============================================================
# 一、首先导入必要的包
# ============================================================
import os # 操作系统接口:读取/设置环境变量(如控制 CUDA 可见设备)
import numpy as np # 数值计算库,图像通常以 numpy 数组形式参与计算
import pandas as pd # 数据分析库,用于读取 CSV 格式的数据
import torch # PyTorch 主库:张量、自动求导、设备管理等
import torch.nn as nn # 神经网络模块:提供卷积/线性/激活等层的定义
import torch.optim as optim # 优化器模块:SGD、Adam 等
from torch.utils.data import Dataset, DataLoader
# Dataset :自定义数据集需要继承的抽象基类
# DataLoader:负责把 Dataset 按 batch 打包、打乱顺序并供训练循环迭代取用
# (相当于给数据集配了一个"自动分批取数据的迭代器")


# ============================================================
# 二、配置训练环境和超参数
# ============================================================
# PyTorch 中使用 GPU 常见有下面两种方式(选一种即可,二者可同时写):

## 方案一:通过环境变量控制 CUDA 设备(须在 CUDA 相关初始化之前设置)
os.environ['CUDA_VISIBLE_DEVICES'] = '0'
# '0' 表示只让程序"看得见" 0 号 GPU;设为 '0,1' 则可见两张卡。

## 方案二:定义一个 device 对象,后续对要使用 GPU 的变量调用 .to(device) 即可
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
# 若 torch.cuda.is_available() 为 True(机器有可用 GPU)则用 "cuda:0",
# 否则退回 "cpu"。注意:
# - "cuda:0" 指的是 0 号GPU(第一块GPU,类似地cuda:1指第二块GPU);单卡机器一般写 "cuda:0" 或直接 "cuda"
# - 在 Mac 上 torch.cuda.is_available() 恒为 False,因此这里会自动落到 cpu
# 本机(无 GPU)后续所有张量都通过 .to(device) 放到 cpu 上,等价于不做任何搬移。

## 配置其他超参数:batch_size、num_workers、learning rate、epochs
batch_size = 256 # 每个 batch 送入 256 张图(一个迭代更新一次参数)
num_workers = 0 # 用几个子进程并行地预取数据(Linux上可取大于1的值)
# 注意:macOS / Windows 上,如果脚本没有用
# if __name__ == '__main__': 把主流程包起来,
# num_workers 必须为 0。
# 原因:这两个平台默认用 spawn 方式启动子进程,
# 子进程会"把主脚本从头到尾再执行一遍"来初始化,
# 而本脚本所有代码都写在顶层,没有 __main__ 保护,
# 于是子进程又去创建新的 DataLoader 子进程 → 无限递归,
# 报 "An attempt has been made to start a new process before
# the current process has finished its bootstrapping phase."
lr = 1e-4 # 学习率(learning rate)
epochs = 20 # 训练的总轮数(整个训练集完整过一遍 = 1 epoch)


# ============================================================
# 三、数据读入和加载
# ============================================================
# 数据变换(transform):对每张图在送入网络前做必要的预处理,如:
# 统一图片尺寸,方便批量送入网络;
# 把数据从 PIL/numpy 转成网络需要的 Tensor 格式。
# torchvision 是 PyTorch 官方的图像工具库,这些变换可以直接用它完成。

from torchvision import transforms # 图像预处理工具

image_size = 28 # 期望图片统一缩放到 28x28(FashionMNIST 原生就是 28x28)

# 两种读取方式的输入类型不同,需各配一条变换流水线(Compose:按顺序依次作用):
# - 方式一(内置数据集):__getitem__ 返回的是 PIL.Image → 直接 Resize + ToTensor
# - 方式二(自读 CSV) :FMDataset 返回的是 numpy 数组 → 需先 ToPILImage 转 PIL
# 因此定义两条链路,分别供两种方式使用,切换读取方式时 transform 无需再改动。

# 方式一用的变换:输入已是 PIL.Image(不能有 ToPILImage,否则会报
# "pic should be Tensor or ndarray. Got PIL.Image.Image")
data_transform = transforms.Compose([
transforms.Resize(image_size), # 统一缩放到 28x28(FashionMNIST 原生就是 28x28,此步为保持原尺寸)
transforms.ToTensor() # PIL → Tensor,像素 /255 归一化到 [0,1],灰度图补成 (1,H,W)
])

# 方式二用的变换:输入是 numpy 数组(uint8,形状 (28,28,1))
csv_transform = transforms.Compose([
# Compose:把多个变换按"从上到下"的顺序依次作用在数据上
transforms.ToPILImage(), # 把 Tensor / numpy 数组 转成 PIL Image
# 这一步取决于数据读取方式:
# - 方式二(自读 CSV):图片是 numpy 的 uint8 数组,需要它
# - 方式一(内置数据集):返回的本来就是 PIL Image,
# 再加 ToPILImage 会报
# "pic should be Tensor or ndarray. Got PIL.Image.Image"
transforms.Resize(image_size), # 统一缩放到 28x28
transforms.ToTensor() # PIL/numpy → Tensor,像素 /255 归一化到 [0,1],
# 并按通道模式补到最前:灰度(L) → (1,H,W),RGB → (3,H,W)
])

# ------------------------------------------------------------------
## 读取方式一:使用 torchvision 自带数据集(适合 MNIST/CIFAR10 等常见数据集,
## 主要用于快速验证某个想法是否有效;首次运行会自动下载,较耗时)
# ------------------------------------------------------------------
from torchvision import datasets

train_data = datasets.FashionMNIST(root='./', train=True, download=True, transform=data_transform)
test_data = datasets.FashionMNIST(root='./', train=False, download=True, transform=data_transform)
# root :数据集存放根目录,这里为当前目录 './'(实际会存到 ./FashionMNIST 下)
# train :True=训练集,False=测试集
# download:本地不存在时是否自动下载
# transform:上面定义的预处理流水线

# ------------------------------------------------------------------
## 读取方式二:读入 csv 格式的数据,自行构建 Dataset 类
## (对把 PyTorch 用到自己工作上很重要——遇到新数据集的常规做法)
## csv 数据下载链接:https://www.kaggle.com/zalando-research/fashionmnist
# ------------------------------------------------------------------
# Kaggle 导出的 fashion-mnist_train.csv 结构:
# 每行 = 1 张图;第 1 列是标签(0~9),后 784 列是按行展开的 28x28=784 个像素值。
class FMDataset(Dataset):
"""自定义数据集:继承 Dataset,实现 __len__ 与 __getitem__ 两个方法。"""

def __init__(self, df, transform=None):
self.df = df # 保存整个 DataFrame(便于查看/调试)
self.transform = transform # 保存数据变换
# 第 0 列之后的所有列是 784 个像素,转成 (n, 784) 的 uint8 数组
# uint8 范围 0~255,和图像原始存储一致,也方便后续 ToPILImage/ToTensor
# self.images 表示所有图像的像素值
self.images = df.iloc[:, 1:].values.astype(np.uint8)
# 第 0 列是标签。 self.labels 表示所有图像对应的标签
self.labels = df.iloc[:, 0].values

def __len__(self):
"""返回样本总数,DataLoader 靠它知道一个 epoch 有多少数据。"""
return len(self.images)

def __getitem__(self, idx):
"""按索引 idx 取出第 idx 个样本,返回 (image, label)。"""
image = self.images[idx].reshape(28, 28, 1) # 把 784 一维拉回 28x28,补通道维(H,W,1)
label = int(self.labels[idx]) # 标签转成 Python 整数
if self.transform is not None:
image = self.transform(image) # 走预处理流水线(含归一化、转 Tensor)
else:
# 没有给 transform 时手动完成"像素归一化 + 转 Tensor"
image = torch.tensor(image / 255., dtype=torch.float) # /255 归一化到 [0,1]
label = torch.tensor(label, dtype=torch.long) # 标签转成 long 型张量(CrossEntropy 要求)
return image, label

# 读取训练/测试 csv 文件(需先手动下载并放到 ./FashionMNIST/ 下)
train_df = pd.read_csv("./FashionMNIST/fashion-mnist_train.csv")
test_df = pd.read_csv("./FashionMNIST/fashion-mnist_test.csv")
# 用自定义 Dataset 封装,构建最终的训练/测试数据
# 注意:这里传入的是 csv_transform(含 ToPILImage),而不是上面的 data_transform
train_data = FMDataset(train_df, csv_transform)
test_data = FMDataset(test_df, csv_transform)
# 【重要】脚本是自上而下顺序执行的:此处又一次对 train_data / test_data 赋值,
# 会覆盖掉上方"读取方式一"得到的对象。因此最终生效的是方式二(CSV 自建 Dataset)。
# 若想改用内置数据集,只需注释掉上面这 4 行 csv 相关代码即可。

# ------------------------------------------------------------------
# 构建 DataLoader:数据在训练循环里是"按 batch 迭代取用"的
# ------------------------------------------------------------------
train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True, num_workers=num_workers, drop_last=True)
# shuffle=True :每个 epoch 开始前打乱样本顺序,防止模型学到样本排列的"假规律"
# drop_last=True:若最后剩余的样本凑不满一个 batch 则丢弃。
# 通常配合 batch_norm 等依赖整批统计量的层;这里没用 BN,
# 丢弃与否影响很小
test_loader = DataLoader(test_data, batch_size=batch_size, shuffle=False, num_workers=num_workers)
# 测试集不需要打乱(shuffle=False),因为准确率与样本顺序无关,
# 且顺序固定也便于观察/复现结果


# ============================================================
# (数据可视化)验证读入的数据是否正确 —— 取一个 batch 看一眼
# ============================================================
import matplotlib.pyplot as plt # 绘图库

image, label = next(iter(train_loader)) # iter() 拿到 DataLoader 的迭代器,next() 取第一批
# image : (batch_size, 1, 28, 28) 四维张量 = (批大小, 通道数, 高, 宽)
# label : (batch_size,) 一维张量,每个元素是该张图所属类别(0~9)
print(image.shape, label.shape) # 预期输出:torch.Size([256, 1, 28, 28]) torch.Size([256])

plt.imshow(image[0][0], cmap="gray") # image[0] 取第 0 张 → (1,28,28);再取 [0] 去掉通道维 → 28x28
print(image[0], label[0]) # 打印第 0 张的像素值张量与标签
# 注意:直接以 .py 脚本运行时,要弹出图像窗口,需在脚本末尾加一行 plt.show()


# ============================================================
# 四、模型设计
# ============================================================
# 任务较简单,手搭一个简单 CNN(不套用复杂的预训练结构),
# 感受 PyTorch 中"卷积提取特征 + 全连接分类"的标准写法。

class Net(nn.Module):
"""自定义 CNN:继承 nn.Module,实现 __init__ 与 forward。"""

def __init__(self):
super(Net, self).__init__() # 先初始化父类(nn.Module 的机制要求)

# 卷积特征提取部分:输入 (1, 28, 28) → 输出 (64, 4, 4)
self.conv = nn.Sequential(
# 第一层卷积:输入1通道(灰度) → 输出32个特征图,卷积核 5x5
# 28x28 → 经5x5无padding卷积后:(28-5)/1+1 = 24 → 24x24
nn.Conv2d(1, 32, 5),
nn.ReLU(), # 激活函数:引入非线性(ReLU = max(0, x))
nn.MaxPool2d(2, stride=2), # 2x2 最大池化,宽高减半:24x24 → 12x12
nn.Dropout(0.3), # 训练时以 30% 概率随机丢弃神经元,防止过拟合
# (Dropout 在 model.eval() 时自动失效)

# 第二层卷积:32通道 → 64通道,卷积核 5x5
# 12x12 → (12-5)/1+1 = 8 → 8x8;再池化 → 4x4
nn.Conv2d(32, 64, 5),
nn.ReLU(),
nn.MaxPool2d(2, stride=2), # 8x8 → 4x4
nn.Dropout(0.3)
)

# 全连接分类部分:把展平后的 64*4*4=1024 维特征映射到 10 个类别
self.fc = nn.Sequential(
nn.Linear(64 * 4 * 4, 512), # 64个通道 × 4x4 空间尺寸 = 1024 → 512
nn.ReLU(),
nn.Linear(512, 10) # 512 → 10(对应 10 个类别,输出的是 logits)
)

def forward(self, x):
"""前向传播:定义数据从输入到输出的计算路径。"""
x = self.conv(x) # 1) 卷积部分提取特征 → (batch, 64, 4, 4)
x = x.view(-1, 64 * 4 * 4) # 2) 展平(flatten):-1 表示自动推断 batch 大小 → (batch, 1024)
x = self.fc(x) # 3) 全连接层得到每个类别的得分(logits) → (batch, 10)
# x = nn.functional.normalize(x) # 可选:对输出做归一化(本任务不需要)
return x

model = Net() # 实例化模型(此时参数还在 CPU 上,随机初始化)
# 【本机为 Mac/CPU】保持默认即可,模型在 cpu 上训练
# model = model.cuda() # 有 GPU 时把模型搬上 GPU;注意要同时保证输入数据也在 GPU
# model = nn.DataParallel(model).cuda() # 多卡并行训练时的写法,后续课程会讲


# ============================================================
# 五、设定损失函数
# ============================================================
criterion = nn.CrossEntropyLoss()
# CrossEntropyLoss = LogSoftmax + NLLLoss 的组合:
# - 它会自动把整数型 label(0~9) 转成 one-hot 形式来计算交叉熵;
# - 因此【模型最后一层不要手动加 softmax】,直接输出 logits 即可;
# - 同时要求 label 必须从 0 开始连续编号。
# 这正说明 PyTorch 各组件是配套使用的,需要通盘考虑:
# 损失函数的选择会影响模型输出层应该怎么设计。
# 若各类别样本数不均衡,可用 weight 参数给少数类更高权重(默认全为1):
# criterion = nn.CrossEntropyLoss(weight=[1,1,1,1,3,1,1,1,1,1])
print(nn.CrossEntropyLoss) # 打印类本身,只是为了演示(笔记本里曾用它查看帮助)


# ============================================================
# 六、设定优化器
# ============================================================
optimizer = optim.Adam(model.parameters(), lr=lr)
# Adam 是自适应学习率的常用优化器,只需传入模型的全部可训练参数。


# ============================================================
# 七、训练和测试(验证)
# ============================================================
# 各自封装成函数,便于逐 epoch 调用。
# 训练与验证(测试)的主要区别:
# - 模型状态不同:训练要 model.train()(启用 Dropout/BatchNorm 统计),
# 验证要 model.eval()(关闭 Dropout 等随机性);
# - 训练需要 清零梯度→前向→算loss→反向→更新参数 五步,
# 验证不需要反向、不需要更新优化器,且可用 no_grad 省显存/加速。

def train(epoch):
"""训练一个 epoch:遍历整个训练集,更新一次模型参数。"""
model.train() # 切换为"训练模式"(启用 Dropout 等)
train_loss = 0 # 累加本 epoch 的总损失
for data, label in train_loader: # data:(256,1,28,28) label:(256,)
data, label = data.to(device), label.to(device) # 把数据搬到 device(cpu/gpu)
optimizer.zero_grad() # 清空上一步累积的梯度。
# PyTorch 梯度是"累加"的,不手动清零会跨 batch 叠加出错
output = model(data) # 前向传播,得到预测 logits (256, 10)
loss = criterion(output, label) # 计算预测与真实标签的交叉熵损失
loss.backward() # 反向传播:自动求出所有参数的梯度
optimizer.step() # 用梯度更新一次参数
train_loss += loss.item() * data.size(0) # 累加:单样本平均loss × 本批样本数(最后的96个训练样本被直接丢弃)
train_loss = train_loss / (len(train_loader) * batch_size) # 除以参与训练的总样本数 → 本 epoch 平均loss
print('Epoch: {} \tTraining Loss: {:.6f}'.format(epoch, train_loss))

def val(epoch):
"""验证一个 epoch:不更新参数,只评估模型在当前测试集上的损失与准确率。"""
model.eval() # 切换为"评估模式"(关闭 Dropout)
val_loss = 0 # 累加验证损失
gt_labels = [] # 收集所有真实标签
pred_labels = [] # 收集所有预测标签
with torch.no_grad(): # 上下文管理器:内部不追踪梯度,省内存、加快计算
for data, label in test_loader: # 使用测试数据加载器 加载测试数据
data, label = data.to(device), label.to(device)
output = model(data) # 前向,得 logits (256, 10)
preds = torch.argmax(output, 1) # dim=1 沿「类别」维对每行取最大值的下标 → 预测类别 (256,)。注:这里的 1 是维度(第二个轴=10个类别),不是"取1个";argmax 恒返回 1 个下标
# .cpu(): .numpy() 只对 CPU 张量开放; .detach() 用于分离梯度计算图; .numpy() 用于CPU 张量转转成 numpy
gt_labels.append(label.cpu().detach().numpy()) # 真实标签(类型是:torch 张量) → numpy 收集起来
pred_labels.append(preds.cpu().detach().numpy()) # 预测标签(类型是:torch 张量) → numpy 收集起来
loss = criterion(output, label) # 验证也计算一下损失,便于观察过拟合
val_loss += loss.item() * data.size(0) # 前39轮data.size(0)为256,最后一轮为16
val_loss = val_loss / len(test_loader.dataset) # 平均验证损失(test_loader.dataset表示test_df 中全体测试数据:10000)
gt_labels, pred_labels = np.concatenate(gt_labels), np.concatenate(pred_labels) # 把分 batch 收集的结果拼接成一个整体,便于算整体准确率
acc = np.sum(gt_labels == pred_labels) / len(pred_labels) # 预测正确的占比
print('Epoch: {} \tValidation Loss: {:.6f}, Accuracy: {:6f}'.format(epoch, val_loss, acc))

# 主训练循环:每轮先训练一遍,再验证一遍
for epoch in range(1, epochs + 1):
train(epoch)
val(epoch)


# ============================================================
# 八、模型保存
# ============================================================
# torch.save 既可按"整个模型"保存,也可只保存模型参数字典(state_dict),
# 后者更通用、更省空间(一般推荐)。本处按原教程保存整个模型:
save_path = "./FahionModel.pkl"
torch.save(model, save_path)
# 模型保存/加载的细节会在后续章节详细展开。

# 弹出图像窗口
plt.show()

4.2.3 重点说明

上述代码中分别八步,其中有几步重点记录或说明下。

1
2
3
4
5
6
7
8
一、导入必要的包
二、配置训练环境与超参数
三、数据读入与加载(含数据变换 / 数据可视化)
四、模型设计(手搭一个简单 CNN)
五、设定损失函数(CrossEntropyLoss)
六、设定优化器(Adam)
七、训练与测试(验证),计算损失与准确率
八、保存模型

4.2.3.1 三-数据读入与加载

运行这部分代码时需要联网,或者提前将相关数据集文件下载保存到指定位置。在示例代码中,提供了两种数据读入与加载的方式:

  • 方法一:使用 torchvision 自带数据集 使用 torchvision 自带数据集(适合 MNIST/CIFAR10 等常见数据集,主要用于快速验证某个想法是否有效;首次运行会自动下载,较耗时)。也可以去 [fashion-mnist的四个gz文件](https://github.com/zalandoresearch/fashion-mnist/tree/master/data/fashion 页面提前下载下面这4个文件保存到当前目录的“FashionMNIST/raw”子目录下:
文件名 数据类型 数据内容与规模 关键命名含义
train-images-idx3-ubyte.gz 训练集图像 包含 60,000 张 \(28 \times 28\) 像素的单通道服装灰度图 train:训练集
idx3:三维张量 \((N, H, W)\)
train-labels-idx1-ubyte.gz 训练集标签 包含 60,000 个图像对应的分类数字标签(0 到 9) train:训练集
idx1:一维向量 \((N)\)
t10k-images-idx3-ubyte.gz 测试集图像 包含 10,000 张 \(28 \times 28\) 像素的单通道服装灰度图 t10k:Test 10k(1万张测试图)
idx3:三维张量
t10k-labels-idx1-ubyte.gz 测试集标签 包含 10,000 个图像对应的分类数字标签(0 到 9) t10k:Test 10k
idx1:一维向量

后续程序执行时,torchvision中源码实现会检查“FashionMNIST/raw”子目录下是否存在这些文件且MD5值匹配得上,否则会去指定默认网址下载它们。 这种方式下,通过torchvision得到的输入已是 PIL.Image,不能在数据变换方法再次ToPILImage(否则报错),而是直接做形状调整、PIL转Tensor操作。

1
2
3
4
data_transform = transforms.Compose([
transforms.Resize(image_size), # 统一缩放到 28x28(FashionMNIST 原生就是 28x28,此步为保持原尺寸)
transforms.ToTensor() # PIL → Tensor,像素 /255 归一化到 [0,1],灰度图补成 (1,H,W)
])

  • 方法二:csv格式存储的数据 读入 csv 格式的数据,自行构建 Dataset 类(对把 PyTorch 用到自己工作上很重要,也是遇到新数据集的常规做法)。用到的csv文件已经被Kaggle社区整理好了,在fashion-mnist的两个csv文件 下载即可:
文件名 数据内容 数据规模与结构(共 785 列)
fashion-mnist_train.csv 训练集数据(训练模型用) 包含 60,000 行。
• 第 1 列(label):分类标签(0~9)
• 第 2~785 列(pixel1 到 pixel784):展开后的 \(28 \times 28\) 像素点灰度值(0~255)
fashion-mnist_test.csv 测试集数据(评估模型用) 包含 10,000 行。
• 第 1 列(label):分类标签(0~9)
• 第 2~785 列(pixel1 到 pixel784):展开后的 \(28 \times 28\) 像素点灰度值(0~255)

因为每个项目中此类csv文件的格式并不一致,PyTorch社区提供了自定义Dataset类的接口,即只要继承torch.utils.data.Dataset类并重写__init__、__len__与 __getitem__ 几个方法即可。此处采用pandas从csv读取数据,读取出来的数据类型是numpy,仍需要构建数据变换方法将Tensor/numpy数组转成PIL Image、做形状调整、PIL转Tensor操作(不同的源数据,具体的数据变换方法具体实现而相应调整)。相比直接使用torchvision 自带数据集,这里多了一个“将Tensor/numpy数组转成PIL Image”操作。

可以看到,上述沷代码实现中用到数据变换借助了torchvision包来完成,这是PyTorch官方为开发者封装好的用于图像处理的工具库。PyTorch的一大方便之处就在于它是一整套“生态”,有着官方和第三方各个领域的支持。

4.2.3.2 五-设定损失函数(CrossEntropyLoss)

这时使用的损失函数是PyTorch内置的交叉熵损失函数CrossEntropyLoss。

1
2
3
criterion = nn.CrossEntropyLoss()
...
loss = criterion(output, label) # 计算预测与真实标签的交叉熵损失,output就是logits
CrossEntropyLoss = LogSoftmax + NLLLoss 的组合: - 它会自动把整数型 label(0~9) 转成 one-hot 形式来计算交叉熵; - 因此【模型最后一层不要手动加 softmax】,直接输出 logits 即可; - 同时要求 label 必须从 0 开始连续编号。

此处one-hot(独热编码)的意思是:把一个整数类别标签,变成一个「长度 = 类别总数」的向量,其中只有正确类别那一格是 1,其余全是 0。 以 FashionMNIST 的 10 类(0~9)为例,标签 3(连衣裙)会被编码成:

1
2
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
↑ 第 3 个位置是 1
为什么要这样? 因为交叉熵损失需要拿两个「概率分布」做对比: - 模型输出 10 个 logits → 经 softmax 变成 10 个概率(和为 1),这是「预测分布」; - 真实标签也必须是一个分布——one-hot 就是那个「100% 确定」的分布:正确类概率 = 1,其余 = 0,这个分布表示「这张图确定无疑属于某一类」。 两者形状对齐了,才能逐项计算交叉熵。 这个 one-hot 转换是 nn.CrossEntropyLoss 在内部自动完成的,所以开发者写代码时只需传整数标签 label(如 [0, 2, 3, ...]),不用自己调 F.one_hot。这也正是为什么 CrossEntropyLoss 要求 label 是从 0 开始的整数——它要拿这个整数去 one-hot。

关于one-hot为什么要求label从0开始的相关示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import torch, torch.nn as nn
import torch.nn.functional as F

print('=== 演示1: one-hot 的机制 —— 数字 = 1 放置的下标 ===')
# num_classes=4 表示结果向量长度是4,下标为 0,1,2,3
print(F.one_hot(torch.tensor([0, 3]), num_classes=4))
# 看第2行: label=3 → 1 放在下标3(第4个位置)
print()
print('上面第2行 label=3, 1放在下标3 → 这就是“数字当下标用”')

print()
print('=== 演示2: 类别若从1开始编号, 会发生什么 ===')
print('若类别是 1,2,3(真实是1、2、3三种), 你只能写 num_classes=4:')
print(F.one_hot(torch.tensor([1, 2, 3]), num_classes=4))
print('→ 下标0那一列永远空着没人用(浪费), 而且长度被迫多出1位')
print()
print('更糟: 类别若从1到10, 而模型输出层是10个神经元(下标0~9):')
print(' 标签=10 就没有对应下标(10超出 0~9), 直接报错')


"""
# 运行时输出如下:
=== 演示1: one-hot 的机制 —— 数字 = 1 放置的下标 ===
tensor([[1, 0, 0, 0],
[0, 0, 0, 1]])

上面第2行 label=3, 1放在下标3 → 这就是“数字当下标用”

=== 演示2: 类别若从1开始编号, 会发生什么 ===
若类别是 1,2,3(真实是1、2、3三种), 你只能写 num_classes=4:
tensor([[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]])
→ 下标0那一列永远空着没人用(浪费), 而且长度被迫多出1位

更糟: 类别若从1到10, 而模型输出层是10个神经元(下标0~9):
标签=10 就没有对应下标(10超出 0~9), 直接报错
"""
如果从 1 编号会错位会引起几个麻烦(如果真实类别是 1,2,3(三种东西),硬要 one-hot,会出现演示 2 的结果:): 1. 下标 0 空置:num_classes 被迫写 4,第一列永远是 0,白白多一维,还让「3 个类别」和「向量长度 4」对不上; 2. 和模型输出层错位:模型最后一层 nn.Linear(512, 10) 输出 10 个神经元,下标天然是 0~9,对应 10 个类别的 logits。标签若编号成 1~10,标签 10 就超出下标范围,直接越界报错; 3. CrossEntropyLoss 内部也是拿 label 当下标去 logits 里挑「正确类那一格」。标签一旦 ≥ 类别数,立刻 IndexError。

交叉熵衡量「真实分布 p」与「预测分布 q」之间的差异:

\[H(p, q) = -\sum_{k} p(k) \cdot \log q(k)\]以下给出交叉熵手算版的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import torch, torch.nn as nn
import torch.nn.functional as F

# 3 个类别,2 个样本。样本0正确类是0,样本1正确类是2
logits = torch.tensor([[2.0, 1.0, 0.1],
[0.1, 1.0, 2.0]])
labels = torch.tensor([0, 2])

# tensor([[1, 0, 0],
# [0, 0, 1]])
onehot = F.one_hot(labels, num_classes=3)
# tensor([[0.6590, 0.2424, 0.0986], # 其中:0.659+0.2424+0.0986 = 1
# [0.0986, 0.2424, 0.6590]]) # 0.0986+0.2424+0.6590 = 1
prob = F.softmax(logits, dim=1)

print('=== 第1步: 概率 prob = softmax(logits),每行和=1 ===')
# tensor([[0.6590, 0.2420, 0.0990],
# [0.0990, 0.2420, 0.6590]])
print(prob.round(decimals=3))
print()

print('=== 第2步: 真实标签的 one-hot ===')
# tensor([[1, 0, 0],
# [0, 0, 1]])
print(onehot)
print()

print('=== 第3步: log(prob) 对每个概率取自然对数 ===')
# tensor([[-0.4170, -1.4170, -2.3170],
# [-2.3170, -1.4170, -0.4170]])
print(torch.log(prob).round(decimals=3))
print()

print('=== 第4步: onehot * log(prob),逐元素相乘 ===')
t4 = onehot * torch.log(prob)
# tensor([[-0.4170, -0.0000, -0.0000],
# [-0.0000, -0.0000, -0.4170]])
print(t4.round(decimals=3))
print(' (onehot每行只有1个1,所以其余全被清0,只剩正确类的 log(prob))')
print()

print('=== 第5步: 取负号 -onehot*log(prob) ===')
t5 = -t4
# tensor([[0.4170, 0.0000, 0.0000],
# [0.0000, 0.0000, 0.4170]])
print(t5.round(decimals=3))
print()

print('=== 第6步: .sum(dim=1) 沿列(各类)求和 ===')
t6 = t5.sum(dim=1)
# tensor([0.4170, 0.4170]) → 每行只剩 -log(正确类概率),即每个样本的交叉熵
print(t6.round(decimals=3), ' → 每行只剩 -log(正确类概率),即每个样本的交叉熵')
print()

print('=== 第7步: .mean() 对 batch 内所有样本取平均 ===')
# 最终 loss = 0.4170300364494324
print('最终 loss =', t6.mean().item())
print()
# 对照: nn.CrossEntropyLoss 直接算 = 0.4170299768447876
print('对照: nn.CrossEntropyLoss 直接算 =', nn.CrossEntropyLoss()(logits, labels).item())



"""
# 某次执行结果:
=== 第1步: 概率 prob = softmax(logits),每行和=1 ===
tensor([[0.6590, 0.2420, 0.0990],
[0.0990, 0.2420, 0.6590]])

=== 第2步: 真实标签的 one-hot ===
tensor([[1, 0, 0],
[0, 0, 1]])

=== 第3步: log(prob) 对每个概率取自然对数 ===
tensor([[-0.4170, -1.4170, -2.3170],
[-2.3170, -1.4170, -0.4170]])

=== 第4步: onehot * log(prob),逐元素相乘 ===
tensor([[-0.4170, -0.0000, -0.0000],
[-0.0000, -0.0000, -0.4170]])
(onehot每行只有1个1,所以其余全被清0,只剩正确类的 log(prob))

=== 第5步: 取负号 -onehot*log(prob) ===
tensor([[0.4170, 0.0000, 0.0000],
[0.0000, 0.0000, 0.4170]])

=== 第6步: .sum(dim=1) 沿列(各类)求和 ===
tensor([0.4170, 0.4170]) → 每行只剩 -log(正确类概率),即每个样本的交叉熵

=== 第7步: .mean() 对 batch 内所有样本取平均 ===
最终 loss = 0.4170300364494324

对照: nn.CrossEntropyLoss 直接算 = 0.4170299768447876
"""

4.2.3.3 七-训练与测试(验证),计算损失与准确率

已经将训练与测试过程分别封装成一个独立的方法,方便分开多次调用。训练与测试的主要在于: - 模型状态设置 - 是否需要清零梯度 - 是否需要将loss传回到网络 - 是否需要每步更新optimizer - 是否使用with torch.no_grad() - 使用了不同的数据集 - 是否计算准确率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def train(epoch):
"""训练一个 epoch:遍历整个训练集,更新一次模型参数。"""
model.train() # 切换为"训练模式"(启用 Dropout 等)
train_loss = 0 # 累加本 epoch 的总损失
for data, label in train_loader: # data:(256,1,28,28) label:(256,)
data, label = data.to(device), label.to(device) # 把数据搬到 device(cpu/gpu)
optimizer.zero_grad() # 清空上一步累积的梯度。
# PyTorch 梯度是"累加"的,不手动清零会跨 batch 叠加出错
output = model(data) # 前向传播,得到预测 logits (256, 10)
loss = criterion(output, label) # 计算预测与真实标签的交叉熵损失
loss.backward() # 反向传播:自动求出所有参数的梯度
optimizer.step() # 用梯度更新一次参数
train_loss += loss.item() * data.size(0) # 累加:单样本平均loss × 本批样本数(最后的96个训练样本被直接丢弃)
train_loss = train_loss / (len(train_loader) * batch_size) # 除以参与训练的总样本数 → 本 epoch 平均loss
print('Epoch: {} \tTraining Loss: {:.6f}'.format(epoch, train_loss))

def val(epoch):
"""验证一个 epoch:不更新参数,只评估模型在当前测试集上的损失与准确率。"""
model.eval() # 切换为"评估模式"(关闭 Dropout)
val_loss = 0 # 累加验证损失
gt_labels = [] # 收集所有真实标签
pred_labels = [] # 收集所有预测标签
with torch.no_grad(): # 上下文管理器:内部不追踪梯度,省内存、加快计算
for data, label in test_loader: # 使用测试数据加载器 加载测试数据
data, label = data.to(device), label.to(device)
output = model(data) # 前向,得 logits (256, 10)
preds = torch.argmax(output, 1) # dim=1 沿「类别」维对每行取最大值的下标 → 预测类别 (256,)。注:这里的 1 是维度(第二个轴=10个类别),不是"取1个";argmax 恒返回 1 个下标
# .cpu(): .numpy() 只对 CPU 张量开放; .detach() 用于分离梯度计算图; .numpy() 用于CPU 张量转转成 numpy
gt_labels.append(label.cpu().detach().numpy()) # 真实标签(类型是:torch 张量) → numpy 收集起来
pred_labels.append(preds.cpu().detach().numpy()) # 预测标签(类型是:torch 张量) → numpy 收集起来
loss = criterion(output, label) # 验证也计算一下损失,便于观察过拟合
val_loss += loss.item() * data.size(0) # 前39轮data.size(0)为256,最后一轮为16
val_loss = val_loss / len(test_loader.dataset) # 平均验证损失(test_loader.dataset表示test_df 中全体测试数据:10000)
gt_labels, pred_labels = np.concatenate(gt_labels), np.concatenate(pred_labels) # 把分 batch 收集的结果拼接成一个整体,便于算整体准确率
acc = np.sum(gt_labels == pred_labels) / len(pred_labels) # 预测正确的占比
print('Epoch: {} \tValidation Loss: {:.6f}, Accuracy: {:6f}'.format(epoch, val_loss, acc))

# 主训练循环:每轮先训练一遍,再验证一遍
for epoch in range(1, epochs + 1):
train(epoch)
val(epoch)
此外再附上将Tensor张量搬到 CPU上、张量分离梯度计算图、将CPU 张量转转成 numpy的代码(自己暂还不够熟练,所以记录在此):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch
label = torch.tensor([0, 2, 3, 1])
print('label 类型:', type(label), 'dtype:', label.dtype)
# 将GPU张量复制到CPU上变成CPU张量。如label本身就是CPU张量也能正常执行、无负面影响
print('label.cpu() 类型:', type(label.cpu()))
a = label.cpu().data # 脱离计算图取纯数值 的老式写法
b = label.cpu().detach() # 脱离计算图取纯数值 的新式推荐写法
print('label.cpu().data 类型:', type(a))
print('label.cpu().detach() 类型:', type(b))
print('a == b ? :', a == b)
print('label.cpu().detach().numpy() 类型:', type(label.cpu().detach().numpy()))
print(' → 最终值是 numpy 数组:', label.cpu().detach().numpy())


"""
# 执行输出的结果:
label 类型: <class 'torch.Tensor'> dtype: torch.int64
label.cpu() 类型: <class 'torch.Tensor'>
label.cpu().data 类型: <class 'torch.Tensor'>
label.cpu().detach() 类型: <class 'torch.Tensor'>
a == b ? : tensor([True, True, True, True])
label.cpu().detach().numpy() 类型: <class 'numpy.ndarray'>
→ 最终值是 numpy 数组: [0 2 3 1]
"""

4.2.3.4 为什么不直接用一个线性层?

在上述的模型Net的实现的全连接层中,为什么搞两个nn.Linear,中间还夹着一个nn.ReLU ? 直接使用一行语句“nn.Linear(64 * 4 * 4, 10)”不行吗?

这个问题问到深度学习的核心了。答案是:一行 Linear 确实能跑,但表达能力被严重限制。我实验展示为什么需要中间这一层。

4.2.3.4.1 分析一:偏理论分析
先回答:能不能直接用一层?

能跑,但有理论上限。 你把它改成 nn.Linear(1024, 10),模型照样运行、不报错,但它退化成了一道线性分类器——和感知机(Perceptron)没有区别。关键区别用一个小例子直观演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# ============================================================
# 全连接层能不能只使用一层?—— 通过 XOR 异或问题直观演示
# ============================================================
# 背景:上一节在 FashionMNIST 中使用了 nn.Sequential(nn.Linear(1024,512), nn.ReLU(), nn.Linear(512,10)),
# 为什么中间要夹一个 ReLU?能不能直接写成 nn.Linear(1024, 10)?
# 本文件用最小例子回答这个问题。
#
# 核心结论:
# 单层线性 = 仿射变换 (wx+b),无论多少层叠在一起仍然等价于一个大的 wx+b,表达能力受限。
# 加 ReLU 后引入非线性,网络才能表达复杂的决策边界。
# ============================================================

import torch, torch.nn as nn

print('=== 经典例证:XOR(异或)问题 ===')
# XOR 是一个众所周知的「不可线性分割」问题:
# - 输入是两个二进制位,输出是它们的异或值;
# - 四个样本点 (0,0)、(0,1)、(1,0)、(1,1),前两个和为偶数 → 标签 0,后两个为奇数 → 标签 1。
# - 在二维平面上画图:(0,0)→白、(0,1)→黑、(1,0)→黑、(1,1)→白 —— 两种颜色交错,
# **一条直线无法把黑白分开**(这就是 "non-linearly separable")。
x_xor = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]]) # 4 个样本 × 2 维特征
y_xor = torch.tensor([0, 1, 1, 0]) # 对应标签


# ------------------------------------------------------------------
# 方案 A:单线性层 —— 只能画一条直线,解决不了 XOR
# ------------------------------------------------------------------
# 方案A总是卡在 Loss≈0.5(具体数值跟线性层初始化参数有关),无法收敛到 0。因为单层线性模型的输出是一个二维平面上的超平面(直线),
net_linear = nn.Linear(2, 1) # 输入 2 维 → 输出 1 维(二分类 logits)
# 等价于 f(x) = w·x + b,其中 w=(w1,w2), b=偏置项

# 训练 500 步,每次都用同一个 batch(这里数据太少,不需要 DataLoader)
for i in range(500):
pred = net_linear(x_xor) # 前向传播,得到 4 个预测值
loss = ((pred.squeeze() - y_xor)**2).mean() # MSE 损失:预测值与真实值的均方误差
# squeeze(): (4,1) → (4,),去掉冗余维度方便相减
if i % 100 == 0: # 每 100 步打印一次损失
print(f' {i:3d}步: Loss={loss.item():.4f}')


print()
print('两层+ReLU:')
# ------------------------------------------------------------------
# 方案 B:两层线性 + ReLU —— 引入非线性,可以画出曲折的决策边界
# ------------------------------------------------------------------
# 架构:Linear(2, 4) → ReLU → Linear(4, 1)
# 输入 2 维 → 隐藏层 4 维 → 输出 1 维
# 关键点:ReLU 在每一维上做了 max(0, x),打破线性限制,使整条链路不再是简单的 wx+b。
# 经过验证,方案B也不能保证每次都能完美拟合 XOR,因为初始化权重不同,可能会陷入局部最优,但大概率可以学会。
net_2layer = nn.Sequential(nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 1))

# 注意优化器的创建位置:必须放在 forward 之前,否则每次循环都重新初始化优化器参数,永远不收敛
lr_sgd = torch.optim.SGD(net_2layer.parameters(), lr=0.1) # SGD 优化器,学习率 0.1
for i in range(500):
out = net_2layer(x_xor) # 前向传播
loss = ((out.squeeze() - y_xor)**2).mean() # MSE 损失
lr_sgd.zero_grad() # ① 清零梯度(PyTorch 梯度默认累加)
loss.backward() # ② 反向传播,计算所有参数的梯度
lr_sgd.step() # ③ 用梯度更新参数

# 输出最终结果并取整得到类别预测
final_loss = loss.item()
preds = [round(p.tolist()[0]) for p in net_2layer(x_xor)] # round: 将 logits 转成 0/1 整数
# preds = []
# for p in net_2layer(x_xor):
# tmp = p.tolist()[0]
# tmp_ = round(tmp)
# print(f' p:{p} → tmp:{tmp:.4f} → tmp_:{tmp_}') # 打印每个样本的预测值和取整后的类别
# preds.append(tmp_)
print(f' 最后: Loss={final_loss:.4f}, preds={preds}')


print()
print('三层+ReLU:')
# ------------------------------------------------------------------
# 方案 C:三层线性 + ReLU —— 引入非线性,可以画出曲折的决策边界
# ------------------------------------------------------------------
# 架构:Linear(2, 4) → ReLU → Linear(4, 2) → ReLU → Linear(2, 1)
# 输入 2 维 → 隐藏层 4 维 → 隐藏层 2 维 → 输出 1 维
# 关键点:ReLU 在每一维上做了 max(0, x),打破线性限制,使整条链路不再是简单的 wx+b。
# 经过验证,方案C也不能保证每次都能完美拟合 XOR。网络层数越多并不一定总是越好
net_3layer = nn.Sequential(nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 2), nn.ReLU(), nn.Linear(2, 1))

# 注意优化器的创建位置:必须放在 forward 之前,否则每次循环都重新初始化优化器参数,永远不收敛
lr_sgd = torch.optim.SGD(net_3layer.parameters(), lr=0.1) # SGD 优化器,学习率 0.1
for i in range(500):
out = net_3layer(x_xor) # 前向传播
loss = ((out.squeeze() - y_xor)**2).mean() # MSE 损失
lr_sgd.zero_grad() # ① 清零梯度(PyTorch 梯度默认累加)
loss.backward() # ② 反向传播,计算所有参数的梯度
lr_sgd.step() # ③ 用梯度更新参数

# 输出最终结果并取整得到类别预测
final_loss = loss.item()
preds = [round(p.tolist()[0]) for p in net_3layer(x_xor)] # round: 将 logits 转成 0/1 整数
print(f' 最后: Loss={final_loss:.4f}, preds={preds}')

# ============================================================
# 【执行结果】对照理解
# ============================================================
"""
=== 经典例证:XOR(异或)问题 ===
0步: Loss=0.4959 ← 初始随机猜测的损失
100步: Loss=0.4959 ← 500 步结束后损失依然卡在这里……
200步: Loss=0.4959 ← 单层线性模型根本学不会 XOR!
300步: Loss=0.4959 ← 无论训练多少步都是如此(数学上限,不是不够长的问题)
400步: Loss=0.4959

两层+ReLU:
最后: Loss=0.0000, preds=[0, 1, 1, 0] ← 完美拟合 4 个样本!

三层+ReLU:
# 这次没有完美拟合,可能是初始化参数不同导致的局部最优,但大概率可以学会。网络层数越多并不一定越好
最后: Loss=0.1667, preds=[1, 1, 1, 0]
"""

实验把结果钉死了:一层线性层连 XOR 都学不会(Loss 卡在 0.5 不动),两层+ReLU 一上来就完美拟合。这就是中间那个 nn.Linear + nn.ReLU 存在的意义。我分三层展开:

① 数学本质:一层只能做"划分半空间",两层能做非线性
模型 表达式 几何含义
Linear(1024, 10) \(f(x) = Wx + b\) 仿射变换——无论多少层叠在一起,始终等价于一个大的 wx+b,没有增加表达能力
Linear(1024, 512) → ReLU → Linear(512, 10) \(h=\text{ReLU}(W_1 x+b_1),; f=W_2 h+b_2\) 非线性变换——ReLU 在每维上做了 \(\max(0,\cdot)\),整条链路不再是简单的直线映射

关键结论:多个线性层串联仍然等于一个线性层,而加 ReLU 后才真正打破了线性限制。

② 从 FashionMNIST 的角度看具体影响

想想 FashionMNIST 里"凉鞋"(class=5) 和"运动鞋"(7) 的像素差异——都不是靠一条直线能分割的。凉鞋是 T 字形底部、运动鞋是圆润弧线。一张图 1024 个像素点上的灰度分布要经过非常复杂的条件组合才能分出类:

  • 只有一层 Linear:相当于让每个类别用「1024 维超平面」切割空间。6 万张图中那些需要「几个特征同时满足才有某类倾向」的模式,单层 Linear 根本表达不了。
  • 两层 + ReLU:第一层学习到了 ~512 种不同的「视觉概念」(比如「左上方有深色区域」「底部宽度大」「中心对称」……),第二层把这些概念的激活值进一步组合成 10 类的 logits。这个组合是非线性的,所以它能建模远比一条直线复杂的关系。

直观类比:

  • 一层 Linear = 「把所有像素加权平均再分类」→ 类似简单投票;
  • 两层+ReLU = 「先提取各种局部模式,再把模式组合起来判断」→ 更接近人类对图像的理解。
③ 为什么选 512?以及能否更多/更少?

这里的选择其实是一个瓶颈设计(bottleneck):

  • 输入 1024 → 压缩到 512 → 输出 10
  • 如果设太小(如 Linear(1024, 10)):表达能力不足,欠拟合;
  • 如果设太大(如 Linear(1024, 1024)):参数量翻倍,训练变慢,且容易过拟合(训练集全背下来但测试不行)。

512 是经验值:足够容纳大量「视觉概念」的组合,又不至于过大。实际深度学习里这一层的维度通常取 256/512/1024 等 2 的幂,就是为了方便硬件计算(GPU 内存对齐)。

最后给一个对照数字

可以在脚本里把 fc 改成一行试试:

1
self.fc = nn.Sequential(nn.Linear(1024, 10))  # 删去中间的层和 ReLU

跑 20 个 epoch 对比精度:前者通常在 85%~93% 区间波动,去掉中间层后大概率会降到 80% 左右甚至更低——这就是非线性那一层的贡献。

4.2.3.4.2 分析二:图文讲解多层线性层带来的空间扭曲

图跑出来了,效果很理想——这次找到的解堪称教科书级的 XOR 解,两个红点几乎正好落在决策线上,两个蓝点被甩到两条轴上。 xor_space_warp.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
"""
XOR「空间扭曲」可视化 —— 为什么单层是一条直线,两层就能解开
================================================================
运行方式(在你的 Mac 上):
conda activate ai_py312
python xor_space_warp.py

本脚本会训练两个网络并画出 2 张图:
图1 (2x2):
(a) 单层网络 nn.Linear(2,1) 在输入空间里的决策边界 —— 一条直线,分不开 XOR
(b) 两层网络 Linear(2,2)+ReLU+Linear(2,1) 的决策边界 —— 一条折线,分开 XOR
(c) 隐藏层空间:输入空间被「扭曲/折叠」后的样子 + 输出层画的那条直线
(d) 两者训练 loss 曲线对比
图2 (1x2):把输入空间的规则网格线映射进隐藏空间,直观看到「折叠」
"""

import numpy as np
import torch
import torch.nn as nn
import matplotlib.pyplot as plt

# ---------------- 中文字体(Mac / Linux 通用回退) ----------------
plt.rcParams["font.sans-serif"] = [
"PingFang SC", "Heiti SC", "Arial Unicode MS", "STHeiti",
"WenQuanYi Micro Hei", "SimHei",
]
plt.rcParams["axes.unicode_minus"] = False

torch.set_default_dtype(torch.float64)

# ---------------- XOR 数据 ----------------
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]]) # (4,2)
Y = torch.tensor([0., 1., 1., 0.]) # (4,)
COLOR = ["tab:red", "tab:blue", "tab:blue", "tab:red"] # 0 类红, 1 类蓝


# ============================================================
# 模型定义
# ============================================================
def make_linear(seed):
"""单层线性网络:y = w1*x1 + w2*x2 + b,只能画一条直线"""
torch.manual_seed(seed)
return nn.Linear(2, 1)


def make_mlp(seed):
"""两层网络:Linear(2,2) -> ReLU -> Linear(2,1)"""
torch.manual_seed(seed)
return nn.Sequential(nn.Linear(2, 2), nn.ReLU(), nn.Linear(2, 1))


def train(model, steps=6000, lr=0.05):
"""用 MSE 训练,返回 (最终loss, loss历史)"""
opt = torch.optim.Adam(model.parameters(), lr=lr)
hist = []
for i in range(steps):
loss = ((model(X).squeeze() - Y) ** 2).mean()
opt.zero_grad()
loss.backward()
opt.step()
if i % 20 == 0:
hist.append(loss.item())
return loss.item(), hist


def hidden_of_mlp(m, pts=X):
"""取 ReLU 之后的隐藏激活 (4,2)"""
with torch.no_grad():
return m[0](pts).clamp(min=0).numpy()


def train_until_success(make_fn, thresh=0.01, tries=120, pick_spread=True, **kw):
"""
小网络容易掉进局部最优,多种子重试。
pick_spread=True 时,在所有收敛解里挑「四个点映射后分得最开」的那个,
这样图 (c) 的扭曲效果最直观(避免多个点被折叠重合)。
"""
best_model, best_loss, best_hist = None, 1e9, None
cands = []
for seed in range(tries):
m = make_fn(seed)
l, h = train(m, **kw)
if l < best_loss:
best_model, best_loss, best_hist = m, l, h
if pick_spread and l < thresh:
H = hidden_of_mlp(m)
d = min(np.linalg.norm(H[i] - H[j])
for i in range(4) for j in range(i + 1, 4))
cands.append((d, m, l, h))
if pick_spread and cands:
cands.sort(key=lambda t: -t[0])
return cands[0][1], cands[0][2], cands[0][3]
return best_model, best_loss, best_hist


# ============================================================
# 绘图辅助
# ============================================================
def make_grid(n=300, pad=0.35):
"""在输入空间生成规则网格点 (N,2)"""
a = np.linspace(-pad, 1 + pad, n)
g1, g2 = np.meshgrid(a, a)
G = np.stack([g1.ravel(), g2.ravel()], axis=1)
return G, g1, g2, a


def forward_np(model, G):
"""把 numpy 网格点送进模型,返回输出 (N,)"""
with torch.no_grad():
return model(torch.tensor(G)).squeeze().numpy()


def grid_lines(n_lines=9, n_pts=60, pad=0.35):
"""输入空间里的规则网格线:返回若干条线的点集,用于展示『折叠』"""
lines = []
a = np.linspace(-pad, 1 + pad, n_pts)
for t in np.linspace(-pad, 1 + pad, n_lines):
lines.append(np.stack([np.full_like(a, t), a], axis=1)) # 竖线
lines.append(np.stack([a, np.full_like(a, t)], axis=1)) # 横线
return lines


# ============================================================
# 开始
# ============================================================
print("=" * 62)
print("训练中... (单层线性 vs 两层 ReLU)")
print("=" * 62)

lin_model, lin_loss, lin_hist = train_until_success(make_linear, thresh=1.0, tries=5)
mlp_model, mlp_loss, mlp_hist = train_until_success(make_mlp, thresh=0.01, tries=120)

print(f"单层网络 nn.Linear(2,1) 最终 Loss = {lin_loss:.4f}")
print(f"两层网络 Linear+ReLU+Linear 最终 Loss = {mlp_loss:.4f}")
print()
print("单层模型的解(注意 loss 卡在 0.25 附近):")
with torch.no_grad():
w = lin_model.weight[0]
b = lin_model.bias[0]
print(f" w1={w[0]:+.4f} w2={w[1]:+.4f} b={b:+.4f}")
print(f" 四个样本预测值 = {lin_model(X).squeeze().numpy().round(4)}")
print()
print("两层模型学到的参数:")
with torch.no_grad():
W1 = mlp_model[0].weight.numpy()
b1 = mlp_model[0].bias.numpy()
W2 = mlp_model[2].weight.numpy()
b2 = mlp_model[2].bias.numpy()
print(f" 第1层 W1=\n{W1.round(4)}\n 第1层 b1={b1.round(4)}")
print(f" 第2层 W2={W2.round(4)} b2={b2.round(4)}")

# ---- 隐藏层空间里的四个点 ----
with torch.no_grad():
H4 = mlp_model[0](X).clamp(min=0).numpy() # ReLU 之后 (4,2)
Z4 = mlp_model(X).squeeze().numpy()
print()
print("四个点被映射到隐藏空间后的新坐标 (h1, h2):")
for i in range(4):
print(f" x={X[i].numpy()} y={int(Y[i])} -> h=({H4[i,0]:+.4f}, {H4[i,1]:+.4f})"
f" 输出z={Z4[i]:+.4f}")

# ============================================================
# 图1:2x2 综合对比
# ============================================================
G, g1, g2, axis = make_grid(300)
Z_lin = forward_np(lin_model, G).reshape(g1.shape)
Z_mlp = forward_np(mlp_model, G).reshape(g1.shape)

Gt = torch.tensor(G)
with torch.no_grad():
H_grid = mlp_model[0](Gt).clamp(min=0).numpy() # 隐藏空间坐标 (N,2)
Z_hid = mlp_model(Gt).squeeze().numpy()

fig, axes = plt.subplots(2, 2, figsize=(13.5, 11))

# ---------- (a) 单层:一条直线 ----------
ax = axes[0, 0]
cf = ax.contourf(g1, g2, Z_lin, levels=[-10, 0, 10], colors=["#ffd6d6", "#d6e4ff"], alpha=.75)
ax.contour(g1, g2, Z_lin, levels=[0], colors="k", linewidths=2)
for i in range(4):
ax.scatter(*X[i], c=COLOR[i], s=180, edgecolors="k", zorder=5)
ax.annotate(f"({int(X[i,0])},{int(X[i,1])})→{int(Y[i])}",
(X[i, 0], X[i, 1]), textcoords="offset points",
xytext=(10, 10), fontsize=11)
ax.set_title(f"(a) 单层 nn.Linear(2,1):只能画一条直线\nLoss 卡在 {lin_loss:.4f},四个点永远分不开",
fontsize=13)
ax.set_xlabel("$x_1$"); ax.set_ylabel("$x_2$")
ax.set_xlim(-.35, 1.35); ax.set_ylim(-.35, 1.35)

# ---------- (b) 两层:折线 ----------
ax = axes[0, 1]
ax.contourf(g1, g2, Z_mlp, levels=[-10, 0, 10], colors=["#ffd6d6", "#d6e4ff"], alpha=.75)
ax.contour(g1, g2, Z_mlp, levels=[0], colors="k", linewidths=2)
for i in range(4):
ax.scatter(*X[i], c=COLOR[i], s=180, edgecolors="k", zorder=5)
ax.annotate(f"({int(X[i,0])},{int(X[i,1])})→{int(Y[i])}",
(X[i, 0], X[i, 1]), textcoords="offset points",
xytext=(10, 10), fontsize=11)
ax.set_title(f"(b) 两层 Linear(2,2)+ReLU+Linear(2,1)\n决策边界变成折线,Loss = {mlp_loss:.4f}",
fontsize=13)
ax.set_xlabel("$x_1$"); ax.set_ylabel("$x_2$")
ax.set_xlim(-.35, 1.35); ax.set_ylim(-.35, 1.35)

# ---------- (c) 隐藏空间:扭曲后 + 输出层那条直线 ----------
ax = axes[1, 0]
ax.scatter(H_grid[:, 0], H_grid[:, 1], c=Z_hid, cmap="coolwarm",
s=1.2, alpha=.35, vmin=-3, vmax=3)
for i in range(4):
ax.scatter(*H4[i], c=COLOR[i], s=260, edgecolors="k", zorder=6)
ax.annotate(f"y={int(Y[i])}", (H4[i, 0], H4[i, 1]),
textcoords="offset points", xytext=(12, 10),
fontsize=12, fontweight="bold")
# 输出层在隐藏空间里画的直线: W2[0,0]*h1 + W2[0,1]*h2 + b2 = 0
h1max = H_grid[:, 0].max()
hh1 = np.linspace(-0.1, h1max + .3, 100)
if abs(W2[0, 1]) > 1e-8:
hh2 = -(W2[0, 0] * hh1 + b2[0]) / W2[0, 1]
ax.plot(hh1, hh2, "k-", lw=2.5,
label=r"$w'_1h_1+w'_2h_2+b'=0$")
else:
ax.axvline(-b2[0] / W2[0, 0], color="k", lw=2.5)
ax.set_title("(c) 隐藏层空间:空间被 ReLU 折叠后\n四个点变成线性可分,输出层只需再画一条直线",
fontsize=13)
ax.set_xlabel("$h_1$ (第1个神经元激活)"); ax.set_ylabel("$h_2$ (第2个神经元激活)")
ax.legend(fontsize=11)

# ---------- (d) loss 曲线 ----------
ax = axes[1, 1]
ax.plot(np.arange(len(lin_hist)) * 20, lin_hist, label="单层 Linear(2,1)", lw=2)
ax.plot(np.arange(len(mlp_hist)) * 20, mlp_hist, label="两层 Linear+ReLU+Linear", lw=2)
ax.axhline(0.25, ls="--", c="gray", lw=1.2)
ax.annotate("0.25:线性模型的理论最优\n(四个点全猜 0.5)",
xy=(len(lin_hist) * 20 * .5, 0.25), xytext=(0, 18),
textcoords="offset points", fontsize=11, color="gray")
ax.set_xlabel("训练步数"); ax.set_ylabel("MSE Loss")
ax.set_title("(d) Loss 曲线:单层卡住不动,两层降到接近 0", fontsize=13)
ax.legend(fontsize=11)
ax.set_ylim(-0.02, 0.5)

plt.tight_layout()
plt.savefig("xor_warp_fig1.png", dpi=150, bbox_inches="tight")
print("\n已保存 xor_warp_fig1.png")
plt.close()

# ============================================================
# 图2:网格折叠
# ============================================================
fig, axes = plt.subplots(1, 2, figsize=(13, 6))

ax = axes[0]
for ln in grid_lines():
ax.plot(ln[:, 0], ln[:, 1], color="#9bb7d4", lw=.9)
for i in range(4):
ax.scatter(*X[i], c=COLOR[i], s=200, edgecolors="k", zorder=5)
ax.set_title("输入空间 $x$:规整的方格网", fontsize=14)
ax.set_xlabel("$x_1$"); ax.set_ylabel("$x_2$")
ax.set_xlim(-.35, 1.35); ax.set_ylim(-.35, 1.35)
ax.set_aspect("equal")

ax = axes[1]
with torch.no_grad():
for ln in grid_lines():
Hl = mlp_model[0](torch.tensor(ln)).clamp(min=0).numpy()
ax.plot(Hl[:, 0], Hl[:, 1], color="#c98b8b", lw=.9, alpha=.8)
for i in range(4):
ax.scatter(*H4[i], c=COLOR[i], s=260, edgecolors="k", zorder=6)
ax.annotate(f"({int(X[i,0])},{int(X[i,1])})", (H4[i, 0], H4[i, 1]),
textcoords="offset points", xytext=(12, 10), fontsize=11)
h1max = H_grid[:, 0].max()
hh1 = np.linspace(-0.1, h1max + .3, 100)
if abs(W2[0, 1]) > 1e-8:
ax.plot(hh1, -(W2[0, 0] * hh1 + b2[0]) / W2[0, 1], "k-", lw=2.5)
ax.set_title("隐藏空间 $h$:网格被 ReLU 折叠、压扁\n原本对角交叉的四个点被拉开,一条直线就能分开",
fontsize=14)
ax.set_xlabel("$h_1$"); ax.set_ylabel("$h_2$")

plt.tight_layout()
plt.savefig("xor_warp_fig2.png", dpi=150, bbox_inches="tight")
print("已保存 xor_warp_fig2.png")
plt.close()

print("\n完成!两张图已生成在当前目录。")

###### 在 Mac 上复现

1
2
3
conda activate ai_py312
cd 存放脚本的目录
python xor_space_warp.py

会打印训练结果并生成 xor_warp_fig1.png、xor_warp_fig2.png。 73546ad0-1491-4892-a2aa-af11e90deb39.png b9b5f780-7323-40ae-af23-6a4bee0868b2.png

这次训练实际学到了什么

脚本跑出来的真实数值:

1
2
单层 nn.Linear(2,1)      Loss = 0.2500   ← 卡死,这就是理论最优
两层 Linear+ReLU+Linear Loss = 0.0000 ← 完美解开

学到的权重:

1
2
3
第1层 W1 = [[-1.0478,  1.0828],      b1 = [-0.0254,  0.2187]
[ 1.2849, -1.4349]]
第2层 W2 = [0.9544, 0.9235] b2 = [-0.2020]
关键看这张映射表(图 c 的灵魂)
输入 x 标签 隐藏空间坐标 (h1​,h2​) 输出
(0,0) 0​ 红 (0.000, 0.219) 0.000
(1,1) 0​ 红 (0.212, 0.000) 0.000
(0,1) 1​ 蓝 (1.260, 0.000) 1.000
(1,0) 1​ 蓝 (0.000, 1.302) 1.000

仔细看这四行,整个"空间扭曲"的秘密就在里面:

  1. 第一个神经元专门盯着 h1​ 轴:它把 (0,1) 推到 1.26,而把其他三个点压到 0 附近 —— 它学会了检测"x₁=0 且 x₂=1"
  2. 第二个神经元专门盯着 h2​ 轴:它把 (1,0) 推到 1.30 —— 它学会了检测"x₁=1 且 x₂=0"
  3. 两个蓝点(y=1)被分别甩到两条轴的正方向,两个红点(y=0)被压到原点附近

于是输出层那条直线 0.9544h1​+0.9235h2​−0.202=0 只要斜着一划,就把原点附近的红点和远处的蓝点干净地分开了。

为什么 ReLU 是"灵魂"

图 2 的右半边是重点:输入空间的规整方格网,经过 Linear(2,2)+ReLU 后被折叠、压扁了。

  • 方格网沿着两条对角线方向被"折"了过去 —— 一部分区域被 ReLU 压到坐标轴上(负半平面全部拍平成 h=0)
  • 原本在输入空间里对角交叉的四个点,被这个折叠动作拉到了互不相干的位置

这就是为什么必须是 ReLU(或任何非线性激活):

1
2
3
4
如果没有 ReLU:Linear(2,2) 然后 Linear(2,1)
= 两次矩阵乘法可以合并成一个矩阵
= 等价于 nn.Linear(2,1)
= 还是一条直线,白搭

6615a901e48667120fbdc0723d7bc34c 有了 ReLU,变换变成分段线性——它能把空间"折"起来,而折叠是非线性的(虽然每一段是线性的)。这就是为什么图 (b) 的决策边界是折线而不是直线。

一句话串起来

单层只能在原始空间里画一条直线 → XOR 对角交叉,直线无解 → loss 卡在 0.25(最优策略就是四个点全猜 0.5)。

加一层 + ReLU,本质是先把空间折叠扭曲,让交叉的四个点被拉到线性可分的新位置,然后输出层在这个扭曲后的空间里再画一条直线就够了。

顺带一提:loss = 0.25 的含义

图 (d) 里那条灰色虚线标的就是 0.25。这是单层模型的理论最优解——当模型发现怎么摆直线都分不开时,最优策略是"四个样本全预测 0.5":

(0.5−0)2, (0.5−1)2, (0.5−1)2, (0.5−0)2=0.25×4,均值=0.25

看到 loss 稳稳停在 0.25 不动,就是"线性不可分"的数值证据。

想更进一步的话,可以把隐藏层改成 Linear(2,3) 再跑一次,看决策边界变成更复杂的折线(三段或更多)——神经元越多,能折出的"折痕"越多,这就是万能逼近定理的直观版本。


PyTorch04-PyTorch基础实战
https://jiangsanyin.github.io/2026/08/27/PyTorch04-PyTorch基础实战/
作者
sanyinjiang
发布于
2026年8月27日
许可协议