卷积网络¶
卷积神经网络直接从像素数据中学习空间特征层级,用梯度优化的滤波器取代了手工设计的滤波器。本文件涵盖卷积机制、池化、步长、膨胀、感受野,以及定义了图像分类的里程碑式架构(LeNet、AlexNet、VGG、ResNet、Inception、EfficientNet)。
-
在文件01中,我们手工设计了用于边缘检测、模糊和角点检测的滤波器。一个自然的问题是:能否从数据中学习最优的滤波器?这正是卷积神经网络(CNN)所做的。
-
CNN 不是手工选择滤波器权重,而是通过梯度下降(第06章)学习它们,发现对当前任务直接有用的特征。
-
在第06章中,我们介绍了卷积运算、CNN 基础以及滤波器学习的思想。这里我们将深入探讨使 CNN 成为计算机视觉领域十多年来主导范式的架构创新。
-
回顾核心的卷积运算:一个大小为 \(k \times k\) 的滤波器 \(K\) 在输入特征图上滑动,在每个位置计算点积(第06章)。输出大小由三个超参数控制:
- 步长:滤波器在位置之间移动的像素数。步长为1意味着滤波器每次移动一个像素;步长为2则移动两个像素,使空间尺寸减半。带步长的卷积是下采样时池化的一种替代方案。
- 填充:在输入边界周围添加零。“相同”填充(\(p = \lfloor k/2 \rfloor\))保持空间尺寸不变。“有效”填充(\(p = 0\))会减小尺寸。
- 膨胀:在滤波器元素之间插入间隙。膨胀率为2的3x3滤波器仅用9个参数就能覆盖5x5的感受野。膨胀卷积可以在不增加计算量的情况下扩大感受野。
-
卷积后的输出空间尺寸:
-
其中 \(\text{in}\) 是输入尺寸,\(k\) 是核尺寸,\(p\) 是填充,\(s\) 是步长。该公式分别适用于高度和宽度。
-
神经元的感受野是原始输入中能够影响其数值的区域。
- 浅层具有较小的感受野(它们看到的是边缘等局部模式)。
- 深层具有较大的感受野(它们看到的是物体部件等更大的结构)。
-
感受野随着每一层增长:大致每层卷积增加 \(k-1\) 个像素(步长或膨胀会增大增量)。
-
池化层在降低空间维度的同时保留最重要的信息。
- 最大池化取每个窗口中的最大值,保留最强的激活(最突出的特征)。
- 平均池化取平均值,平滑特征图。一个2x2池化窗口,步长为2,会将两个空间维度都减半。
-
全局平均池化将每个通道的整个空间范围平均成一个数值,产生一个长度等于通道数的向量。GAP 替代了许多现代架构末尾的全连接层,大幅减少了参数量,并起到了结构性正则化的作用。
-
批量归一化在每个小批量内将激活值归一化为零均值和单位方差,然后应用可学习的缩放和平移(第06章)。在 CNN 中,批量归一化按通道进行:统计量独立地对每个通道在批量和空间维度上计算。它能稳定训练、允许更高的学习率,并起到轻微的正则化效果。
-
丢弃法(第06章)在训练期间随机将神经元置零。
-
在 CNN 中,空间丢弃法(Dropout2D)会丢弃整个特征图通道,而不是单个像素,因为特征图中相邻像素高度相关,这种方法更有效。
-
数据增强通过在训练期间对每张图像应用随机变换来人工扩充训练集:水平翻转、随机裁剪、旋转、颜色抖动(调整亮度、对比度、饱和度、色调)以及剪切(遮挡随机矩形块)。网络以多种不同形式看到同一张图像,迫使它学习变换不变的特征,而不是记住特定的像素模式。
-
更高级的增强策略包括 Mixup(混合两张图像及其标签:\(\tilde{x} = \lambda x_i + (1-\lambda) x_j\),\(\tilde{y} = \lambda y_i + (1-\lambda) y_j\))、CutMix(将一张图像中的矩形块粘贴到另一张图像上,并按面积比例混合标签)以及 RandAugment(从固定集中随机采样一系列增强操作,使用单一强度参数)。
-
CNN 架构的历史是一个不断加深、更高效设计的故事,每个新架构都解决了限制前者的一个问题。
-
LeNet-5(LeCun 等,1998)是最早的 CNN,设计用于手写数字识别。两个卷积层后接三个全连接层,使用平均池化和 tanh 激活。它证明了学习到的滤波器优于手工设计的特征,但按现代标准来看非常小(6万个参数)。
-
AlexNet(Krizhevsky 等,2012)以巨大优势赢得了 ImageNet 竞赛,点燃了深度学习革命。关键创新:ReLU 激活(替代存在梯度消失问题的 tanh)、用于正则化的丢弃法、数据增强以及在 GPU 上训练。五个卷积层、三个全连接层、6000万个参数。
-
VGG(Simonyan 和 Zisserman,2014)表明,仅堆叠使用 3x3 滤波器比使用更大的滤波器效果更好。两个堆叠的 3x3 滤波器与一个 5x5 滤波器具有相同的感受野,但参数更少(\(2 \times 3^2 = 18\) vs \(5^2 = 25\)),并且多了一个非线性变换。VGG-16(16层)和 VGG-19(19层)至今仍广泛用作特征提取器。其架构非常简单:卷积块,通道数递增(64、128、256、512),每个块后跟最大池化。
- GoogLeNet/Inception(Szegedy 等,2014)引入了Inception 模块:不选择单一滤波器尺寸,而是将 1x1、3x3 和 5x5 的卷积并行使用,将其输出拼接起来,让网络决定哪个尺度最有用。在较大滤波器之前使用 1x1 卷积作为瓶颈,以减少计算量。GoogLeNet 以比 VGG 少 12 倍的参数(680万 vs 1.38亿)实现了更高的准确率。
-
Inception 模块同时捕获多个尺度的特征。1x1 滤波器捕捉逐点模式,3x3 捕捉局部纹理,5x5 捕捉更大的结构。拼接将所有视角组合成一个丰富的表示。
-
ResNet(He 等,2016)解决了退化问题:更深的网络表现不如较浅的网络,原因不是过拟合,而是更难优化。解决方案是跳跃连接(残差连接):
- 该层学习残差 \(F(x) = \text{output} - x\)。如果最优变换接近恒等映射(这在深层网络中很常见),学习一个接近零的残差比学习完整的映射要容易得多。跳跃连接还提供了直接的梯度高速公路,减轻了梯度消失问题。ResNet 训练了 152 层的网络,远超以往任何网络。
-
当输入和输出维度不同时(由于步长或通道数变化),投影捷径使用一个 1x1 卷积对 \(x\) 进行维度匹配:\(\text{output} = F(x) + W_s x\)。
-
瓶颈模块(用于 ResNet-50 及更深网络)使用三个卷积:1x1 降维,3x3 空间处理,1x1 升维回原通道数。这比两个 3x3 卷积更便宜,并允许构建更深的网络。
-
DenseNet(Huang 等,2017)将跳跃连接的思想推向了极致:在一个密集块内,每一层都与之后的所有层相连。第 \(l\) 层的输入是所有前面层的特征图拼接:\(x_l = H_l([x_0, x_1, \ldots, x_{l-1}])\),其中 \([\cdot]\) 表示沿通道维度拼接。这鼓励了特征复用、增强了梯度流动,并减少了总参数量。
-
高效架构面向移动设备和边缘硬件上的部署,这些场景计算、内存和能量都受限。
-
MobileNet(Howard 等,2017)用深度可分离卷积替代了标准卷积,将操作分解为两步:
- 深度卷积:对每个输入通道单独应用一个 \(k \times k\) 滤波器(无跨通道交互)
- 逐点卷积:应用 1x1 卷积来组合跨通道信息
-
一个标准的 \(k \times k\) 卷积,输入通道数为 \(C_{\text{in}}\),输出通道数为 \(C_{\text{out}}\),在每个空间位置的计算成本为 \(k^2 \cdot C_{\text{in}} \cdot C_{\text{out}}\) 次乘法。深度可分离卷积的成本为 \(k^2 \cdot C_{\text{in}} + C_{\text{in}} \cdot C_{\text{out}}\),约减少了 \(k^2\) 倍。对于 3x3 滤波器,这大约便宜 9 倍。
-
MobileNet-V2 引入了倒残差模块:先用 1x1 卷积扩展通道数,在扩展后的空间上进行深度卷积,再用 1x1 卷积投影回窄通道。跳跃连接放置在窄(瓶颈)层上,反转了 ResNet 的模式。扩展因子通常为 6。
-
EfficientNet(Tan 和 Le,2019)引入了复合缩放:不是独立地只缩放深度、或只缩放宽度、或只缩放分辨率,而是使用一个固定的比率将三个维度一起缩放。给定缩放系数 \(\phi\):
- 约束条件为 \(\alpha \cdot \beta^2 \cdot \gamma^2 \approx 2\)(使得总计算量大约随着 \(\phi\) 每增加一个单位而翻倍)。通过网格搜索找到基线比例 \(\alpha = 1.2\),\(\beta = 1.1\),\(\gamma = 1.15\)。从 EfficientNet-B0 到 B7 逐步放大,以远少于先前模型的参数和 FLOPs 达到了最先进的准确率。
-
ShuffleNet 通过使用分组卷积后接通道重排来降低 1x1 卷积的成本(在 MobileNet 式架构中占主导)。分组卷积将通道分成几组,在每组内独立进行卷积,但这会阻止跨组信息流动。重排操作在组之间重新排列通道,以极低成本恢复了信息混合。
-
迁移学习是将一个任务上训练好的模型应用到不同任务上的实践。在计算机视觉中,这几乎总是意味着从在 ImageNet(140万张图像,1000个类别)上预训练的模型开始,然后适应到特定领域的数据集(医学图像、卫星图像、制造缺陷)。
-
特征提取:冻结所有卷积层,移除最后的分类头,仅训练一个新的头部。冻结的层充当通用特征提取器。当目标领域与 ImageNet 相似且目标数据集较小时,这种方法效果很好。
-
微调:解冻部分或全部卷积层,并用较小的学习率进行训练。预训练权重作为起点而非固定特征。微调通常先只解冻后面的层(这些层捕获高层、任务特定的特征),之后可选择性地也解冻前面的层。
-
迁移学习之所以有效,是因为 CNN 的浅层学习通用特征(边缘、纹理、颜色),这些特征在不同任务中都有用,而深层学习任务特定的特征。一个训练用于分类动物的网络,其边缘检测器对分类建筑物仍然有用。
-
可视化 CNN 可以揭示网络学习到的内容,并帮助调试意外行为。
-
激活图(特征图)显示给定输入图像下每个滤波器的输出。浅层激活看起来像边缘图;深层激活则变得越来越抽象,空间上更粗糙。
-
Grad-CAM(梯度加权类激活映射,Selvaraju 等,2017)高亮显示输入图像中对模型预测最重要的区域。其工作原理如下:
- 计算目标类别得分相对于最后一个卷积层的特征图的梯度(使用第03章的链式法则)
- 对这些梯度进行全局平均池化,得到每个通道的重要性权重
- 计算特征图的加权组合并应用 ReLU
- 其中 \(A^k\) 是第 \(k\) 个特征图,\(\alpha_k\) 是通道 \(k\) 的重要性权重,\(y^c\) 是类别 \(c\) 的得分。结果是一个粗略的热力图,显示哪些区域驱动了分类。使用 ReLU 是因为我们只关心对类别有正影响的特征。
-
特征反演通过优化随机图像以匹配目标特征(使用梯度下降更新像素值),从特征表示中重构输入图像。这揭示了网络在每一层保留了哪些信息。浅层能重构出近乎完美的图像;深层会产生可辨认但扭曲的图像,表明精细的空间细节丢失而语义内容得以保留。
-
DeepDream 和神经风格迁移是特征可视化的创造性应用。DeepDream 最大化选定层神经元的激活,产生超现实、图案被放大的图像。神经风格迁移优化目标图像,使其匹配一张图像的内容特征(来自深层的特征图)和另一张图像的风格特征(滤波器激活的 Gram 矩阵,它捕捉纹理统计量)。
编程任务(使用 CoLab 或 notebook)¶
-
使用 JAX 从零实现一个简单的 CNN,包含两个卷积层、最大池化和一个分类头。在一个合成的二维模式分类任务上训练它。
import jax import jax.numpy as jnp import jax.lax as lax import matplotlib.pyplot as plt def conv2d(x, kernel, stride=1): """用于单输入、单滤波器的简单二维卷积。""" return lax.conv(x[None, None], kernel[None, None], (stride, stride), 'SAME')[0, 0] def max_pool(x, size=2): """2x2 最大池化。""" H, W = x.shape x = x[:H//size*size, :W//size*size] return x.reshape(H//size, size, W//size, size).max(axis=(1, 3)) def init_cnn(key): k1, k2, k3 = jax.random.split(key, 3) return { 'conv1': jax.random.normal(k1, (5, 5)) * 0.3, 'conv2': jax.random.normal(k2, (3, 3)) * 0.3, 'fc_w': jax.random.normal(k3, (64, 1)) * 0.1, 'fc_b': jnp.zeros(1), } def forward_cnn(params, img): # Conv1 -> ReLU -> 池化 h = jnp.maximum(0, conv2d(img, params['conv1'])) h = max_pool(h) # Conv2 -> ReLU -> 池化 h = jnp.maximum(0, conv2d(h, params['conv2'])) h = max_pool(h) # 展平并分类 flat = h.ravel() # 填充或截断到固定大小 flat = jnp.pad(flat, (0, max(0, 64 - len(flat))))[:64] logit = (flat @ params['fc_w'] + params['fc_b']).squeeze() return jax.nn.sigmoid(logit) # 生成合成数据:类别0 = 低频模式,类别1 = 高频模式 def make_data(key, n=200): images, labels = [], [] for i in range(n): k1, key = jax.random.split(key) x, y = jnp.meshgrid(jnp.linspace(0, 4*jnp.pi, 32), jnp.linspace(0, 4*jnp.pi, 32)) if i < n // 2: img = jnp.sin(x) + jax.random.normal(k1, (32, 32)) * 0.1 labels.append(0) else: img = jnp.sin(4 * x) * jnp.sin(4 * y) + jax.random.normal(k1, (32, 32)) * 0.1 labels.append(1) images.append(img) return images, jnp.array(labels, dtype=jnp.float32) key = jax.random.PRNGKey(42) images, labels = make_data(key) params = init_cnn(jax.random.PRNGKey(0)) def loss_fn(params, img, label): pred = forward_cnn(params, img) return -(label * jnp.log(pred + 1e-7) + (1 - label) * jnp.log(1 - pred + 1e-7)) grad_fn = jax.grad(loss_fn) lr = 0.01 for epoch in range(5): total_loss = 0.0 for img, label in zip(images, labels): grads = grad_fn(params, img, label) params = {k: params[k] - lr * grads[k] for k in params} total_loss += loss_fn(params, img, label) print(f"Epoch {epoch}: loss = {total_loss / len(images):.4f}") # 测试准确率 preds = jnp.array([forward_cnn(params, img) > 0.5 for img in images]) acc = jnp.mean(preds == labels) print(f"Accuracy: {acc:.2%}") -
可视化不同滤波器尺寸对感受野的影响。展示两个堆叠的 3x3 滤波器与一个 5x5 滤波器具有相同的感受野,但参数更少。
import jax.numpy as jnp import matplotlib.pyplot as plt def compute_receptive_field(layers): """根据(kernel_size, stride)元组列表计算感受野大小。""" rf = 1 # 从1个像素开始 stride_product = 1 for k, s in layers: rf += (k - 1) * stride_product stride_product *= s return rf # 比较不同架构 configs = { '单个 5x5': [(5, 1)], '两个 3x3': [(3, 1), (3, 1)], '三个 3x3': [(3, 1), (3, 1), (3, 1)], '单个 7x7': [(7, 1)], '3x3 步长2 + 3x3': [(3, 2), (3, 1)], } print(f"{'配置':<25} {'感受野':>4} {'参数数量(每通道)':>20}") print('-' * 55) for name, layers in configs.items(): rf = compute_receptive_field(layers) # 参数数量:每层 k^2 之和(每个输入-输出通道对) params = sum(k * k for k, s in layers) print(f"{name:<25} {rf:>4} {params:>20}") # 可视化感受野 fig, axes = plt.subplots(1, 3, figsize=(14, 4)) for ax, (name, rf_size) in zip(axes, [('5x5 滤波器', 5), ('两个 3x3 滤波器', 5), ('三个 3x3 滤波器', 7)]): grid = jnp.zeros((9, 9)) c = 4 # 中心 half = rf_size // 2 grid = grid.at[c-half:c+half+1, c-half:c+half+1].set(1.0) ax.imshow(grid, cmap='Blues', vmin=0, vmax=1) ax.set_title(f'{name}\n感受野 = {rf_size}x{rf_size}') ax.set_xticks(range(9)); ax.set_yticks(range(9)) ax.grid(True, alpha=0.3) plt.suptitle('感受野比较') plt.tight_layout(); plt.show() -
从零实现 Grad-CAM。给定一个预构建的简单 CNN,计算特定类别的梯度加权激活图,并将其可视化为热力图。
import jax import jax.numpy as jnp import matplotlib.pyplot as plt def simple_cnn(params, img): """简单的 CNN,同时返回预测值和最后一个卷积层的激活图。""" # 卷积层(作为 Grad-CAM 的“最后一个卷积层”) H, W = img.shape k = params['conv'].shape[0] pad = k // 2 img_pad = jnp.pad(img, pad, mode='edge') activation_map = jnp.zeros((H, W)) for i in range(H): for j in range(W): activation_map = activation_map.at[i, j].set( jnp.sum(img_pad[i:i+k, j:j+k] * params['conv']) ) activation_map = jnp.maximum(0, activation_map) # ReLU # 全局平均池化 -> 全连接 -> 输出 pooled = activation_map.mean() logit = pooled * params['w'] + params['b'] return jax.nn.sigmoid(logit), activation_map # 创建测试图像:左侧有亮区域(类别指示器) img = jnp.zeros((32, 32)) img = img.at[8:24, 4:16].set(1.0) img = img.at[5:10, 20:28].set(0.3) key = jax.random.PRNGKey(42) params = { 'conv': jax.random.normal(key, (5, 5)) * 0.3, 'w': jnp.array(2.0), 'b': jnp.array(-0.5), } # 计算 Grad-CAM def class_score(params, img): pred, _ = simple_cnn(params, img) return pred # 获取激活图和梯度 pred, act_map = simple_cnn(params, img) grad_fn = jax.grad(lambda img: simple_cnn(params, img)[0]) img_grad = grad_fn(img) # 权重 = 梯度的全局平均值(简化的单通道 Grad-CAM) alpha = img_grad.mean() grad_cam = jnp.maximum(0, alpha * act_map) # ReLU grad_cam = (grad_cam - grad_cam.min()) / (grad_cam.max() - grad_cam.min() + 1e-8) fig, axes = plt.subplots(1, 3, figsize=(14, 4)) axes[0].imshow(img, cmap='gray'); axes[0].set_title('输入图像'); axes[0].axis('off') axes[1].imshow(act_map, cmap='viridis'); axes[1].set_title('激活图'); axes[1].axis('off') axes[2].imshow(img, cmap='gray', alpha=0.6) axes[2].imshow(grad_cam, cmap='jet', alpha=0.4) axes[2].set_title(f'Grad-CAM (预测值={pred:.2f})'); axes[2].axis('off') plt.tight_layout(); plt.show() -
比较深度可分离卷积与标准卷积。计算两者的参数量和 FLOPs,并证明它们能以更少的计算量产生相似的输出。
import jax import jax.numpy as jnp def standard_conv(x, kernel): """标准卷积:(H, W, C_in) * (k, k, C_in, C_out) -> (H, W, C_out)""" H, W, C_in = x.shape k, _, _, C_out = kernel.shape pad = k // 2 x_pad = jnp.pad(x, ((pad, pad), (pad, pad), (0, 0)), mode='constant') out = jnp.zeros((H, W, C_out)) for i in range(H): for j in range(W): patch = x_pad[i:i+k, j:j+k, :] # (k, k, C_in) for c in range(C_out): out = out.at[i, j, c].set(jnp.sum(patch * kernel[:, :, :, c])) return out def depthwise_separable_conv(x, dw_kernel, pw_kernel): """深度可分离卷积:深度卷积 (k,k,C_in) 然后逐点卷积 (C_in, C_out)""" H, W, C_in = x.shape k = dw_kernel.shape[0] pad = k // 2 x_pad = jnp.pad(x, ((pad, pad), (pad, pad), (0, 0)), mode='constant') # 深度卷积:每个通道一个滤波器 dw_out = jnp.zeros((H, W, C_in)) for i in range(H): for j in range(W): for c in range(C_in): patch = x_pad[i:i+k, j:j+k, c] dw_out = dw_out.at[i, j, c].set(jnp.sum(patch * dw_kernel[:, :, c])) # 逐点卷积:跨通道的 1x1 卷积 out = dw_out @ pw_kernel return out # 设置 H, W, C_in, C_out, k = 8, 8, 16, 32, 3 key = jax.random.PRNGKey(42) k1, k2, k3, k4 = jax.random.split(key, 4) x = jax.random.normal(k1, (H, W, C_in)) std_kernel = jax.random.normal(k2, (k, k, C_in, C_out)) * 0.1 dw_kernel = jax.random.normal(k3, (k, k, C_in)) * 0.1 pw_kernel = jax.random.normal(k4, (C_in, C_out)) * 0.1 # 比较 std_params = k * k * C_in * C_out dw_params = k * k * C_in + C_in * C_out std_flops = H * W * k * k * C_in * C_out dw_flops = H * W * (k * k * C_in + C_in * C_out) print(f"标准卷积: {std_params:>8,} 参数, {std_flops:>10,} FLOPs") print(f"深度可分离卷积: {dw_params:>8,} 参数, {dw_flops:>10,} FLOPs") print(f"参数减少: {std_params / dw_params:.1f}x") print(f"FLOP 减少: {std_flops / dw_flops:.1f}x") std_out = standard_conv(x, std_kernel) ds_out = depthwise_separable_conv(x, dw_kernel, pw_kernel) print(f"\n标准卷积输出形状: {std_out.shape}") print(f"深度可分离卷积输出形状: {ds_out.shape}")