| C3输出 | 输入1 | 输入2 | 输入3 | 输入4 | 输入5 | 输入6 | 连接数 |
|---|---|---|---|---|---|---|---|
| 1 | x | x | x | 3 | |||
| 2 | x | x | x | 3 | |||
| 3 | x | x | x | 3 | |||
| 4 | x | x | x | 3 | |||
| 5 | x | x | x | 3 | |||
| 6 | x | x | x | 3 | |||
| 7 | x | x | x | x | 4 | ||
| 8 | x | x | x | x | 4 | ||
| 9 | x | x | x | x | 4 | ||
| 10 | x | x | x | x | 4 | ||
| 11 | x | x | x | x | 4 | ||
| 12 | x | x | x | x | 4 | ||
| 13 | x | x | x | x | 4 | ||
| 14 | x | x | x | x | 4 | ||
| 15 | x | x | x | x | 4 | ||
| 16 | x | x | x | x | x | x | 6 |
7 卷积网络
“放大。向右平移。放大。停。”
—— 《银翼杀手》 (1982)
银翼杀手德卡德使用 Esper 机器分析一张看似普通的照片。通过语音指令,他控制机器在一个模糊的角落不断扫描、放大,最终在镜子的微弱反光中,提取出了关键的线索。
7.1 应用场景
卷积神经网络(ConvNets 或 CNN)是一类神经网络,在图像识别和分类等领域已经证明非常有效。CNN 已经成功用于 人脸识别、物体和交通标志识别,机器人视觉,自动驾驶等等。下面看两个具体例子。
一种是对于给定图片描述其内容的卷积神经网络(Simonyan 和 Zisserman 2015)。

另外一种是用于识别日常人和物体的卷积神经网络(Ren 等 2017)。

既然说到卷积神经网络,必然要从 LeNet 架构开始,最早这个架构是为了识别手写数字而设计的,在升级到 LeNet-5(Lecun 等 1998) 后,就成为了卷积神经网络的基础架构,被誉为卷积神经网络的“开山之作”。

从左到右,每一层的操作和参数变化意义如下:
- 输入层 (INPUT):一张 \(32 \times 32\) 像素的灰度图像(Input image)。
- C1 卷积层 (Convolutions):使用 6 个卷积核(Filter/Kernel),每个卷积核的大小是 \(5 \times 5\)。生成 6 张特征图 (Feature Maps),每张图的大小为 \(28 \times 28\)。提取图像的低级特征(如边缘、线条、角点)。
- S2 下采样层/池化层 (Subsampling):平均池化(Average Pooling。注意,现代网络多用最大池化 Max Pooling)。窗口大小为 \(2 \times 2\),步长为 2。保持 6 个通道数不变,特征图尺寸减半,变为 6 张 \(14 \times 14\) 的特征图。
- C3 卷积层 (Convolutions):LeCun 设计了一个如下图所示的非对称连接表,目的是打破对称性,迫使不同的特征图提取互补的特征。同时使用 \(5 \times 5\) 的卷积核,将前面 6 张特征图做映射,返回 16 张 \(10 \times 10\)(14-5+1)的特征图。
在现代深度学习框架实现 LeNet-5 时,为了编程方便,很多教程往往会忽略这个复杂的表,直接使用全连接卷积。虽然这偏离了原始论文的设计,但在现代硬件上运行得更快,效果也通常更好。
- S4 下采样层 (Subsampling):与 S2 类似,进行 \(2 \times 2\) 的池化。生成 16 张 \(5 \times 5\) 的特征图。
- C5 卷积层 (相当于全连接):使用 120 个 \(5 \times 5\) 的卷积核,输入大小是 \(5 \times 5\),生成120 个 \(1 \times 1\) 的特征图(即长度为 120 的向量)。
- F6 全连接层 (Full connection):传统的神经网络层,将 120 个神经元连接到 84 个神经元。当时为了对应 ASCII 字符的标准位图(\(7 \times 12 = 84\) 像素),设计者希望这一层的输出能直接对应字符的某种图像表示。
- 输出层 (OUTPUT):高斯连接(Gaussian connections),输出 10 个值,分别代表 0-9 这 10 个数字的类别概率。在现在的深度学习实现中,我们通常用 Softmax 函数来替代这里的高斯连接,直接输出属于每个类别的概率。
简单总结一下:
- 卷积 (Convolution) 提取空间特征。
- 池化 (Pooling) 降低维度并保持平移不变性。
- 全连接 (FC) 进行分类决策。
这一结构(Conv -> Pool -> Conv -> Pool -> FC)成为了后来 AlexNet、VGG 等强大网络的基础模板。
7.2 卷积
在第 4 章中,当我们尝试用多层感知机(MLP)处理数据时,我们做了一个极其粗暴的操作:将所有输入特征展平成一个一维向量。
对于波士顿房价数据集,13 个特征展平没问题。但对于图像呢?
一张最普通的 \(256 \times 256\) 的彩色图片,其形状是 \((3, 256, 256)\)。如果展平,输入层就需要 \(3 \times 256 \times 256 = 196,608\) 个神经元。如果第一个隐藏层只有 1000 个神经元,那么仅这一层的权重矩阵就有近 2 亿个参数!这不仅会导致计算量爆炸,更严重的是不可避免的过拟合。
更根本的问题是,展平操作完全破坏了图像的空间结构。在 MLP 眼里,像素点 (10, 10) 和像素点 (10, 11) 只是两个毫不相干的输入特征,它不知道这两个点在物理空间上是相邻的。并且,假如你训练的网络学会了识别图片左上角的“猫”,把猫移到图片右下角时,全连接网络必须重新学习一遍“右下角的猫”是什么样子的。这显然不合理。
为了解决这个问题,卷积神经网络(CNN)应运而生。它受到了生物视觉皮层研究的启发:我们不需要一次看清整张图,而是用一个小视窗(感受野,Receptive Field)去扫描图像,提取局部的特征(如边缘、纹理),然后再将这些特征组合起来。
CNN 的核心就在于“卷积(Convolution)”这个操作。对于 R 用户来说,最接近的类比可能是时间序列分析中的“滑动窗口平均”。
7.2.1 滑动与点积
图片的本质是由像素值组成的矩阵。如果是灰度图片,每个像素点只有一个通道,值也在 \([0, 255]\) 之间。如果是彩色图片,每个像素点有 3 个通道 red/green/blue,每个通道的值都是 \([0, 255]\) 之间的标量。
我们拿 MNIST 手写数字 举例,它是一个 \(28 \times 28\) 的矩阵,把颜色为白色的地方去掉,就得到了下面这个对比图。

既然有了这个矩阵,我们就可以用它来提取特征。想象你手里拿着一个小手电筒(称为卷积核 Kernel 或滤波器 Filter),在挂在墙上的一幅大画(输入图像 Input)上移动扫描。

卷积核是一个小的权重矩阵(例如 \(3 \times 3\)),里面的数值是需要学习的参数。它们决定了我们要寻找什么样的特征(例如,垂直边缘检测器的卷积核可能左边是正数,右边是负数)。
操作过程:
- 将卷积核覆盖在图像的左上角。
- 将卷积核里的数值与图像对应位置的像素值做逐元素相乘,然后求和。得到的结果就是输出特征图(Feature Map)上的一个像素点。
- 将卷积核向右滑动一步,重复上述操作,直至卷积核覆盖完整个图像。
从数学上定义,假设输入图像为 \(X\),卷积核为 \(K\)(大小为 \(k \times k\)),输出特征图为 \(Y\)。对于输出位置 \((i, j)\) 有:
\[ Y[i, j] = \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} X[i+m, j+n] \cdot K[m, n] + b \]
其中 \(b\) 是一个偏置项,用于调整输出的整体值。
这个滑动的过程,确保了两个重要特性:
- 局部连接 (Local Connectivity):每个输出神经元只连接输入图像的一小块区域。
- 权值共享 (Parameter Sharing):扫描整张图时,我们用的是同一个卷积核(同一把手电筒)。这意味着无论一只猫出现在图片的左上角还是右下角,我们都用同一组权重去检测它。这极大地减少了参数量,并赋予了网络平移不变性 (Translation Invariance)。
7.2.2 输出形状的变化
在构建 CNN 时,我们必须精确计算每一层输出张量的形状。这取决于三个关键要素:
1. 卷积核大小 (Kernel Size, \(K\))
通常是奇数,如 \(3 \times 3, 5 \times 5, 7 \times 7\)。\(3 \times 3\) 是最常用的选择,因为它参数少,可以通过堆叠多层来达到大卷积核的效果。
2. 步长 (Stride, \(S\))
卷积核每次滑动的距离。
- \(S=1\):一步一个脚印,输出尺寸变化较小。
- \(S=2\):跳着走,输出尺寸大约减半(起到降维作用)。
3. 填充 (Padding, \(P\))
在进行卷积操作之前,通常会在图像周围填充一些 0,使得卷积后的输出尺寸与输入尺寸保持一致(当 \(S=1\) 时)。如果不做处理,每次卷积后图像都会“缩水”一圈(因为边界点无法成为卷积核的中心)。
4. 空洞 (Dilation, \(D\))
卷积核元素之间的间距,也被称之为“空洞率”(Dilation Rate),参数默认为 \(D=1\),一般不做改变。 它的作用是在不增加参数量的情况下,成倍扩大感受野(Receptive Field)。
维度计算公式(不考虑空洞的情况下)
假设输入尺寸为 \(W_{in}\),卷积核为 \(K\),填充为 \(P\),步长为 \(S\),则输出尺寸 \(W_{out}\) 为: \[W_{out} = \lfloor \frac{W_{in} - K + 2P}{S} \rfloor + 1\] 该公式在构建网络时需要反复用到,特别是连接全连接层之前。
光看公式比较枯燥,我们用 R 代码来验证一下上述结论。
# 定义输入:Batch=1, Channel=1, Height=5, Width=5
input_img <- torch_randn(1, 1, 5, 5)
# 定义卷积层:Padding = 1
conv_same <- nn_conv2d(in_channels=1, out_channels=1, kernel_size=3, padding=1)
output <- conv_same(input_img)
# 查看输出形状
output$shape # 1 1 5 5, 与输入形状保持一致再来个复杂的多通道变换:
# 输入为 RGB 图像 (3 通道)
input_rgb <- torch_randn(1, 3, 32, 32)
# 卷积核:输入通道必须匹配 (3),输出通道任意 (16)
conv_features <- nn_conv2d(in_channels = 3, out_channels = 16, kernel_size = 3)
output_feat <- conv_features(input_rgb)
# 多通道卷积权重形状
conv_features$weight$shape # 16 3 3 3, (Out, In, K, K)
# 特征图输出尺寸
output_feat$shape # 1 16 30 30, 与输入形状一致,通道数变为 167.2.3 处理多通道
R 用户处理的图像通常是 RGB 彩色的,形状为 \((3, H, W)\)。这里的“3”就是通道数(Channels)。
如果你想把一张 RGB 图像作为输入,你的卷积核形状实际上是 \((3, K, K)\)。它就像一块三层的夹心饼干,每一层分别去卷积 R、G、B 三个通道,然后把三个通道的卷积结果相加,再加上一个偏置 \(b\),最终得到一张二维的特征图。也就是说,无论输入有多少个通道,一个卷积核只生成 1 个输出通道(一张特征图)。
- 输入通道数 (\(C_{in}\)):必须等于上一层特征图的通道数(第一层就是图像的颜色通道数,如 RGB=3, 灰度=1)。
- 输出通道数 (\(C_{out}\)):你用了多少个不同的卷积核(多少把不同的手电筒)。例如,你可能用 64 个卷积核,分别检测垂直边缘、水平边缘、色彩斑点等,那么输出特征图的形状就是 \((64, H_{out}, W_{out})\)。
7.3 池化层和归一化
池化层 Pooling,又称为降采样、下采样(subsampling、downsampling)。卷积层负责提取特征,但它提取的特征位置太精确了。为了让网络具有一定的容错能力(比如猫稍微歪一点也能识别),并减少计算量,我们引入池化层进行下采样(Downsampling)。
池化层就像一个粗糙的过滤器,它的操作逻辑和卷积很像(滑动窗口),但是没有权重参数。
- 最大池化 (Max Pooling):这是最常用的。它在一个窗口(如 \(2 \times 2\))内选取最大值。只要这个区域内出现了我们想要的特征(比如一个明显的边缘),我们就保留这个最强的信号,忽略具体位置和弱信号。它能显著增强特征的鲁棒性。
- 全局平均池化 (Global Average Pooling):忽略图上结构信息,将整张图变为一个标量。在早期的网络(如 LeNet, VGG)中,卷积层之后会接一个 Flatten 操作,把多维张量拉平,然后接巨大的全连接层。这会导致参数量爆炸(例如 VGG-16 最后的全连接层有 1 亿多参数)。现代网络(如 ResNet, MobileNet)普遍采用 GAP 替代 Flatten + FC。
图 7.1 辅助理解平均池化的概念:来源于 2011 年的一篇博客 Mosaic Plot with R。头像的每个像素点位置由一张猫的照片填充,填充的逻辑是:本地有一个 5000 张灰度图的图库,每张照片的颜色均值已知。按照头像的每个像素点灰度值,找到照片颜色均值最接近的一张图,依次填充进去,就得到了头像的马赛克图。
以最大池化举例:\(2 \times 2\) 的最大池化配合步长 \(S=2\),可以将特征图的长和宽各减半。
\[ \begin{bmatrix} 1 & 3 & | & 2 & 4 \\ 5 & 9 & | & 1 & 1 \\ \hline 8 & 2 & | & 3 & 2 \\ 4 & 6 & | & 9 & 5 \end{bmatrix} \]
池化操作会在每个区域内选取最大值:
- 左上区域 \([1, 3, 5, 9]\) \(\rightarrow\) 最大值是 9
- 右上区域 \([2, 4, 1, 1]\) \(\rightarrow\) 最大值是 4
- 左下区域 \([8, 2, 4, 6]\) \(\rightarrow\) 最大值是 8
- 右下区域 \([3, 2, 9, 5]\) \(\rightarrow\) 最大值是 9
经过池化后,图像尺寸减半,数据量减少 75%,但保留了最显著的特征(强响应点):
\[\begin{bmatrix} 9 & 4 \\ 8 & 9 \end{bmatrix}\]
7.3.1 归一化层
在上一章中,我们介绍了批归一化 (Batch Normalization) 如何通过平滑损失地形来加速 MLP 的训练。然而,当我们处理图像数据时,直接套用 MLP 的逻辑(对每个特征维度独立归一化)是行不通的。
图像数据的核心特性是平移不变性——如果一个卷积核在图像左上角负责检测“垂直边缘”,那么它在图像右下角也应该检测同样的特征。为了配合卷积核的这种特性,我们的归一化策略也必须在空间上是“共享”的。
这就引入了 2D 卷积网络中特有的归一化方式:Channel-wise(按通道)归一化。
让我们从张量形状的角度来理解 nn_batch_norm1d (MLP) 与 nn_batch_norm2d (CNN) 的区别:
全连接层 (MLP):
- 输入形状:\((N, D)\),其中 \(N\) 是样本数,\(D\) 是特征数。
- 统计方式:对于每个特征 \(d\),计算它在 \(N\) 个样本上的均值和方差。
- 参数数量:\(2 \times D\) 个(即 \(D\) 个 \(\gamma\) 和 \(D\) 个 \(\beta\))。
卷积层 (CNN):
- 输入形状:\((N, C, H, W)\),其中 \(C\) 是通道数,\(H, W\) 是空间尺寸。
- 统计方式:对于每个通道 \(c\),我们需要计算该通道在所有样本 (\(N\)) 以及所有空间位置 (\(H, W\)) 上的综合统计量。
- 参数数量:\(2 \times C\) 个。注意,无论图片多大 (\(H, W\)),参数量只与通道数有关。
数学定义:聚合轴 (Aggregation Axes)
对于输入张量 \(x \in \mathbb{R}^{N \times C \times H \times W}\),我们在通道 \(c\) 上的均值 \(\mu_c\) 计算公式为:
\[ \mu_c = \frac{1}{N \cdot H \cdot W} \sum_{n=1}^{N} \sum_{h=1}^{H} \sum_{w=1}^{W} x_{n,c,h,w} \]
方差 \(\sigma_c^2\) 同理。
同 nn_batch_norm1d 一样,nn_batch_norm2d 同样也包含两个可学习参数,只是形状变成了 \((C)\) 向量:
- Weight (\(\gamma\)):缩放因子,长度为 \(C\)。
- Bias (\(\beta\)):平移因子,长度为 \(C\)。
这意味着网络可以为每个通道学习不同的分布特征。
7.3.2 激活函数
在 CNN 中常用的激活函数有两个:
ReLU (Rectified Linear Unit):
\[ \text{ReLU}(x) = \begin{cases} x, & \text{if } x > 0 \\ 0, & \text{otherwise} \end{cases} \] 优点:计算极快,解决了梯度消失问题。缺点:Dead ReLU。如果某个神经元在训练中输入变为负数,它的梯度永远为 0,这个神经元就“死”了,再也不会更新。
LeakyReLU:
\[ \text{LeakyReLU} (x)= \begin{cases} x, & \text{if } x \geq 0 \\ \mathit{negative\_slope} \times x, & \text{otherwise} \end{cases} \]
给负半区一个很小的斜率,保证梯度能流回,避免神经元坏死。如果发现网络很深难以收敛,可以尝试将 nn_relu() 换成 nn_leaky_relu(negative_slope = 0.01)。
7.4 从零搭建卷积网络
前文详细描述的 LeNet-5 是第一个证明了卷积层(Convolution)比全连接层更适合图像任务的模型。它确立了“卷积层提取特征 \(\to\) 池化层降维 \(\to\) 全连接层分类”的经典范式。在掌握了这些核心组件后,我们将像搭积木一样,使用 torch 搭建一个用于 MNIST 手写数字识别(\(28 \times 28\) 灰度图,10 分类)的卷积神经网络。
7.4.1 网络结构实现
我们定义一个名为 MNIST_CNN 的网络。为了更清晰地展示数据流动的过程,我们在 forward 函数中标记了张量(Tensor)在每一步的形状变化 [Batch, Channel, Height, Width]。
#| filename: "7_cnn_mnist.R"
cnn_module <- nn_module(
"MNIST_CNN",
initialize = function() {
# Conv1: 输入1通道(灰度), 输出16通道, 3x3核
self$conv1 <- nn_conv2d(1, 16, kernel_size = 3, padding = 1)
# Pool: 2x2 最大池化
self$pool <- nn_max_pool2d(kernel_size = 2)
# Conv2: 输入16通道, 输出32通道, 3x3核
self$conv2 <- nn_conv2d(16, 32, kernel_size = 3, padding = 1)
# FC1: 展平后的维度是 32 * 7 * 7 = 1568
self$fc1 <- nn_linear(32 * 7 * 7, 128)
self$fc2 <- nn_linear(128, 10)
self$dropout <- nn_dropout(p = 0.5)
},
forward = function(x) {
# x 输入形状: [N, 1, 28, 28]
# 第一层块: Conv -> ReLU -> Pool
x <- self$conv1(x) %>% nnf_relu() # -> [N, 16, 28, 28] (padding=1 保持尺寸)
x <- self$pool(x) # -> [N, 16, 14, 14] (尺寸减半)
# 第二层块: Conv -> ReLU -> Pool
x <- self$conv2(x) %>% nnf_relu() # -> [N, 32, 14, 14] (通道翻倍)
x <- self$pool(x) # -> [N, 32, 7, 7] (再次减半)
# 展平: 将三维特征图拉直为一维向量
x <- torch_flatten(x, start_dim = 2)# -> [N, 1568]
# 全连接分类
x <- self$fc1(x) %>% nnf_relu() # -> [N, 128]
x <- self$dropout(x)
x <- self$fc2(x) # -> [N, 10]
return(x)
}
)关于 Padding 的选择: 我们在卷积层使用了 padding = 1 配合 kernel_size = 3。 根据公式 \((W - F + 2P)/S + 1\),计算为 \((28 - 3 + 2)/1 + 1 = 28\)。 这种设置被称为 “Same Padding”,目的是让卷积操作不改变图片的尺寸,将降维的任务完全交给池化层(Pooling)处理,使网络结构的设计更清晰。
7.4.2 参数量剖析
理解每一层有多少个参数(Parameter)需要被训练,是掌握 CNN 的关键。我们通过公式计算,并与代码实际运行结果进行对比。
参数计算公式:
- 卷积层:\((K \times K \times C_{in} + 1) \times C_{out}\) (核宽 \(\times\) 核高 \(\times\) 输入通道 + 偏置)\(\times\) 输出通道
- 全连接层:\((N_{in} + 1) \times N_{out}\) (输入节点数 + 偏置)\(\times\) 输出节点数
| 层级 | 类型 | 形状变换 | 参数计算细节 | 参数数量 | 占比 |
|---|---|---|---|---|---|
| Conv1 | 卷积 | \(1 \to 16\) | \((3 \times 3 \times 1 + 1) \times 16\) | 160 | <0.1% |
| Conv2 | 卷积 | \(16 \to 32\) | \((3 \times 3 \times 16 + 1) \times 32\) | 4,640 | 2.3% |
| FC1 | 全连接 | \(1568 \to 128\) | \((1568 + 1) \times 128\) | 200,832 | 97.1% |
| FC2 | 全连接 | \(128 \to 10\) | \((128 + 1) \times 10\) | 1,290 | 0.6% |
| 总计 | 206,922 | 100% |
请注意 FC1 层。虽然 Conv2 看起来很复杂(处理 32 个通道),但它的参数量仅有 4640 个。而 FC1 层因为需要将卷积层输出的 1568 个特征点与 128 个神经元全连接,参数量瞬间爆炸到了 20 万,占据了整个模型 97% 的参数量。
这也是为什么现代深度网络(如 ResNet)倾向于在末端使用“全局平均池化(Global Average Pooling)”来替代庞大的全连接层,以大幅减少模型体积。
使用代码来查看参数情况:
initial_model <- cnn_module()
sapply(initial_model$parameters, function(x) x$numel())
# conv1.weight conv1.bias conv2.weight conv2.bias
# 144 16 4608 32
# fc1.weight fc1.bias fc2.weight fc2.bias
# 200704 128 1280 10 可以看到,模型的参数数量与我们计算的一致。
7.4.3 学习到了什么
利用 luz 框架,我们增加了准确率这个指标,十几秒训练的 3 个 epochs 就可以达到接近 99% 的准确率。
fitted <- cnn_module %>%
setup(
loss = nn_cross_entropy_loss(),
optimizer = optim_adam,
metrics = list(luz_metric_accuracy())
) %>%
set_hparams() %>%
fit(
train_dl,
epochs = 3,
valid_data = test_dl
)网络训练的过程,就是卷积核(Kernels)从“随机混沌”进化到“有序提取”的过程。我们将分别观察卷积核(权重)本身,以及图像经过卷积后的特征图(Feature Maps)。
1. 卷积核的变化
我们先来看第一层卷积(Conv1)的 16 个卷积核在训练前后的对比。


请仔细观察:
- Ker 12 呈现出明显的“黑-白-灰”垂直结构,这可能是一个垂直边缘检测器。
- Ker 13 呈现出“上黑下白”的结构,这通常用于检测水平边缘。
传统的计算机视觉(Photoshop 时代)需要人类手工设计这些矩阵:如果我们想找轮廓,就设计一个边缘检测核;如果我们想去噪,就设计一个模糊核。
而深度学习(CNN)的魔法在于:我们不需要告诉计算机‘去找垂直边缘’。我们只需要给它看成千上万个数字,通过反向传播(Backpropagation),网络会自动调整这 \(3 \times 3\) 矩阵里的 9 个数字,进化出最适合识别任务的滤镜组合。
2. 第一层特征(Conv1)变化


随机初始化的卷积核是随机的,因此输出的特征图只是原始“9”的简单复制或反色,显得有些模糊,没有强调任何特定部分。
但训练后的效果非常明显!模型学会了“拆解”。
- 请看 第 3 行第 4 列 的子图:貌似光线从右边照射过来。
- 请看 第 4 行第 1 列 的子图:貌似光线从下面照射过来。
模型不再是看整张图,而是开始提取“圆圈”、“竖线”等基础几何特征。
3. 第二层特征(Conv2)变化


随机初始化的 Conv2 特征图依然是充满噪声的混乱图像,没有任何有效信息。但是训练后的特征图就有意思了:我们几乎看不出“9”的形状了,取而代之的是黑背景上稀疏的“光点”。
这正是卷积神经网络强大的根源——稀疏表示与抽象化。
- 图中的每一个亮斑,不再代表一个像素,而是代表“这里有一个特定的部件”。
- 例如,某一个通道的亮斑可能代表“检测到了一个上半圆”,另一个通道代表“检测到了垂直长线”。
对于后续的分类器来说,它不需要看清图片,只要看到这些特定的“概念灯泡”亮起,它就能自信地判断:“有圆环,有尾巴,这一定是个 9!”
7.5 模块化的经典架构
在上一节中,我们手动堆叠了一个简单的 CNN。然而,当网络深度从几层增加到几十层甚至上百层(如 ResNet-152)时,手动书写每一层代码不仅低效,而且极易出错。
现代深度学习架构的设计哲学是模块化(Modularity)。我们不再关注单个卷积层,而是关注“块”(Block)。本节我们将学习如何利用 R 语言的函数式编程特性和 R6 面向对象系统,构建 VGG 和 ResNet 的核心组件。
7.5.1 VGG 块
VGG 网络(Visual Geometry Group Network)确立了现代 CNN 的第一个重要设计模式:规律性重复。它不再像 AlexNet 那样每层的超参数都不同,而是由多个结构相同的“卷积块”组成。
一个标准的 VGG 块包含:
- 数个保持尺寸 (高和宽) 不变的 \(3 \times 3\) 卷积层(padding=1)。
- 每个卷积层后接 ReLU 激活。
- 块的末尾接一个 \(2 \times 2\) 最大池化层(用于减半尺寸)。
在 R 中,我们可以编写一个函数,根据输入的参数动态生成一个 nn_sequential 容器。
vgg_block <- function(num_convs, in_channels, out_channels) {
layers <- list()
for (i in 1:num_convs) {
layers[[length(layers) + 1]] <- nn_conv2d(
in_channels = in_channels,
out_channels = out_channels,
kernel_size = 3,
padding = 1 # 保持尺寸不变
)
layers[[length(layers) + 1]] <- nn_relu()
# 技巧:如果块内有多层卷积,下一层的输入通道必须等于上一层的输出通道
in_channels <- out_channels
}
# 块的末尾统一添加池化层 (尺寸减半)
layers[[length(layers) + 1]] <- nn_max_pool2d(kernel_size = 2, stride = 2)
# 将列表打包为 nn_sequential
do.call(nn_sequential, layers)
}为什么是 \(3 \times 3\)?
VGG 论文通过实验证明,堆叠两个 \(3 \times 3\) 的卷积层,其感受野(Receptive Field)相当于一个 \(5 \times 5\) 的卷积层。但这样做有两个巨大的优势:
- 参数更少:\(2 \times (3 \times 3) = 18\) vs \(1 \times (5 \times 5) = 25\)。
- 非线性更强:两个卷积层之间夹了两次 ReLU,比单层卷积多一次非线性变换,这让网络能学习更复杂的特征。
标准的 VGG-16 网络是为了处理 ImageNet (\(224 \times 224\)) 图像设计的,包含 5 个池化层。如果我们直接将其用于 MNIST (\(28 \times 28\)),特征图在经过 5 次减半后会变成 0,导致报错。
因此,我们针对 MNIST 定制一个 “Mini-VGG”。它保留了 VGG 的深层堆叠思想,但调整了块的数量。我们将使用 2 个 VGG 块,每个块包含 2 层卷积(num_convs = 2)。
MiniVGG <- nn_module(
"MiniVGG",
initialize = function() {
# === 1. 特征提取器 (Features) ===
# 就像搭积木一样,我们串联了两个 vgg_block
self$features <- nn_sequential(
# Block 1: [Conv-ReLU-Conv-ReLU] -> Pool
# 输入: 1通道 -> 输出: 64通道
# 尺寸变化: 28x28 -> (池化) -> 14x14
vgg_block(num_convs = 2, in_channels = 1, out_channels = 64),
# Block 2: [Conv-ReLU-Conv-ReLU] -> Pool
# 输入: 64通道 -> 输出: 128通道
# 尺寸变化: 14x14 -> (池化) -> 7x7
vgg_block(num_convs = 2, in_channels = 64, out_channels = 128)
)
# === 2. 分类器 (Classifier) ===
self$classifier <- nn_sequential(
nn_flatten(),
# 维度计算: 128个通道 * 7(高) * 7(宽) = 6272
nn_linear(128 * 7 * 7, 256),
nn_relu(),
nn_dropout(0.5), # 防止过拟合
nn_linear(256, 10)
)
},
forward = function(x) {
x %>%
self$features() %>%
self$classifier()
}
)在这个模型中,特征提取部分变得异常清晰。如果你想加深网络,只需将 num_convs 改为 3 或 4,而不需要手动编写几十行重复的代码。
当然当你训练网这个网络,发现同样 3 轮迭代,精确度又有提升。
7.5.2 残差网络 (ResNet)
随着网络层数的加深(例如超过 20 层),研究者发现了一个反直觉的现象:退化问题(Degradation Problem)。更深的网络在训练集上的误差反而比浅层网络更高。这并非过拟合,而是因为梯度在深层反向传播时逐渐消失或爆炸,导致优化变得异常困难。
ResNet(Residual Network)通过引入跳跃连接(Skip Connection)完美解决了这个问题。
假设一个深层网络的一个子结构(几层卷积)试图学习一个复杂的底层映射 \(H(x)\)。在传统的平铺网络(Plain Network)中,非线性层的堆叠直接逼近目标函数:
\[H(x) \approx f_n(f_{n-1}(\dots f_1(x)))\]
根据通用近似定理(Universal Approximation Theorem),多层非线性网络确实可以逼近任何连续函数。但在工程实践中,由于非线性激活函数(如 ReLU)的存在,让网络去逼近一个简单的恒等映射(Identity Mapping) \(H(x) = x\) 实际上是非常困难的。
ResNet 的构造假设:与其让网络直接逼近 \(H(x)\),不如让它逼近残差映射(Residual Mapping) \(F(x) := H(x) - x\)。此时,原始的目标函数变为:
\[H(x) = F(x) + x\]
如果最优解倾向于恒等映射,优化器只需要将权重和偏置推向零,使 \(F(x) \approx 0\)。在权重衰减(Weight Decay/L2 Regularization)的作用下,将函数推向零比推向一个特定的非线性恒等映射要容易得多。考虑一个由 \(L\) 个残差块组成的简化模型,其递归定义为:
\[x_{l+1} = x_l + F(x_l, W_l)\]
根据递归展开,任意深层 \(L\) 的状态可以表示为:
\[x_L = x_l + \sum_{i=l}^{L-1} F(x_i, W_i)\]
根据链式法则,损失函数 \(\mathcal{E}\) 对浅层输出 \(x_l\) 的导数为:
\[ \frac{\partial \mathcal{E}}{\partial x_l} = \frac{\partial \mathcal{E}}{\partial x_L} \frac{\partial x_L}{\partial x_l} = \frac{\partial \mathcal{E}}{\partial x_L} \left( 1 + \frac{\partial}{\partial x_l} \sum_{i=l}^{L-1} F(x_i, W_i) \right) \]
- 由于存在项 \(1\),即便残差分支的梯度 \(\frac{\partial F}{\partial x}\) 极小,误差信号 \(\frac{\partial \mathcal{E}}{\partial x_L}\) 依然可以无损地回传给浅层 \(x_l\)。
- 加法 在平铺网络中,\(\frac{\partial x_L}{\partial x_l}\) 是各层雅可比矩阵的连乘,极易导致特征值塌陷或爆炸;在 ResNet 中,它变成了加法结构,这极大拓宽了可训练权重的解空间。
残差块的逻辑无法简单的通过 nn_sequential 实现,因为它涉及分叉和合并。我们需要定义一个自定义的 nn_module。
residual_block <- nn_module(
"ResidualBlock",
initialize = function(in_channels, out_channels, stride = 1) {
# 主路径 (F(x)):包含两个卷积层
self$conv1 <- nn_conv2d(in_channels, out_channels, kernel_size = 3,
padding = 1, stride = stride, bias = FALSE)
self$bn1 <- nn_batch_norm2d(out_channels)
self$relu <- nn_relu()
self$conv2 <- nn_conv2d(out_channels, out_channels, kernel_size = 3,
padding = 1, bias = FALSE)
self$bn2 <- nn_batch_norm2d(out_channels)
# 捷径 (x):如果输入输出形状不一致(stride > 1 或 通道改变),
# 需要通过一个 1x1 卷积调整 x 的形状以匹配 F(x)
self$downsample <- if (stride > 1 || in_channels != out_channels) {
nn_sequential(
nn_conv2d(in_channels, out_channels, kernel_size = 1,
stride = stride, bias = FALSE),
nn_batch_norm2d(out_channels)
)
} else {
function(x) x # 恒等映射
}
},
forward = function(x) {
identity <- self$downsample(x)
out <- self$conv1(x) %>% self$bn1() %>% self$relu()
out <- self$conv2(out) %>% self$bn2()
# 核心操作:残差相加
out <- out + identity
# 相加后再通过 ReLU
out %>% self$relu()
}
)这段代码展示了现代 CNN 的标准构建单元。通过这种“残差学习”,我们可以轻松训练深度达 100 层甚至 1000 层的网络。
7.5.3 全局平均池化 (GAP)
在 VGG 和早期 CNN 架构中,卷积层与全连接层(分类器)之间的连接通常依赖于 Flatten(展平) 操作。这带来了一个严重的问题:参数爆炸。
回顾我们之前的 MNIST CNN 模型,在进入分类器之前,特征图的形状是 [N, 32, 7, 7]。
- 传统做法 (Flatten):将 \(32 \times 7 \times 7\) 拉直为 1568 个点。全连接层的权重矩阵大小为 \(1568 \times 10\),仅这一层就有 15,680 个参数。
- GAP 做法:如果我们能省略掉 \(7 \times 7\) 这个空间维度,直接将每个通道浓缩为一个数,那么输入就变成了 32。全连接层权重仅为 \(32 \times 10\),参数量骤降至 320 个。
全局平均池化 (Global Average Pooling, GAP) 正是为此而生。它的逻辑非常简单:对于每一个特征通道(Channel),计算该通道上所有像素的平均值。
R 代码实现:用
mean()替代 Flatten
在 Network In Network 论文中首次提出的这一理念,现已成为 ResNet 等现代架构的标配。在 R torch 中,我们可以通过对张量指定维度求均值来实现。
让我们看看如何改造之前的 CNN 模型:
cnn_gap_module <- nn_module(
"MNIST_CNN_GAP",
initialize = function() {
# 卷积层保持不变
self$conv1 <- nn_conv2d(1, 16, kernel_size = 3, padding = 1)
self$pool <- nn_max_pool2d(kernel_size = 2)
self$conv2 <- nn_conv2d(16, 32, kernel_size = 3, padding = 1)
# --- 关键变化 1:全连接层的输入维度 ---
# 传统 CNN 这里是: nn_linear(32 * 7 * 7, 10)
# 使用 GAP 后,我们不再关心空间尺寸(7x7),只关心通道数(32)
self$fc_out <- nn_linear(32, 10)
},
forward = function(x) {
# [N, 1, 28, 28] -> [N, 16, 14, 14]
x <- self$pool(nnf_relu(self$conv1(x)))
# [N, 16, 14, 14] -> [N, 32, 7, 7]
x <- self$pool(nnf_relu(self$conv2(x)))
# --- 关键变化 2:全局平均池化 ---
# 此时 x 的形状是 [N, 32, 7, 7] (Batch, Channel, Height, Width)
# 我们需要在 Height(dim=3) 和 Width(dim=4) 方向上求平均
# 操作后,7x7 的空间信息被压缩为 1 个均值
x <- x$mean(dim = c(3, 4))
# 此时 x 的形状变成了 [N, 32]
# 刚好匹配 fc_out 的输入
x <- self$fc_out(x)
return(x)
}
)GAP 的三大优势:
- 分类头的参数量减少了 98% (15,680 vs 320)。在更深的网络(如 ResNet-50)中,这种节省是惊人的,它使得深层网络的文件体积依然可以保持得很小。
- 全连接层因为参数众多,非常容易死记硬背特征的位置信息。GAP 没有任何参数需要学习,它强制网络在卷积层就提取出高置信度的特征(Feature Maps),这本身就是一种结构上的正则化。
- 注意看
self$fc_out的定义,它只依赖于通道数 32。这意味着,无论你喂给网络的是 \(28 \times 28\) 的图片,还是 \(100 \times 100\) 的图片,经过x$mean(dim = c(3, 4))后,输出永远是[N, 32]
GAP 引导网络去关注特征的存在性(What),而不是特征的具体位置(Where)。这对于分类任务来说是非常自然的,因为我们通常只关心“图里是不是猫”,而不关心“猫在左下角还是右上角”。
7.6 图像处理流
但对于 R 用户而言,我们拥有自己独特的图像生态(如 magick 和 imager)。本节将打通 R 图像对象与 torch 张量之间的“最后一公里”,构建一个高效、标准的图像处理流水线。
7.6.1 维度转换
当我们使用 magick::image_read() 或大多数 R 图形包读取图像并转化为数组时,数据的维度通常是 \((Height, Width, Channels)\)。例如,一张 \(224 \times 224\) 的 RGB 彩色图片,其数组形状为 (224, 224, 3)。
然而,R torch 为了优化底层 CUDA 计算,强制要求卷积层的输入必须是 \((Channels, Height, Width)\)。即上述图片必须转换为 (3, 224, 224)。
当然处理也很容易,借助 permute 函数:
tensor_chw <- raw_data$permute(c(3, 1, 2))在实际工程中,我们不需要每次都手动写这么多代码。torchvision 包提供了高度封装的工具。
7.6.2 Torchvision 管道与变换
torchvision 是 torch 生态中专门处理计算机视觉任务的包。它提供了一系列 transform_* 函数,用于构建预处理管道。
最核心的函数是 transform_to_tensor。它不仅完成了从 R 数组/位图到张量的转换,还自动执行了以下两个关键操作:
- 自动将 HWC 转换为 CHW。
- 自动将 [0, 255] 的整数像素值除以 255,缩放到 [0, 1] 区间。
在深度学习的工程实践中,有一句名言:“数据决定了模型的上限,而算法只是在不断逼近这个上限。”对于图像任务,如何高效地将磁盘上的图片转化为 GPU 上的张量,并利用数据增强(Data Augmentation)压榨出数据的每一分潜力。
我们在训练过程中动态地“伪造”新数据:把图片随机旋转一下、裁切一部分、把颜色调亮一点、水平翻转一下。对于网络来说,这些都是它没见过的新样本,迫使它学习更本质的特征,而不是死记硬背像素位置。常用增强算子:
- 随机裁剪 (Random Resized Crop):模拟物体在画面中的远近和位置变化。
- 水平翻转 (Horizontal Flip):最简单有效的增强,增加模型对方向的鲁棒性。
- 色彩抖动 (Color Jitter):随机调整亮度、对比度和饱和度,模拟不同的光照条件。
train_transforms <- function(img) {
img %>%
transform_random_resized_crop(size = c(224, 224)) %>%
transform_random_horizontal_flip(p = 0.5) %>%
transform_color_jitter(brightness = 0.2, contrast = 0.2) %>%
transform_to_tensor() %>%
transform_normalize(mean = c(0.485, 0.456, 0.406),
std = c(0.229, 0.224, 0.225))
}
多种增强方式串联起来是标准做法,但也有些方法在某些场景下不适用,比如手写识别中水平翻转(将 6 变成 9),过度的旋转会改变标签含义,导致模型无法收敛。另外,数据增强仅应用于训练集,验证集和测试集只需进行缩放和归一化。
调试技巧:
前面的代码将原始图片进行了随机裁剪、水平翻转和色彩抖动增强,最后转换为张量并归一化,因为是归一化操作,我们要看原始图片需要反归一化(Inverse Normalization)才能看到原始图片。
# 将标准化后的张量还原并显示
visualize_tensor <- function(tensor_img) {
mean <- c(0.485, 0.456, 0.406)
std <- c(0.229, 0.224, 0.225)
# 公式: output = (input * std) + mean
# 假设 tensor_img 形状是 [3, H, W]
# 我们需要将 mean 和 std 调整为 [3, 1, 1] 形状以便广播
img_rescaled <- tensor_img$cpu() * torch_tensor(std)$view(c(3, 1, 1)) +
torch_tensor(mean)$view(c(3, 1, 1))
img_array <- img_rescaled %>%
torch_clamp(0, 1) %>% # 确保值在 [0, 1] 之间
as.array() %>%
aperm(c(2, 3, 1)) # CHW -> HWC
plot(as.raster(img_array))
}
# 抽取一个 batch 查看
batch <- train_dl$.iter()$.next()
visualize_tensor(batch$x[1,]) # 显示该 batch 中第一张增强后的图
7.7 迁移学习与混合模型
在现实工作中,我们很少从零开始训练一个像 ResNet-50 这样拥有几千万参数的深层网络。那需要海量的数据(ImageNet 有 1400 万张图)和昂贵的 GPU 集群。绝大多数情况下,我们使用迁移学习 (Transfer Learning)。
一个在大型数据集(ImageNet)上训练好的网络,它的卷积层已经学会了极其丰富的特征提取能力(“线条”、“边缘”、“纹理”以及“复杂的几何形状”等)。 这些基础视觉特征在识别猫狗和识别细胞病变或卫星图像时是通用的。我们可以把这个网络的前半部分(骨干网络 Backbone)拿来当做现成的特征提取器,只重新训练最后面的分类层。
在 R torch 的迁移学习有以下几步:
- 加载预训练模型。
- 冻结骨干网络的参数(设置
requires_grad = FALSE),让它们在训练中保持不变。 - 替换最后一层全连接层(Head),使其输出维度匹配你的新任务类别数,只训练新加的那一层。
7.7.1 冻结骨架策略
我们以 MobileNetV2(Sandler 等 2019) 为例,展示迁移学习的具体实现。这里需要调用 torchvision 包的 model_mobilenet_v2 函数。
mobilenetv2 网络,输入分辨率为 \(224 \times 224 \times 3\),约 340 万个参数,总计算量约 3 亿次浮点运算。主干部分由约 17 个模块组成,每个模块包含多层卷积,总计 53 层。头部全连接层是 1280 维特征映射到目标类别数。Top1-精度 ~72%。
我们将预训练模型视为一个固定的特征提取器。我们冻结(Freeze)所有卷积层参数,只训练最后新添加的分类层(Fully Connected Layer)。
#| filename: "7_cnn_CIFAR-10.R"
MobileNetV2_HeadOnly <- nn_module(
"MobileNetV2_HeadOnly",
initialize = function(num_classes = 10) {
full_model <- model_mobilenet_v2(pretrained = TRUE)
self$features <- full_model$features
for (p in self$features$parameters) {
p$requires_grad <- FALSE # 冻结卷积层所有参数
}
self$dropout <- nn_dropout(p = 0.2)
self$fc <- nn_linear(in_features = 1280, out_features = num_classes)
},
forward = function(x) {
x %>%
self$features() %>%
# 全局平均池化: (B, 1280, H, W) -> (B, 1280, 1, 1)
nnf_adaptive_avg_pool2d(output_size = c(1, 1)) %>%
torch_flatten(start_dim = 2) %>%
self$dropout() %>%
self$fc()
}
)model_mobilenet_v2(pretrained = TRUE) 是 torchvision 提供的接口。它会从远程仓库下载在 ImageNet 上训练好的权重。在底层,这些权重是以 .pth 文件形式存储并在加载时映射到 R 的张量中。
MobileNetV2 本身是一个 1000 分类模型,但全连接层 Head 可以按照我们的需要调整参数,比如 10 类。通过 nn_linear 的输出设定 out_features 参数。
前文设计的网络结构每一层的形状如下:
| 步骤 | 操作 | 输入形状 | 输出形状 | 说明 |
|---|---|---|---|---|
| 输入 | 图片 | [16,3,64,64] | [16,3,64,64] | Batch=16 |
| 骨干网 | self$features |
[16,3,64,64] | [16,1280,2,2] | 1280 个特征 |
| 池化 | avg_pool2d |
[16,1280,2,2] | [16,1280,1,1] | 压缩 |
| 展平 | torch_flatten |
[16,1280,1,1] | [16,1280] | 变为一维 |
| 全连接 | self$fc |
[16,1280] | [16,10] | 映射到类别数 |
我们使用 CIFAR-10 数据集来进行演示,该数据集包含 60000 张 32x32 彩色图像,分为 10 个类别,每类6000 张图像。训练图像有 5 万张,测试图像有 1 万张。这个数据集通过 torchvision 包提供,我们可以直接通过 cifar10_dataset 直接加载。
这个数据集下载后会被加载为 R 对象,但这里有个小 bug,数据目录下的 batches.meta.txt 为 10 个分类信息,它多了一个空行,导致加载会出现一个空类别。我们需要手动删除这一行。
fitted_frozen <- MobileNetV2_HeadOnly %>%
setup(
loss = nn_cross_entropy_loss(),
optimizer = optim_adam,
metrics = list(luz_metric_accuracy())
) %>%
set_hparams(num_classes = 10) %>%
set_opt_hparams(lr = 1e-3) %>% # 初始学习率可以稍大
fit(
train_dl,
epochs = 5, # 少量 epoch 即可收敛
valid_data = test_dl
)在 MobileNetV2 模型(这个模型的参数是利用 120 万张图片,1000 个类别提取的能力)的加持下,1 轮迭代就可以有 0.6 的准确率,3 轮可达:
> get_metrics(evaluation)
# A tibble: 2 × 2
metric value
<chr> <dbl>
1 loss 1.07
2 acc 0.6337.7.2 渐进式微调
仅靠“冻结骨干 Backbone”和“训练分类头 Head”往往只能发挥迁移学习 70% 的潜力。为了解决这个问题,微调(Fine-tuning)应运而生。它指的是在预训练模型的基础上,对参数进行微调,包括骨干网和头部全连接层。
当然,在算力有限的情况下,我们有效的策略是对部分骨干网进行微调,而不是对所有参数都进行微调。
显然,越靠近输出的层,捕捉的特征越具体(如细胞的特定边缘),越值得针对新任务进行微调。例如 MobileNetV2 有 19 个卷积层块,我们可以选择冻结前 18 个,只微调后 1 个卷积层块儿。这样可以在保持模型性能的同时,大大减少训练时间和计算量。该想法实现逻辑如下,仅呈现 initialize 部分。
# 从后往前数,需要解冻的块数 (0-18)
initialize = function(num_classes = 10,
unfreeze_blocks = 0) {
full_model <- model_mobilenet_v2(pretrained = TRUE)
self$features <- full_model$features
for (p in self$features$parameters) {
p$requires_grad <- FALSE
}
total_blocks <- length(self$features)
if (unfreeze_blocks > 0) {
start_idx <- max(1, total_blocks - unfreeze_blocks + 1)
for (i in start_idx:total_blocks) {
for (p in self$features[[i]]$parameters) {
p$requires_grad <- TRUE
}
}
}
self$dropout <- nn_dropout(p = 0.2)
self$fc <- nn_linear(in_features = 1280, out_features = num_classes)
}通过该设置,我们解冻了 MobileNetV2 的最后 unfreeze_blocks 个卷积块,其他参数保持冻结状态。我们将参数设置为 1,看看模型的表现。
> evaluation_FT <- fitted_FT %>% evaluate(data = test_dl)
> get_metrics(evaluation_FT)
metric value
<chr> <dbl>
1 loss 0.895
2 acc 0.688准确度有不错的提升(从 0.633 提升到 0.688),说明经过微调后的模型在新任务上有了更好的表现。
通过迁移学习,我们不需要再随机生成的杂乱参数上开始训练。在只有几百张图片的数据集上,短短几分钟内训练出一个世界级的图像分类器。这是深度学习工程中最实用、最强大的技术之一。
实际上,微调的效果还可以继续提升。我们可以解冻更多的卷积块,甚至解冻全部卷积块。本案例如果解冻全部参数,10 轮训练后训练集的 acc 可以达到 0.90+。
7.7.3 混合模型
深度学习并不总是孤立的。R 语言拥有极强的传统统计学习生态(XGBoost, RandomForest, LightGBM)。有时,将深度学习作为特征提取器(Feature Extractor),配合传统的梯度提升树(GBDT),能在这个小样本数据上取得比纯神经网络更好的效果,且解释性更强。
这种混合模型(Hybrid Model)的工作流如下:
- 图像 -> CNN Backbone -> 向量:利用预训练网络将非结构化的图像转换为结构化的数值向量(Embedding)。
- 向量 -> XGBoost -> 预测:利用 R 的 xgboost 包进行分类或回归。
我们将渐进式微调的 forward 部分做一个改造,将最后全连接层之前的特征单独构造一个函数:
extract_features = function(x) { # 混合模型的提取特征辅助方法
x %>%
self$features() %>%
nnf_adaptive_avg_pool2d(output_size = c(1, 1)) %>%
torch_flatten(start_dim = 2)
},
forward = function(x) {
x %>%
self$extract_features() %>% # 复用上面的方法
self$dropout() %>%
self$fc()
}之后在把训练集和测试集走一遍模型,获得 1280 维的向量:
#| filename: "7_cnn_mnist.R"
extract_features <- function(luz_obj, dataloader) {
model <- luz_obj$model
model$to(device = "cpu")
model$eval()
features_list <- list()
labels_list <- list()
with_no_grad({
coro::loop(for (batch in dataloader) {
x <- batch$x$to(device = "cpu")
y <- batch$y$to(device = "cpu")
feat <- model$extract_features(x)
features_list[[length(features_list) + 1]] <- as.matrix(feat)
labels_list[[length(labels_list) + 1]] <- as.integer(y)
})
})
list(
data = do.call(rbind, features_list), # 特征矩阵
label = unlist(labels_list) # 标签向量
)
}
train_extracted <- extract_features(fitted_FT, train_dl)
test_extracted <- extract_features(fitted_FT, test_dl)有了两个数据集,再通过 xgboost 包来训练标准 R 模型 (篇幅所限,该步骤省略),可获得比 evaluation_FT 更高的准确率。
这种“CNN + XGBoost”的策略在以下场景中往往是SOTA (State of the Art):
- 例如只有 50 张图片。神经网络的分类头(Linear Layer)需要大量数据才能收敛,而随机森林或 XGBoost 对小样本更鲁棒。
- 如果你的输入不仅有图像(如房屋照片),还有表格数据(如房屋面积、邮编)。你可以将 CNN 提取的图像特征(1280 维)与表格特征拼接在一起,丢进 XGBoost 训练。这是 Kaggle 竞赛中的经典刷分技巧。
通过这种方式,我们不仅利用了深度学习强大的感知能力(Backbone),还利用了传统统计学习强大的分类决策能力(XGBoost),这种“强强联合”是 R 深度学习实践中有益尝试。
7.8 一维卷积 (1D-CNN)
在前面的章节中,我们将卷积核(Kernel)比作一双在二维图像上滑动的眼睛。如果我们将维度的光圈收缩,让这双眼睛只在一个方向上滑动——那就是 1D 卷积(1D Convolution)。
虽然循环神经网络(RNN)通常被认为是处理序列数据的传统选择,但 1D CNN 在近年来异军突起 。它拥有 RNN 无法比拟的并行计算优势,且擅长捕捉局部特征(如心电图中的一个波峰,或传感器信号中的一次震荡)。
本节我们将利用 triact 包 (Simmler 和 Brouwers 2024) 提供的奶牛行为数据集,训练一个神经网络来自动识别奶牛是在站立 (Standing) 还是 躺卧 (Lying) 。
加速度计通过测量三维空间中的重力加速度和动态身体加速度来捕捉动作 。这就好比我们之前的 RGB 图像:
- 图像:有 Red, Green, Blue 3 个颜色通道。
- 传感器:有 Forward, Up, Right 3 个加速度通道 。

当奶牛从站立变为躺卧时,其附着在腿部的传感器坐标系会相对于重力方向发生旋转 。这种物理特性的改变,在 1D CNN 看来,就是不同通道间数值分布的“特征跳变” 。
在处理 triact 包自带的传感器数据时,我们的重点在于坐标系映射与滑动窗口切片 。
- 由于不同传感器厂商的列定义不同,我们需要通过 timeFwdUpRight_cols 明确指定时间轴与三个加速度轴的对应关系 。
- CNN 无法直接处理无限长的数据流。我们采用 5 秒长度(50 个采样点) 作为一个窗口样本,并设置 50% 的重叠步长。重叠确保了即使动作发生在窗口边缘,也能被相邻窗口完整捕捉。
- 对 X、Y、Z 三轴分别进行 Z-Score 标准化。

7.8.1 架构设计
为了处理这组多通道序列,我们设计了一个轻量级的卷积模型:
#| filename: "7_cnn_1d_triact.R"
CowNet <- nn_module(
"CowNet",
initialize = function(num_classes = 2) {
# Block 1: 捕捉初级波形特征 (32个通道)
self$conv1 <- nn_sequential(
nn_conv1d(in_channels = 3, out_channels = 32, kernel_size = 5, padding = 2),
nn_batch_norm1d(32),
nn_relu(),
nn_max_pool1d(kernel_size = 2) # 长度从 50 压缩至 25
)
# Block 2: 提取深层语义特征 (64个通道)
self$conv2 <- nn_sequential(
nn_conv1d(32, 64, kernel_size = 3, padding = 1),
nn_batch_norm1d(64),
nn_relu(),
nn_adaptive_avg_pool1d(1) # 全局平均池化,压缩时间维
)
# 分类头
self$fc <- nn_sequential(
nn_flatten(),
nn_linear(64, 32),
nn_relu(),
nn_dropout(0.5),
nn_linear(32, num_classes)
)
},
forward = function(x) {
x %>% self$conv1() %>% self$conv2() %>% self$fc()
}
)5轮训练之后,模型达到 0.9998 的准确率。
通过对模型第一层卷积输出的热力图分析,我们可以发现:

- 模式 A (站立):激活了某些特定的特征通道(高亮条带),代表卷积核识别到了高数值的垂直分量。
- 模式 B (躺卧):原有的激活模式消失,另一组针对低重力或侧向重力的卷积核开始产生强烈响应。
这种互斥的激活模式证明了 CNN 不仅学会了简单的数值判断,还学会了过滤背景噪声,从而实现了比传统阈值法更鲁棒的识别能力 。