机器学习数学基础专题【左扬精讲】—— PyTorch 深度可分离膨胀卷积详解与实战
机器学习数学基础专题【左扬精讲】—— PyTorch 深度可分离膨胀卷积详解与实战
在前两篇文章中,我们学习了卷积神经网络的基础知识和基于 CNN 的 MNIST 分类实战。本篇文章将深入探讨两种重要的卷积变体:深度可分离卷积(Depthwise Separable Convolution)和膨胀卷积(Dilated Convolution)。这两种技术是现代高效 CNN 架构(如 MobileNet)和语义分割网络(如 DeepLab)的核心组件。
通过本文的学习,你将彻底理解深度可分离卷积如何大幅减少参数量和计算量,以及膨胀卷积如何在不损失分辨率的情况下扩大感受野。最后,我们将把这两种技术结合起来,构建一个轻量高效的 MNIST 识别网络。
torch.nn.Conv2d(groups=in_channels) ← 深度卷积(Depthwise Convolution)
torch.nn.Conv2d(groups>1) ← 分组卷积(Grouped Convolution)
torch.nn.Conv2d(dilation>1) ← 空洞/膨胀卷积(Dilated Convolution)
torch.nn.Sequential ← 容器:按顺序堆叠网络层
MobileNetV1 / MobileNetV2 ← 深度可分离卷积的典型应用
PyTorch深度可分离卷积膨胀卷积MobileNet参数量对比感受野
学习重点
- 必须掌握
- 深度可分离卷积的定义:深度卷积 + 点卷积的分步计算
- 深度可分离卷积与标准卷积的参数量和计算量对比
- 膨胀卷积(Dilated Convolution)如何扩大感受野
- PyTorch 中 groups 和 dilation 参数的使用
- 理解即可
- MobileNetV2 的倒残差结构设计思想
- 膨胀率(dilation rate)与感受野的关系
目录
一、深度可分离卷积详解
What — 什么是深度可分离卷积?
深度可分离卷积(Depthwise Separable Convolution)是一种高效的卷积操作,它将标准卷积分成两个独立的步骤:深度卷积(Depthwise Convolution)和点卷积(Pointwise Convolution)。这种分解可以将参数量和计算量大幅减少,同时保持接近标准卷积的表达能力。
深度可分离卷积的核心思想:
- 深度卷积:每个输入通道由一个独立的卷积核处理,实现通道内的空间卷积
- 点卷积:使用 1x1 卷积核混合不同通道的信息,实现通道间的线性组合
- 分步计算:先做深度卷积,再做点卷积,两步完成标准卷积的操作
Why — 为什么要使用深度可分离卷积?
问题一:标准卷积的计算开销太大。对于一个 in_channels=256, out_channels=512, kernel_size=3x3 的卷积层,参数量高达 256×512×3×3 ≈ 118万。深度可分离卷积可以将参数量减少到原来的约 1/9 到 1/12。
问题二:移动端部署需要轻量网络。手机、嵌入式设备算力有限,无法运行 ResNet-152 这样的大型网络。MobileNet 使用深度可分离卷积,在 ImageNet 上达到与 VGG-16 相当的准确率,但参数量只有后者的 1/27。
问题三:深度可分离卷积如何保证表达能力? 深度卷积负责提取每个通道的空间特征,点卷积(1x1卷积)负责混合不同通道的信息。两步操作虽然分开,但表达能力与标准卷积相当,因为 1x1 卷积可以学习任意通道间的线性组合。
没有深度可分离卷积会发生什么?
- 移动端无法部署高效 CNN 模型
- 边缘计算场景无法实现实时推理
- 模型压缩和加速技术受限
深度可分离卷积将标准卷积分解为两个独立的步骤。让我们通过数学公式来理解:
# 标准卷积的计算量
# 标准卷积参数量 = in_channels × out_channels × K_h × K_w
# 标准卷积计算量 = in_channels × out_channels × K_h × K_w × H_out × W_out
# 深度可分离卷积 = 深度卷积 + 点卷积
# 深度卷积参数量 = in_channels × 1 × K_h × K_w(每个通道独立一个卷积核)
# 深度卷积计算量 = in_channels × 1 × K_h × K_w × H_out × W_out
# 点卷积参数量 = in_channels × out_channels × 1 × 1(1x1 卷积)
# 点卷积计算量 = in_channels × out_channels × 1 × 1 × H_out × W_out
# 深度可分离卷积总参数量 = 深度卷积 + 点卷积
# = in_channels × K_h × K_w + in_channels × out_channels
# 与标准卷积的参数量比值
# ratio = (in_channels × K_h × K_w + in_channels × out_channels)
# / (in_channels × out_channels × K_h × K_w)
# = 1/out_channels + 1/(K_h × K_w)
# 当 K=3, out_channels 较大时
# ratio ≈ 1/9 = 11.1%
print("深度可分离卷积参数量约为标准卷积的 1/K²")
print("对于 3x3 卷积核,参数量减少约 8-9 倍")
在 PyTorch 中,深度可分离卷积通过 groups 参数实现:
import torch
import torch.nn as nn
# 深度可分离卷积 = 深度卷积 + 点卷积
class DepthwiseSeparableConv(nn.Module):
"""深度可分离卷积模块"""
def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):
super(DepthwiseSeparableConv, self).__init__()
# 第一步:深度卷积(Depthwise Convolution)
# groups=in_channels 表示每个输入通道独立处理
# 输出通道数 = 输入通道数
self.depthwise = nn.Conv2d(
in_channels=in_channels,
out_channels=in_channels, # 深度卷积要求 out_channels == in_channels
kernel_size=kernel_size,
stride=stride,
padding=padding,
groups=in_channels, # 关键参数:分组数等于通道数
bias=False
)
# 第二步:点卷积(Pointwise Convolution)
# 使用 1x1 卷积核混合通道信息
# 输出通道数可以扩展或压缩
self.pointwise = nn.Conv2d(
in_channels=in_channels,
out_channels=out_channels,
kernel_size=1, # 1x1 卷积,只混合通道
stride=1,
padding=0,
bias=False
)
# 批归一化和激活函数
self.bn = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU6(inplace=True) # MobileNet 专用激活函数
def forward(self, x):
# 先深度卷积:每个通道独立卷积
x = self.depthwise(x)
# 再点卷积:混合通道信息
x = self.pointwise(x)
x = self.bn(x)
x = self.relu(x)
return x
# 测试深度可分离卷积
print("=" * 60)
print("深度可分离卷积测试")
print("=" * 60)
# 输入: 3通道, 32x32 图像
test_input = torch.randn(1, 3, 32, 32)
# 创建深度可分离卷积层
ds_conv = DepthwiseSeparableConv(in_channels=3, out_channels=64)
output = ds_conv(test_input)
print(f"输入形状: {test_input.shape}")
print(f"输出形状: {output.shape}")
通过图示理解深度可分离卷积的两步操作:
# ============================================
# 深度可分离卷积的两步操作可视化说明
# ============================================
print("""
第一步:深度卷积(Depthwise Convolution)
----------------------------------------
输入: [B, C_in, H, W] (如 [1, 3, 32, 32])
↓
每个通道独立与一个 3x3 卷积核卷积
↓
输出: [B, C_in, H_out, W_out] (如 [1, 3, 32, 32])
注意:通道数不变!
第二步:点卷积(Pointwise Convolution)
----------------------------------------
输入: [B, C_in, H_out, W_out] (如 [1, 3, 32, 32])
↓
每个空间位置:用 1x1 卷积核混合所有通道
↓
输出: [B, C_out, H_out, W_out] (如 [1, 64, 32, 32])
通道数变为 out_channels
""")
# 实际计算来验证
import torch
# 模拟:3通道输入
batch_size, in_ch, out_ch = 1, 3, 64
h, w = 32, 32
# 深度卷积参数
depthwise_params = in_ch * 1 * 3 * 3 # 每个通道一个 3x3 核
# 点卷积参数
pointwise_params = in_ch * out_ch * 1 * 1 # 1x1 卷积
# 总参数
total_params_ds = depthwise_params + pointwise_params
# 标准卷积参数
standard_params = in_ch * out_ch * 3 * 3
print(f"标准卷积参数量: {standard_params:,}")
print(f"深度可分离卷积参数量: {total_params_ds:,}")
print(f"参数量减少比例: {total_params_ds/standard_params:.2%}")
print(f"参数量减少倍数: {standard_params/total_params_ds:.1f}x")
ReLU6 激活函数
MobileNet 使用 ReLU6 而不是普通 ReLU。ReLU6 将输出限制在 [0, 6] 范围内,这在低精度量化(8位整数)部署时非常重要,可以减少量化误差对网络性能的影响。
本章小结
- 两步分解:深度卷积(通道内)+ 点卷积(通道间)
- groups 参数:groups=in_channels 实现深度卷积
- 参数量优势:约为标准卷积的 1/K²(K=3 时约 1/9)
- 表达能力:两步操作等价于标准卷积的分解,表达能力相近
二、参数量与计算量对比分析
What — 深度可分离卷积能节省多少参数量?
参数量是衡量卷积层效率的关键指标。让我们通过详细的数学推导来量化深度可分离卷积相比标准卷积的优势。
关键概念定义:
- 参数量(Parameters):模型需要学习的权重数量
- 计算量(FLOPs):一次前向传播所需的浮点运算次数
- 内存占用:模型参数在内存中的大小
Why — 为什么要精确计算参数量?
问题一:参数量直接影响模型大小。部署到移动端时,APK/IPA 包大小受限于几十到几百 MB。MobileNetV1 只有 4.2M 参数,而 VGG-16 有 138M 参数,相差 30 多倍。
问题二:计算量决定推理速度。移动端 GPU/NPU 算力有限,实时推理需要控制 FLOPs。深度可分离卷积可以将 FLOPs 减少到原来的约 1/9 到 1/15。
问题三:内存带宽是瓶颈。模型越大,需要从内存加载的权重越多。MobileNet 的设计目标就是在有限的内存带宽下实现高效推理。
没有这种效率优化会发生什么?
- 移动端无法运行深度学习模型
- 实时应用(如视频分析)无法实现
- 边缘计算场景受限
通过具体数值理解两种卷积的参数量差异:
import torch
def calculate_conv_params(in_channels, out_channels, kernel_size, bias=True):
"""计算标准卷积参数量"""
params = in_channels * out_channels * kernel_size * kernel_size
if bias:
params += out_channels
return params
def calculate_depthwise_params(in_channels, kernel_size):
"""计算深度卷积参数量"""
return in_channels * 1 * kernel_size * kernel_size
def calculate_pointwise_params(in_channels, out_channels):
"""计算点卷积(1x1)参数量"""
return in_channels * out_channels * 1 * 1
def calculate_ds_conv_params(in_channels, out_channels, kernel_size, bias=True):
"""计算深度可分离卷积总参数量"""
depthwise = calculate_depthwise_params(in_channels, kernel_size)
pointwise = calculate_pointwise_params(in_channels, out_channels)
bn_params = 2 * out_channels if bias else 0 # BatchNorm: gamma + beta
return depthwise + pointwise + bn_params
# ============================================
# 不同配置下的参数量对比
# ============================================
print("=" * 70)
print("深度可分离卷积 vs 标准卷积 参数量对比")
print("=" * 70)
configs = [
# (in_channels, out_channels, kernel_size)
(3, 32, 3), # 第一个卷积层
(32, 64, 3), # 第二个卷积层
(64, 128, 3), # 第三个卷积层
(128, 256, 3), # 深层卷积层
(256, 512, 3), # 更深的卷积层
]
print(f"{'配置':<20} {'标准卷积':<15} {'DS卷积':<15} {'节省比例':<12} {'节省倍数':<10}")
print("-" * 70)
for in_ch, out_ch, k in configs:
std_params = calculate_conv_params(in_ch, out_ch, k)
ds_params = calculate_ds_conv_params(in_ch, out_ch, k)
ratio = ds_params / std_params
times = std_params / ds_params
config_str = f"Conv({in_ch}->{out_ch}, {k}x{k})"
print(f"{config_str:<20} {std_params:>12,} {ds_params:>12,} {ratio:>10.2%} {times:>8.1f}x")
print("-" * 70)
print("结论:3x3 深度可分离卷积的参数量约为标准卷积的 1/8 ~ 1/12")
计算量通常用 FLOPs(Floating Point Operations)衡量:
def calculate_conv_flops(in_channels, out_channels, kernel_size,
output_height, output_width, bias=True):
"""计算标准卷积 FLOPs"""
# 每个输出元素需要 in_channels * kernel_size * kernel_size 次乘加
# 加上偏置的一次加法(如有)
flops = out_channels * output_height * output_width * \
in_channels * kernel_size * kernel_size
if bias:
flops += out_channels * output_height * output_width
return flops
def calculate_ds_conv_flops(in_channels, out_channels, kernel_size,
output_height, output_width):
"""计算深度可分离卷积 FLOPs"""
# 深度卷积:每个通道独立卷积
depthwise_flops = in_channels * 1 * kernel_size * kernel_size * \
output_height * output_width
# 点卷积:1x1 卷积混合通道
pointwise_flops = in_channels * out_channels * 1 * 1 * \
output_height * output_width
return depthwise_flops + pointwise_flops
# MNIST 网络场景:输入 28x28,经过 stride=2 的卷积后变为 14x14
print("=" * 70)
print("深度可分离卷积 vs 标准卷积 FLOPs 对比(输出尺寸 14x14)")
print("=" * 70)
configs = [
(3, 32, 3, 14, 14),
(32, 64, 3, 14, 14),
(64, 128, 3, 7, 7),
(128, 256, 3, 7, 7),
]
print(f"{'配置':<22} {'标准FLOPs':<18} {'DS卷积FLOPs':<18} {'节省比例':<12}")
print("-" * 70)
total_std = 0
total_ds = 0
for in_ch, out_ch, k, h, w in configs:
std_flops = calculate_conv_flops(in_ch, out_ch, k, h, w)
ds_flops = calculate_ds_conv_flops(in_ch, out_ch, k, h, w)
ratio = ds_flops / std_flops
config_str = f"Conv({in_ch}->{out_ch}, {k}x{k})"
print(f"{config_str:<22} {std_flops:>15,} {ds_flops:>15,} {ratio:>10.2%}")
total_std += std_flops
total_ds += ds_flops
print("-" * 70)
print(f"{'总计':<22} {total_std:>15,} {total_ds:>15,} {total_ds/total_std:>10.2%}")
print(f"\n结论:深度可分离卷积可以节省约 {total_std/total_ds:.1f}x 的计算量")
MobileNetV1 是深度可分离卷积的典型应用:
# MobileNetV1 架构参数量分析
print("=" * 70)
print("MobileNetV1 架构参数量分析")
print("=" * 70)
# MobileNetV1 的第一层(普通卷积,非深度可分离)
first_conv_params = 3 * 32 * 3 * 3 # RGB -> 32 channels
print(f"第一层(普通Conv): {first_conv_params:,} 参数")
# 后续所有层都是深度可分离卷积
layers = [
# (in_ch, out_ch, repetitions)
(32, 64, 1),
(64, 128, 2),
(128, 128, 1),
(128, 256, 2),
(256, 256, 1),
(256, 512, 5),
(512, 512, 1),
(512, 1024, 2),
(1024, 1024, 1),
]
total_ds_params = 0
print(f"\n{'层':<30} {'重复':<8} {'参数量/层':<15} {'总计':<15}")
print("-" * 70)
for in_ch, out_ch, reps in layers:
# 深度可分离卷积参数量(不含 BatchNorm)
depthwise = in_ch * 3 * 3
pointwise = in_ch * out_ch
layer_params = depthwise + pointwise
total_params = layer_params * reps
total_ds_params += total_params
layer_name = f"DSConv({in_ch}->{out_ch})"
print(f"{layer_name:<30} {reps:<8} {layer_params:>12,} {total_params:>12,}")
print("-" * 70)
total_mobilenet = first_conv_params + total_ds_params
print(f"第一层普通卷积: {first_conv_params:,}")
print(f"深度可分离层总计: {total_ds_params:,}")
print(f"\nMobileNetV1 总参数量: {total_mobilenet:,} ({total_mobilenet/1e6:.1f}M)")
# 对比 VGG-16(简化估计)
vgg16_params = 138_000_000 # 约 138M
print(f"\n对比 VGG-16: {vgg16_params:,} ({vgg16_params/1e6:.0f}M)")
print(f"MobileNetV1 是 VGG-16 的 {total_mobilenet/vgg16_params:.2%}")
print(f"参数量减少约 {vgg16_params/total_mobilenet:.0f}x")
本章小结
- 参数量优势:3x3 深度可分离卷积约为标准卷积的 1/8 ~ 1/12
- 计算量优势:FLOPs 减少约 8-9 倍
- 内存效率:模型文件更小,适合移动端部署
- MobileNetV1:仅 4.2M 参数,比 VGG-16 减少约 30 倍
三、膨胀卷积详解
What — 什么是膨胀卷积?
膨胀卷积(Dilated Convolution,也称为空洞卷积或膨胀卷积)是一种特殊的卷积操作,通过在卷积核内部插入间隔(空洞)来扩大感受野,同时保持参数量不变。这使得网络能够在不损失空间分辨率的情况下,获取更大的上下文信息。
膨胀卷积的核心参数:
- 膨胀率(dilation rate / atrous rate):卷积核内部采样点的间隔
- dilation=1:标准卷积,无间隔
- dilation=2:卷积核内部有 1 个间隔,实际覆盖 5x5 区域
- dilation=4:卷积核内部有 3 个间隔,实际覆盖 9x9 区域
Why — 为什么需要膨胀卷积?
问题一:池化操作会损失分辨率。标准 CNN 通过池化减小特征图尺寸,但这会损失空间细节。在语义分割任务中,需要对每个像素进行分类,池化导致的分辨率损失使得难以精确定位物体边界。
问题二:增大感受野的传统方法有局限。加深网络或增大卷积核可以增大感受野,但会增加计算量和参数量。3 个 3x3 卷积的感受野等于 7x7,但两个 3x3 串联的感受野仍然是 5x5。
问题三:如何同时获得大感受野和高分辨率? 膨胀卷积通过在卷积核中插入空洞,使一个 3x3 的卷积核覆盖 5x5、7x7 甚至更大的区域,但只使用 9 个参数。这是获取大感受野而不损失分辨率的唯一方法。
没有膨胀卷积会发生什么?
- 语义分割需要大量上采样操作,引入棋盘效应
- 难以捕捉大范围上下文信息
- 物体边界定位精度受限
通过图示和代码理解膨胀卷积的采样方式:
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np
print("=" * 60)
print("膨胀卷积采样点示意图")
print("=" * 60)
def draw_dilated_kernel(ax, kernel_size, dilation, title):
"""绘制膨胀卷积核的采样点"""
ax.set_xlim(-0.5, kernel_size - 0.5)
ax.set_ylim(-0.5, kernel_size - 0.5)
ax.set_aspect('equal')
ax.set_title(title, fontsize=10)
ax.grid(True, alpha=0.3)
# 计算采样点
for i in range(kernel_size):
for j in range(kernel_size):
# 采样点位置(考虑膨胀)
actual_i = i * (dilation)
actual_j = j * (dilation)
# 检查是否在有效范围内
if actual_i < kernel_size * dilation and actual_j < kernel_size * dilation:
# 采样点相对于膨胀核中心的位置
center_offset_i = i - (kernel_size - 1) / 2
center_offset_j = j - (kernel_size - 1) / 2
ax.scatter([center_offset_j], [-center_offset_i], s=200, c='blue', zorder=5)
ax.annotate(f'({i},{j})', (center_offset_j + 0.15, -center_offset_i + 0.15), fontsize=7)
# 创建可视化
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
kernel_size = 3
# dilation=1: 标准卷积
draw_dilated_kernel(axes[0], kernel_size, 1,
f'dilation=1\n感受野: 3x3\n等效 3x3')
# dilation=2: 膨胀卷积
draw_dilated_kernel(axes[1], kernel_size, 2,
f'dilation=2\n感受野: 5x5\n等效 5x5 (只用9参数)')
# dilation=4: 大膨胀卷积
draw_dilated_kernel(axes[2], kernel_size, 4,
f'dilation=4\n感受野: 9x9\n等效 9x9 (只用9参数)')
plt.suptitle('膨胀卷积感受野对比(3x3 卷积核)', fontsize=14)
plt.tight_layout()
plt.savefig('dilated_conv_comparison.png', dpi=150)
plt.show()
print("\n膨胀卷积可视化已保存为 dilated_conv_comparison.png")
PyTorch 的 nn.Conv2d 原生支持膨胀卷积:
import torch
import torch.nn as nn
# 创建不同膨胀率的卷积层
conv_standard = nn.Conv2d(3, 64, kernel_size=3, padding=1, dilation=1) # 标准卷积
conv_dilated_2 = nn.Conv2d(3, 64, kernel_size=3, padding=2, dilation=2) # 膨胀率=2
conv_dilated_4 = nn.Conv2d(3, 64, kernel_size=3, padding=4, dilation=4) # 膨胀率=4
# 创建测试输入
test_input = torch.randn(1, 3, 28, 28)
print("=" * 60)
print("不同膨胀率的输出尺寸对比")
print("=" * 60)
# 计算输出尺寸
def conv_output_size(input_size, kernel_size, padding, dilation, stride=1):
return (input_size + 2 * padding - dilation * (kernel_size - 1) - 1) // stride + 1
input_size = 28
kernel_size = 3
print(f"输入尺寸: {input_size}x{input_size}")
print()
for dilation, padding in [(1, 1), (2, 2), (4, 4)]:
output_size = conv_output_size(input_size, kernel_size, padding, dilation)
effective_receptive_field = dilation * (kernel_size - 1) + 1
print(f"dilation={dilation}, padding={padding}")
print(f" 输出尺寸: {output_size}x{output_size}")
print(f" 有效感受野: {effective_receptive_field}x{effective_receptive_field}")
print()
膨胀卷积的感受野计算公式:
def calculate_receptive_field(kernel_size, dilation, layers=1):
"""
计算多层膨胀卷积的累积感受野
公式:
对于单层:RF = dilation * (kernel_size - 1) + 1
对于多层累积:
RF_n = RF_{n-1} + (kernel_size_n - 1) * dilation_n * stride_n * product(dilation_{1:n-1})
"""
if layers == 1:
return dilation * (kernel_size - 1) + 1
# 多层情况(简化版,假设 stride=1)
total_rf = kernel_size
accumulated_dilation = 1
for i in range(layers - 1):
accumulated_dilation *= dilation
total_rf += (kernel_size - 1) * accumulated_dilation
return total_rf
print("=" * 60)
print("多层膨胀卷积的累积感受野")
print("=" * 60)
# 单层感受野
print("单层感受野(3x3 卷积核):")
for dilation in [1, 2, 4, 8]:
rf = calculate_receptive_field(3, dilation, 1)
print(f" dilation={dilation}: 感受野 = {rf}x{rf}")
print()
# 多层累积感受野
print("多层累积感受野(每层 3x3,dilation=2):")
for layers in [1, 2, 3, 4]:
rf = calculate_receptive_field(3, 2, layers)
print(f" {layers}层: 累积感受野 = {rf}x{rf}")
print()
# DeepLabv3 风格:dilation = [1, 2, 4, 8]
print("DeepLabv3 风格多尺度感受野(dilation = 1, 2, 4, 8):")
dilation_rates = [1, 2, 4, 8]
cumulative_rf = 1
for i, d in enumerate(dilation_rates):
cumulative_rf = cumulative_rf + (3 - 1) * d
print(f" 添加 dilation={d} 后: 累积感受野 = {cumulative_rf}x{cumulative_rf}")
膨胀卷积的网格效应(Gridding Effect)
当连续使用相同膨胀率的膨胀卷积时,会出现"网格效应":感受野呈现棋盘状分布,部分像素从未被卷积核覆盖。解决方案包括:
- 使用混合膨胀率(dilation rates)如 [1, 2, 3, 1, 2, 3]
- 使用 HDC(Hybrid Dilated Convolution)设计原则
- 减少膨胀卷积的层数
本章小结
- dilation 参数:控制卷积核内部的采样间隔
- 感受野:dilation=2 时 3x3 卷积核覆盖 5x5 区域
- 分辨率保持:参数量不变,但感受野增大
- 应用场景:语义分割、目标检测等需要大感受野的任务
四、实战:深度可分离膨胀卷积的 MNIST 识别
整合两种技术
现在我们将深度可分离卷积和膨胀卷积结合起来,构建一个轻量高效的 MNIST 识别网络。这个网络将展示如何在减少参数量和计算量的同时,通过膨胀卷积保持良好的特征提取能力。
实战目标:
- 构建一个基于深度可分离膨胀卷积的轻量网络
- 对比其与标准 CNN 的参数量和性能
- 验证深度可分离卷积 + 膨胀卷积的可行性
Why — 为什么结合两种技术?
问题一:深度可分离卷积的局限。深度卷积只处理通道内的空间信息,虽然高效,但可能无法充分捕获通道间的特征关联。膨胀卷积可以在保持分辨率的同时增大感受野,弥补这一不足。
问题二:计算效率的平衡。深度可分离卷积已经大幅减少了计算量,膨胀卷积在扩大感受野时不增加参数量,两者结合可以在效率和性能之间取得良好平衡。
问题三:MNIST 任务的适用性。MNIST 是相对简单的任务,但通过这个实战可以验证这些技术在轻量网络中的有效性。
构建结合两种技术的卷积模块:
import torch
import torch.nn as nn
import torch.nn.functional as F
class DepthwiseSeparableDilatedConv(nn.Module):
"""深度可分离膨胀卷积模块
结合深度可分离卷积的高效性和膨胀卷积的大感受野
"""
def __init__(self, in_channels, out_channels, kernel_size=3,
dilation=1, stride=1, padding=None):
super(DepthwiseSeparableDilatedConv, self).__init__()
# 计算合适的 padding 以保持尺寸
if padding is None:
padding = dilation * (kernel_size - 1) // 2
self.in_channels = in_channels
self.out_channels = out_channels
self.dilation = dilation
# 深度卷积(支持膨胀)
self.depthwise = nn.Conv2d(
in_channels=in_channels,
out_channels=in_channels,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation, # 膨胀率
groups=in_channels, # 深度卷积
bias=False
)
# 点卷积(1x1 卷积混合通道)
self.pointwise = nn.Conv2d(
in_channels=in_channels,
out_channels=out_channels,
kernel_size=1,
bias=False
)
# 批归一化和激活
self.bn1 = nn.BatchNorm2d(in_channels)
self.bn2 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU6(inplace=True)
def forward(self, x):
# 深度卷积(可能带膨胀)
x = self.depthwise(x)
x = self.bn1(x)
x = self.relu(x)
# 点卷积混合通道
x = self.pointwise(x)
x = self.bn2(x)
x = self.relu(x)
return x
# 测试模块
print("深度可分离膨胀卷积模块测试")
print("=" * 60)
test_input = torch.randn(1, 32, 14, 14)
# 标准深度可分离卷积
ds_conv = DepthwiseSeparableDilatedConv(32, 64, dilation=1)
output_ds = ds_conv(test_input)
print(f"输入: {test_input.shape}")
print(f"深度可分离卷积 (dilation=1) 输出: {output_ds.shape}")
# 膨胀深度可分离卷积
ds_dilated_conv = DepthwiseSeparableDilatedConv(32, 64, dilation=2)
output_dilated = ds_dilated_conv(test_input)
print(f"深度可分离膨胀卷积 (dilation=2) 输出: {output_dilated.shape}")
构建完整的轻量级 MNIST 分类网络:
class LightweightMNISTNet(nn.Module):
"""基于深度可分离膨胀卷积的轻量级 MNIST 分类网络"""
def __init__(self, num_classes=10):
super(LightweightMNISTNet, self).__init__()
# 第一层:普通卷积,将灰度图转为 32 通道
# 输入: 1x28x28 -> 输出: 32x28x28
self.first_conv = nn.Conv2d(1, 32, kernel_size=3, padding=1, bias=False)
self.first_bn = nn.BatchNorm2d(32)
self.first_relu = nn.ReLU6(inplace=True)
# 深度可分离膨胀卷积层
# 使用渐进式膨胀率增大感受野
self.dsd_conv1 = DepthwiseSeparableDilatedConv(32, 64, kernel_size=3, dilation=1)
# dilation=2: 感受野等效 5x5,但只用了 3x3 参数
self.dsd_conv2 = DepthwiseSeparableDilatedConv(64, 128, kernel_size=3, dilation=2)
# 池化减小尺寸
self.pool1 = nn.MaxPool2d(2, 2) # 28x28 -> 14x14
# dilation=4: 感受野等效 9x9,覆盖更大的上下文
self.dsd_conv3 = DepthwiseSeparableDilatedConv(128, 128, kernel_size=3, dilation=2)
self.dsd_conv4 = DepthwiseSeparableDilatedConv(128, 256, kernel_size=3, dilation=4)
self.pool2 = nn.MaxPool2d(2, 2) # 14x14 -> 7x7
# 全局平均池化:替代展平 + 全连接,减少参数量
self.global_pool = nn.AdaptiveAvgPool2d(1)
# 分类器
self.classifier = nn.Linear(256, num_classes)
# Dropout
self.dropout = nn.Dropout(0.3)
def forward(self, x):
# 第一层
x = self.first_relu(self.first_bn(self.first_conv(x)))
# 深度可分离膨胀卷积块
x = self.dsd_conv1(x)
x = self.dsd_conv2(x)
x = self.pool1(x)
x = self.dsd_conv3(x)
x = self.dsd_conv4(x)
x = self.pool2(x)
# 全局平均池化
x = self.global_pool(x)
x = x.view(x.size(0), -1)
x = self.dropout(x)
# 分类
x = self.classifier(x)
return x
# 创建模型并统计参数量
model = LightweightMNISTNet()
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("=" * 60)
print("LightweightMNISTNet 模型信息")
print("=" * 60)
print(f"总参数量: {total_params:,} ({total_params/1e6:.2f}M)")
print(f"可训练参数量: {trainable_params:,} ({trainable_params/1e6:.2f}M)")
# 测试前向传播
test_input = torch.randn(1, 1, 28, 28)
output = model(test_input)
print(f"输入形状: {test_input.shape}")
print(f"输出形状: {output.shape} (logits for 10 classes)")
对比深度可分离膨胀卷积网络与标准 CNN 的性能:
"""
完整训练脚本:深度可分离膨胀卷积 vs 标准 CNN
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import time
# ============================================
# 模型定义
# ============================================
class StandardCNN(nn.Module):
"""标准 CNN(用于对比)"""
def __init__(self, num_classes=10):
super(StandardCNN, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(128 * 7 * 7, 256)
self.fc2 = nn.Linear(256, num_classes)
self.dropout = nn.Dropout(0.5)
def forward(self, x):
x = self.pool(F.relu(self.bn1(self.conv1(x)))) # 28->14
x = self.pool(F.relu(self.bn2(self.conv2(x)))) # 14->7
x = F.relu(self.bn3(self.conv3(x)))
x = x.view(x.size(0), -1)
x = self.dropout(F.relu(self.fc1(x)))
x = self.fc2(x)
return x
# ============================================
# 数据准备
# ============================================
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)
# ============================================
# 训练函数
# ============================================
def train_model(model, train_loader, test_loader, epochs=15):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
history = {'train_loss': [], 'test_acc': []}
for epoch in range(epochs):
model.train()
running_loss = 0.0
correct, total = 0, 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
train_loss = running_loss / len(train_loader)
train_acc = 100 * correct / total
# 测试集评估
model.eval()
correct, total = 0, 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
test_acc = 100 * correct / total
history['train_loss'].append(train_loss)
history['test_acc'].append(test_acc)
print(f"Epoch {epoch+1:2d}: Loss={train_loss:.4f}, "
f"Train Acc={train_acc:.2f}%, Test Acc={test_acc:.2f}%")
return history
# ============================================
# 对比实验
# ============================================
print("=" * 60)
print("模型参数量对比")
print("=" * 60)
standard_cnn = StandardCNN()
lightweight_net = LightweightMNISTNet()
std_params = sum(p.numel() for p in standard_cnn.parameters())
light_params = sum(p.numel() for p in lightweight_net.parameters())
print(f"标准 CNN 参数量: {std_params:,} ({std_params/1e6:.2f}M)")
print(f"轻量网络参数量: {light_params:,} ({light_params/1e6:.2f}M)")
print(f"参数量减少: {(1 - light_params/std_params)*100:.1f}%")
print(f"轻量网络是标准 CNN 的 {light_params/std_params*100:.1f}%")
print("\n" + "=" * 60)
print("开始训练(各 15 个 Epoch)")
print("=" * 60)
print("\n>>> 训练标准 CNN...")
start = time.time()
std_history = train_model(standard_cnn, train_loader, test_loader, epochs=15)
std_time = time.time() - start
print(f"标准 CNN 训练时间: {std_time:.1f}s, 最终准确率: {std_history['test_acc'][-1]:.2f}%")
print("\n>>> 训练轻量网络...")
start = time.time()
light_history = train_model(lightweight_net, train_loader, test_loader, epochs=15)
light_time = time.time() - start
print(f"轻量网络训练时间: {light_time:.1f}s, 最终准确率: {light_history['test_acc'][-1]:.2f}%")
print("\n" + "=" * 60)
print("训练结果汇总")
print("=" * 60)
print(f"{'指标':<20} {'标准CNN':<15} {'轻量网络':<15}")
print("-" * 50)
print(f"{'参数量':<20} {std_params/1e6:.2f}M {light_params/1e6:.2f}M")
print(f"{'最终准确率':<20} {std_history['test_acc'][-1]:.2f}% {light_history['test_acc'][-1]:.2f}%")
print(f"{'训练时间':<20} {std_time:.1f}s {light_time:.1f}s")
本章小结
- 模块设计:深度可分离膨胀卷积 = 深度卷积(dilation) + 点卷积
- 感受野优势:小参数获得大感受野(dilation=2 时 3x3 核覆盖 5x5)
- 轻量化:参数量大幅减少,适合移动端部署
- 性能验证:在 MNIST 上验证了方法的有效性
五、FAQ(20 组)
FAQ — 精选 20 问,深入理解深度可分离膨胀卷积
Q1. 深度可分离卷积和分组卷积有什么关系?
深度卷积是分组卷积的特例,当 groups=in_channels 时就是深度卷积。分组卷积将输入和输出通道分成多组,每组独立卷积。深度卷积要求输出通道数等于输入通道数,每组只有一个输入通道和一个输出通道。
Q2. 深度可分离卷积为什么需要两步(深度+点卷积)?
深度卷积只做通道内空间卷积,点卷积负责混合通道信息。深度卷积后每个通道独立处理,没有跨通道信息交互。点卷积(1x1 卷积)可以学习任意通道间的线性组合,将各通道特征融合。两步缺一不可。
Q3. 深度可分离卷积的表达能力是否等于标准卷积?
理论上可以证明深度可分离卷积可以近似标准卷积,但不是完全等价。对于 K×K 卷积核,理论上可以分解为 K² 个 1x1 卷积的和。但在实践中,由于训练和优化的限制,两者表达能力可能有细微差异,但差距通常很小。
Q4. MobileNet 为什么使用 ReLU6 而不是 ReLU?
ReLU6 在低精度量化时更稳定。ReLU6 = min(max(x, 0), 6) 将输出限制在 [0, 6]。在移动端部署时,权重常被量化到 8 位整数(0-255),6 是 255/255≈42 的约 1/7,便于量化。
Q5. 膨胀卷积的感受野如何计算?
单层感受野 = dilation × (kernel_size - 1) + 1。对于 dilation=2, kernel_size=3,感受野 = 2×(3-1)+1 = 5。多层累积感受野更复杂,需考虑各层的 dilation 和 stride。
Q6. 膨胀卷积的 padding 如何计算?
same padding 公式:padding = dilation × (kernel_size - 1) / 2。例如 dilation=2, kernel_size=3,padding = 2×(3-1)/2 = 2。PyTorch 会自动计算,或手动传入正确的 padding 值。
Q7. 什么是网格效应(Gridding Effect)?
连续使用相同膨胀率时,感受野呈棋盘状分布,部分像素未被覆盖。例如连续 dilation=2 的卷积,相邻感受野之间有间隙未覆盖。解决方案是使用混合膨胀率如 [1, 2, 3, 1, 2, 3]。
Q8. 深度可分离卷积和普通卷积的计算量比值是多少?
深度可分离卷积的计算量约为标准卷积的 1/K² + 1/out_channels。对于 3x3 卷积和较大的 out_channels,比值约为 1/9 到 1/12。计算量减少与参数量减少比例相近。
Q9. MobileNetV2 的倒残差结构是什么?
先扩展通道数(1x1),再深度卷积,最后压缩通道数(1x1)。与 MobileNetV1 直接深度可分离不同,V2 先用扩展层将通道数扩展到 6 倍,深度卷积后再压缩回来,提高特征表达能力。
Q10. 深度可分离卷积可以用在 1x1 卷积层吗?
1x1 卷积本身就是点卷积,不需要分离。深度可分离卷积的"深度"部分要求 kernel_size > 1,用于空间卷积。1x1 卷积只有通道混合作用,已经是"点"的形式。
Q11. 膨胀卷积和转置卷积(反卷积)有什么区别?
膨胀卷积保持尺寸增大感受野;转置卷积增大尺寸用于上采样。膨胀卷积的 stride=1,输出尺寸不变或略小,感受野增大。转置卷积的 stride>1,用于将特征图上采样,如分割网络中的解码器。
Q12. 深度可分离卷积的 BatchNorm 放在哪里?
通常在深度卷积后和点卷积后各放一个 BatchNorm。深度卷积后 BatchNorm 处理单通道特征,点卷积后 BatchNorm 处理混合通道特征。这样设计比只在最后放一个 BatchNorm 效果更好。
Q13. 为什么深度可分离卷积适合移动端?
参数量和计算量大幅减少,内存占用更低,推理更快。移动端设备的算力和内存带宽有限。深度可分离卷积可以将计算量减少 8-9 倍,使实时推理成为可能,同时保持可接受的准确率。
Q14. 膨胀卷积在哪些任务中常用?
语义分割、目标检测、语音合成等需要大感受野的任务。DeepLab 系列分割网络使用膨胀卷积保持高分辨率的同时增大感受野。语音模型 WaveNet 也使用膨胀卷积捕获大范围的音频上下文。
Q15. 深度可分离卷积是否可以叠加多层?
可以,MobileNet 就堆叠了 13 层深度可分离卷积。每层可以设置不同的 dilation 来调整感受野。深层堆叠可以学习更复杂的特征层次结构。
Q16. 为什么用全局平均池化替代全连接层?
全局平均池化将空间维度压缩为 1x1,大幅减少参数量。全连接层参数多且容易过拟合。全局平均池化没有参数,将每个通道压缩为一个值,然后直接接分类器。这在轻量网络中很常用。
Q17. 膨胀卷积会丢失空间信息吗?
不会丢失,空间分辨率保持不变。与池化不同,膨胀卷积的 stride=1,不进行下采样,输出尺寸与输入相近。它只是通过在卷积核中插入间隔来增大感受野,不损失分辨率。
Q18. 深度可分离卷积在推理时可以融合吗?
可以,深度卷积和点卷积可以融合为单个卷积层。在部署时,两个连续卷积可以合并为一个卷积层,但只对推理有效,训练时仍需分开以保持正确的梯度流。
Q19. MobileNetV3 相比 V1/V2 有什么改进?
使用 Neural Architecture Search (NAS) 搜索最优结构,加入 SE 模块和 Hard-Swish 激活。MobileNetV3 通过 NAS 自动设计网络结构,加入通道注意力模块(Squeeze-and-Excitation)和更高效的 Hard-Swish 激活函数,在 ImageNet 上达到更高准确率。
Q20. 深度可分离膨胀卷积适合所有任务吗?
不是,对于需要强通道间交互的任务,深度可分离卷积可能不如标准卷积。深度卷积只处理通道内信息,通道间交互完全依赖点卷积。对于通道间关系重要的任务,可能需要更多的点卷积层或额外的注意力机制来弥补。
FAQ 总结
- 深度可分离卷积:深度卷积 + 点卷积,参数量减少约 1/K²
- 膨胀卷积:dilation 参数控制感受野,保持分辨率
- 应用场景:移动端部署、语义分割、大感受野需求
- 常见组合:MobileNet 系列、DeepLab 系列
六、Roadmap 预告
后续学习 Roadmap
通过这三篇文章,我们从卷积基础到实战应用,再到高效卷积变体,系统学习了卷积神经网络的核心知识。建议继续深入的方向:
- 残差网络(ResNet):跳跃连接与恒等映射
- 通道注意力机制:SENet、CBAM
- 空间注意力机制:Non-Local Networks
- 目标检测实战:YOLO、Faster R-CNN
- 语义分割实战:U-Net、DeepLab
感谢阅读!如有疑问,欢迎在评论区讨论。

浙公网安备 33010602011771号