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)\),而是把路径拆成两条:
- 主干路径(卷积层):负责学习残差 \(F(x)\)。
- 快捷路径(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 | |
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,其中网络后面的数字代表的是网络的层数。
这张图来自于 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 明显分为了两大类别:
- 浅层网络(ResNet-18 / ResNet-34):使用基础块(Basic Block)
- 结构:每一个残差块由两个 \(3 \times 3\) 的卷积层组成,如 \(\begin{bmatrix} 3\times 3, 64 \\ 3\times 3, 64 \end{bmatrix}\)。
- 特点:输入通道数与输出通道数保持一致(例如均为 64),结构简单直接。
- 深层网络(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 \times 1\):降维(例如将 256 维降到 64 维。其中64就是卷积核的输出通道大小),减少后续 \(3 \times 3\) 卷积的计算量。
- 第二层 \(3 \times 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 这三列中,所有由三个卷积层组合构成的方括号结构整体。
- 图表中的具体位置与形态
在图表右侧的 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 \times 1, 64\)(降维层:把输入通道压缩到 64)
- \(3 \times 3, 64\)(空间特征提取层:在 64 通道的低维空间运算)
- \(1 \times 1, 256\)(升维层:把通道重新恢复/扩充到 256)
- 右侧的 \(\times 3\):代表将这一个瓶颈模块按顺序连续堆叠 3 次。
- 为什么叫“瓶颈(Bottleneck)”?
它被称为“瓶颈”是因为其通道数呈现出 “大 \(\to\) 小 \(\to\) 大” 的两头宽、中间窄的物理形状:
1 | |
数据流经过中间的 \(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) 实现:
- 主干路径(Main Branch):在每个 Stage(如
conv3_x,conv4_x,conv5_x)的第一个残差块中,将卷积层的步长设置为stride = 2。- 特征图尺寸计算:\(H_{out} = \lfloor \frac{H_{in}}{2} \rfloor\),直接实现高宽减半。
- 快捷旁路(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)\)
空间尺寸或通道维度不匹配问题的线性映射分支(恒等映射或投影映射)。
4.1.2.4 Shortcut Connection
下图展示了shortcut connection 的过程:
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,就能同时将图像的高和宽缩小一半,实现空间尺寸的调整。
具体通过以下两种方式完成调整:
调整通道尺寸(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]\)。
调整空间尺寸(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]\)。
总结(Shortcut 支路中的 Downsample)
在 ResNet 的 Shortcut 支路上,当需要进行 downsample
时,正是使用了 stride=2 且改变卷积核数量的 \(1 \times 1\) 卷积,一次性将输入
\(x\)
的通道数加倍并将其高宽减半,完美对齐了主干分支
\(F(x)\) 的维度与尺寸。
上图展示了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 | |
4.1.2.6 BottleNeck
BottleNeck模块则是用来构建resnet-50、resnet-101和resnet152的。
1 | |
4.1.3 网络整体结构
4.1.3.0 ResNet整体流程分析
ResNet的主体部分就是BasicBlock或Bottleneck,vision b0.21.0中其源码实现如下(其中已经添加了中文注释):
1 | |
可以看到,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,无需人工计算池化窗口大小、步长,可以接受任意空间尺寸与输入通道数的特征图。
关键点总结:
恒等捷径 vs 投影捷径:conv2_x 内通道与尺寸都不变,捷径是纯恒等映射;conv3_x / conv4_x / conv5_x 的第一个 block 通道翻倍且尺寸减半,捷径需用 1×1 卷积(stride=2)对齐,共 3 处投影捷径。
带权重层的层数:conv1(1)+ 32 个 3×3 卷积(16 个 block × 2)+ 3 个投影 1×1 卷积 + fc(1)= 37 层。论文口径的 "34 层" = 33 个卷积(不含 3 个投影卷积)+ 1 个全连接。
参数规模:约 21.8M(21797672 个参数,与 torchvision 官方
ResNet34_Weights元数据一致),其中绝大部分集中在 conv5_x 的 512 通道卷积层。尺寸变化规律:网络共做 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 类 |
关键点总结:
投影捷径共 4 处(比 ResNet-34 多 1 处):由于 Bottleneck 的
expansion=4,conv2_x 的第一个 block 输入 64、输出 256,也需要投影捷径(仅升维、不下采样);conv3_x / conv4_x / conv5_x 第一个 block 的投影捷径同时升维和下采样(stride=2)。其余 block 均为恒等捷径。带权重层的层数: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 个投影卷积。
参数规模:约 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\))非常接近。尺寸与通道变化规律:网络同样做 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 | |
前向传播:
1 | |
一个完整的 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 | |
- 输入 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 | |
得分最高的那个类别,就是模型预测的结果(配合 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 | |
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
10kidx1:一维向量 |
后续程序执行时,torchvision中源码实现会检查“FashionMNIST/raw”子目录下是否存在这些文件且MD5值匹配得上,否则会去指定默认网址下载它们。
这种方式下,通过torchvision得到的输入已是
PIL.Image,不能在数据变换方法再次ToPILImage(否则报错),而是直接做形状调整、PIL转Tensor操作。
1
2
3
4data_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
3criterion = nn.CrossEntropyLoss()
...
loss = criterion(output, label) # 计算预测与真实标签的交叉熵损失,output就是logits
此处one-hot(独热编码)的意思是:把一个整数类别标签,变成一个「长度 =
类别总数」的向量,其中只有正确类别那一格是 1,其余全是 0。 以
FashionMNIST 的 10 类(0~9)为例,标签
3(连衣裙)会被编码成: 1
2[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
↑ 第 3 个位置是 1nn.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
38import 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,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
94import 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
41def 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)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import 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 | |
实验把结果钉死了:一层线性层连 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 | |
跑 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完成!两张图已生成在当前目录。")
1 | |
会打印训练结果并生成 xor_warp_fig1.png、xor_warp_fig2.png。

这次训练实际学到了什么
脚本跑出来的真实数值:
1 | |
学到的权重:
1 | |
关键看这张映射表(图 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 |
仔细看这四行,整个"空间扭曲"的秘密就在里面:
- 第一个神经元专门盯着 h1 轴:它把 (0,1) 推到 1.26,而把其他三个点压到 0 附近 —— 它学会了检测"x₁=0 且 x₂=1"
- 第二个神经元专门盯着 h2 轴:它把 (1,0) 推到 1.30 —— 它学会了检测"x₁=1 且 x₂=0"
- 两个蓝点(y=1)被分别甩到两条轴的正方向,两个红点(y=0)被压到原点附近
于是输出层那条直线 0.9544h1+0.9235h2−0.202=0 只要斜着一划,就把原点附近的红点和远处的蓝点干净地分开了。
为什么 ReLU 是"灵魂"
图 2
的右半边是重点:输入空间的规整方格网,经过 Linear(2,2)+ReLU 后被折叠、压扁了。
- 方格网沿着两条对角线方向被"折"了过去 —— 一部分区域被 ReLU 压到坐标轴上(负半平面全部拍平成 h=0)
- 原本在输入空间里对角交叉的四个点,被这个折叠动作拉到了互不相干的位置
这就是为什么必须是 ReLU(或任何非线性激活):
1 | |
有了
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) 再跑一次,看决策边界变成更复杂的折线(三段或更多)——神经元越多,能折出的"折痕"越多,这就是万能逼近定理的直观版本。