CNN 架构基础
CNN 架构基础
简介
卷积神经网络(Convolutional Neural Network,CNN)是处理网格状数据(如图像)的核心架构,通过局部连接、权值共享和池化操作,高效提取空间层次特征。从 LeNet 到 ResNet,再到 EfficientNet,CNN 始终是计算机视觉的基石。
CNN 的核心思想源自生物视觉系统的研究:视觉皮层中的神经元只对局部区域(感受野)产生响应,而同一组神经元在不同位置检测相同的特征模式。这一观察被 Hubel 和 Wiesel 在 1959 年通过猫的视觉皮层实验首次发现,并在 1998 年由 Yann LeCun 等人通过 LeNet-5 将其成功应用于手写数字识别。
在深度学习时代,AlexNet(2012)在 ImageNet 竞赛中的突破性表现标志着 CNN 的崛起。此后,VGGNet(2014)、GoogLeNet/Inception(2014)、ResNet(2015)、DenseNet(2017)、EfficientNet(2019)和 ConvNeXt(2022)等架构相继涌现,不断推动着 CNN 的性能边界。
从信息论的角度看,CNN 实际上是在做一种有结构的降维过程:通过卷积和池化,逐步将高维的像素信号压缩为低维的语义表示。每一层卷积都可以看作一个可学习的特征提取器,将输入映射到一个更有判别力的特征空间。
特点
局部感受野的数学本质
局部感受野的核心优势在于参数效率。假设输入为 224x224x3 的图像,如果用全连接层连接到 4096 维的隐藏层,参数量为 224×224×3×4096 ≈ 6 亿。而使用 3×3 卷积核输出 64 个通道,参数量仅为 3×3×3×64 = 1728。这种参数效率的来源在于:图像的像素之间存在强局部相关性,全局连接是冗余的。
感受野的计算公式如下:
RF[n] = RF[n-1] + (kernel[n] - 1) × jump[n-1]
jump[n] = jump[n-1] × stride[n]其中 RF[n] 是第 n 层的感受野大小,jump[n] 是第 n 层的跳跃距离(即输出特征图上一个像素对应输入上的多少个像素)。
权值共享与平移等变性
权值共享不仅减少参数,还引入了平移等变性(Translation Equivariance):如果输入图像中的物体发生平移,那么特征图中的响应也会相应平移。注意这是"等变"而非"不变"——特征图会跟着移动,而非保持不变。真正的平移不变性通常需要通过池化操作来实现。
从频域的角度看,卷积等价于频域的乘法。一个 3×3 卷积核实际上定义了一个空间滤波器,低通滤波器(如均值卷积核)模糊图像,高通滤波器(如 Sobel 算子)检测边缘。CNN 的可学习卷积核就是在数据驱动下自动发现的最优滤波器组合。
实现
# 示例1:手动实现卷积操作
import torch
import torch.nn.functional as F
def manual_conv2d(input_tensor, kernel, stride=1, padding=0):
"""手动实现2D卷积,理解卷积的计算过程"""
if padding > 0:
input_tensor = F.pad(input_tensor, (padding, padding, padding, padding))
batch, channels, h, w = input_tensor.shape
k_out, k_in, kh, kw = kernel.shape
oh = (h - kh) // stride + 1
ow = (w - kw) // stride + 1
output = torch.zeros(batch, k_out, oh, ow)
for b in range(batch):
for c_out in range(k_out):
for i in range(oh):
for j in range(ow):
region = input_tensor[b, :, i*stride:i*stride+kh, j*stride:j*stride+kw]
output[b, c_out, i, j] = (region * kernel[c_out]).sum()
return output
x = torch.randn(1, 3, 32, 32)
kernel = torch.randn(16, 3, 3, 3)
out = manual_conv2d(x, kernel, padding=1)
print(f"输入: {x.shape} -> 输出: {out.shape}")# 示例2:构建一个完整的 CNN 分类模型
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), # 32x32 -> 32x32
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 32x32 -> 16x16
nn.Conv2d(32, 64, 3, padding=1), # 16x16 -> 16x16
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16x16 -> 8x8
nn.Conv2d(64, 128, 3, padding=1), # 8x8 -> 8x8
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1, 1)), # 8x8 -> 1x1
)
self.classifier = nn.Linear(128, num_classes)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
return self.classifier(x)
model = SimpleCNN()
dummy = torch.randn(4, 3, 32, 32)
print(f"输出形状: {model(dummy).shape}")
print(f"参数量: {sum(p.numel() for p in model.parameters()):,}")# 示例3:ResNet 残差连接实现
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(channels)
def forward(self, x):
identity = x
out = torch.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out = out + identity # 残差连接
return torch.relu(out)
# 对比:50层有残差 vs 无残差的梯度
def compare_gradients(use_residual=True):
x = torch.randn(2, 64, 8, 8, requires_grad=True)
layers = nn.ModuleList()
for _ in range(50):
if use_residual:
layers.append(ResidualBlock(64))
else:
layers.append(nn.Sequential(
nn.Conv2d(64, 64, 3, padding=1),
nn.ReLU(),
))
out = x
for layer in layers:
out = layer(out)
out.sum().backward()
return x.grad.norm().item()
print(f"有残差连接梯度范数: {compare_gradients(True):.6f}")
print(f"无残差连接梯度范数: {compare_gradients(False):.6f}")# 示例4:感受野计算
def compute_receptive_field(kernel_sizes, strides, paddings):
"""逐层计算感受野大小"""
rf = 1
jump = 1
for k, s, p in zip(kernel_sizes, strides, paddings):
rf = rf + (k - 1) * jump
jump = jump * s
return rf
# VGG 风格网络:3x3 卷积 + 池化
layers_info = [(3,1,1)]*2 + [(2,2,0)] + [(3,1,1)]*2 + [(2,2,0)] + [(3,1,1)]*3 + [(2,2,0)]
rf = compute_receptive_field(
[l[0] for l in layers_info],
[l[1] for l in layers_info],
[l[2] for l in layers_info],
)
print(f"VGG 风格网络最终感受野: {rf}x{rf}")深入理解:卷积操作的各种变体
不同类型的卷积
import torch
import torch.nn as nn
# 1. 标准卷积 (Standard Convolution)
# 每个输出通道都与所有输入通道做卷积
# 参数量 = k_h × k_w × C_in × C_out
standard_conv = nn.Conv2d(64, 128, kernel_size=3, padding=1)
x = torch.randn(1, 64, 32, 32)
print(f"标准卷积参数量: {sum(p.numel() for p in standard_conv.parameters()):,}")
print(f"标准卷积输出: {standard_conv(x).shape}")
# 2. 深度可分离卷积 (Depthwise Separable Convolution)
# MobileNet 的核心创新,将标准卷积分解为两步:
# a) Depthwise: 每个输入通道独立卷积(不跨通道)
# b) Pointwise: 1x1 卷积实现跨通道信息融合
# 参数量 = k_h × k_w × C_in + C_in × C_out (远小于标准卷积)
depthwise_conv = nn.Conv2d(64, 64, 3, padding=1, groups=64) # groups=通道数
pointwise_conv = nn.Conv2d(64, 128, 1)
params_dw = sum(p.numel() for p in depthwise_conv.parameters())
params_pw = sum(p.numel() for p in pointwise_conv.parameters())
print(f"深度可分离卷积参数量: {params_dw + params_pw:,} (节省约 {1 - (params_dw+params_pw)/73728:.1%})")
# 3. 空洞卷积 (Dilated Convolution)
# 在卷积核元素之间插入空洞,增大感受野但不增加参数量
# 在语义分割中广泛使用(DeepLab 系列)
dilated_conv = nn.Conv2d(64, 128, 3, padding=2, dilation=2) # 感受野等效于 5x5
print(f"空洞卷积输出 (dilation=2): {dilated_conv(x).shape}")
# 4. 转置卷积 (Transposed Convolution)
# 用于上采样,在生成模型和分割解码器中使用
# 注意:转置卷积不是真正的"反卷积",只是数学上的转置操作
transpose_conv = nn.ConvTranspose2d(64, 32, kernel_size=4, stride=2, padding=1)
print(f"转置卷积输出: {transpose_conv(torch.randn(1, 64, 8, 8)).shape}")
# 5. 分组卷积 (Grouped Convolution)
# 将输入通道分为多组,每组独立卷积
# ResNeXt 的核心思想:分组增加网络宽度但不增加参数
grouped_conv = nn.Conv2d(64, 128, 3, padding=1, groups=4)
print(f"分组卷积参数量: {sum(p.numel() for p in grouped_conv.parameters()):,}")不同类型卷积的参数量对比
def compare_conv_types(c_in=64, c_out=128, k=3):
"""比较不同卷积类型的参数量"""
# 标准卷积
std_params = k * k * c_in * c_out + c_out # weight + bias
# 深度可分离卷积
dw_params = k * k * c_in + c_in # depthwise
pw_params = c_in * c_out + c_out # pointwise
dws_params = dw_params + pw_params
# 分组卷积 (groups=8)
g = 8
grp_params = (k * k * (c_in // g) * (c_out // g)) * g + c_out
print(f"输入通道: {c_in}, 输出通道: {c_out}, 卷积核: {k}x{k}")
print(f" 标准卷积: {std_params:>10,} 参数")
print(f" 深度可分离卷积: {dws_params:>10,} 参数 (比率: {dws_params/std_params:.2%})")
print(f" 分组卷积(g=8): {grp_params:>10,} 参数 (比率: {grp_params/std_params:.2%})")
compare_conv_types()1x1 卷积的多重作用
# 1x1 卷积(也称 pointwise convolution)在 CNN 中有多种用途:
# 用途1:跨通道信息融合与特征变换
# 等价于对每个空间位置的通道向量做一次全连接变换
conv_1x1_fuse = nn.Conv2d(64, 128, 1)
x = torch.randn(1, 64, 32, 32)
out = conv_1x1_fuse(x) # 空间尺寸不变,通道数从 64 变为 128
# 用途2:通道降维(bottleneck 结构)
# 先用 1x1 卷积降维,再用 3x3 卷积提取特征,最后用 1x1 升维
class Bottleneck(nn.Module):
"""ResNet 的 bottleneck 块"""
def __init__(self, in_channels, mid_channels, out_channels, stride=1):
super().__init__()
# 1x1 降维: in_channels -> mid_channels
self.conv1 = nn.Conv2d(in_channels, mid_channels, 1, bias=False)
self.bn1 = nn.BatchNorm2d(mid_channels)
# 3x3 提取特征
self.conv2 = nn.Conv2d(mid_channels, mid_channels, 3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(mid_channels)
# 1x1 升维: mid_channels -> out_channels
self.conv3 = nn.Conv2d(mid_channels, out_channels, 1, bias=False)
self.bn3 = nn.BatchNorm2d(out_channels)
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
identity = self.shortcut(x)
out = torch.relu(self.bn1(self.conv1(x)))
out = torch.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
out = torch.relu(out + identity)
return out
bottleneck = Bottleneck(256, 64, 256)
print(f"Bottleneck 参数量: {sum(p.numel() for p in bottleneck.parameters()):,}")
# 用途3:SE 模块中的通道注意力
class SEModule(nn.Module):
"""Squeeze-and-Excitation 通道注意力模块"""
def __init__(self, channels, reduction=16):
super().__init__()
self.squeeze = nn.AdaptiveAvgPool2d(1) # 全局平均池化
self.excitation = nn.Sequential(
nn.Linear(channels, channels // reduction, bias=False),
nn.ReLU(inplace=True),
nn.Linear(channels // reduction, channels, bias=False),
nn.Sigmoid()
)
def forward(self, x):
b, c, _, _ = x.shape
# Squeeze: 全局信息压缩为通道描述符
scale = self.squeeze(x).view(b, c)
# Excitation: 学习通道间的重要性权重
scale = self.excitation(scale).view(b, c, 1, 1)
# Scale: 用注意力权重重新校准特征
return x * scale
se = SEModule(64)
x = torch.randn(1, 64, 32, 32)
print(f"SE 模块输入: {x.shape} -> 输出: {se(x).shape}")深入理解:池化操作
import torch.nn as nn
# 最大池化:保留局部最显著特征
max_pool = nn.MaxPool2d(kernel_size=2, stride=2)
x = torch.randn(1, 1, 4, 4)
print(f"最大池化输入:\n{x[0, 0]}")
print(f"最大池化输出:\n{max_pool(x)[0, 0]}")
# 平均池化:保留局部平均信息
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
print(f"平均池化输出:\n{avg_pool(x)[0, 0]}")
# 全局平均池化 (GAP):替代全连接层的分类头
# 将每个通道压缩为一个标量值,消除空间维度
gap = nn.AdaptiveAvgPool2d((1, 1))
feature_map = torch.randn(1, 256, 7, 7)
pooled = gap(feature_map)
print(f"GAP: {feature_map.shape} -> {pooled.shape}")
print(f"GAP 后可直接接 Linear 层: {pooled.view(1, -1).shape}")
# 全局最大池化 (GMP):对检测显著特征更有效
gmp = nn.AdaptiveMaxPool2d((1, 1))
# 空间金字塔池化 (SPP):处理可变尺寸输入
class SpatialPyramidPooling(nn.Module):
"""空间金字塔池化:在不同尺度上池化,处理可变尺寸输入"""
def __init__(self, pool_sizes=(1, 2, 4)):
super().__init__()
self.pool_sizes = pool_sizes
def forward(self, x):
b, c, _, _ = x.shape
pooled_features = []
for pool_size in self.pool_sizes:
pooled = nn.functional.adaptive_avg_pool2d(x, pool_size)
pooled_features.append(pooled.view(b, c, -1))
return torch.cat(pooled_features, dim=-1)
spp = SpatialPyramidPooling()
x_var = torch.randn(2, 256, 13, 13) # 可变尺寸输入
print(f"SPP 输出: {spp(x_var).shape}") # 256*(1+4+16) = 5376
# 池化的替代方案:使用带步长的卷积
# 现代架构(如 ResNet)中,可以用 stride=2 的卷积替代池化层
# 优势:池化是固定操作,不可学习;步长卷积可以在下采样的同时提取特征
conv_downsample = nn.Conv2d(64, 128, 3, stride=2, padding=1)
x = torch.randn(1, 64, 32, 32)
print(f"步长卷积下采样: {x.shape} -> {conv_downsample(x).shape}")深入理解:批归一化与层归一化
import torch
import torch.nn as nn
class BatchNormExplanation(nn.Module):
"""BatchNorm 的详细工作原理
BatchNorm 在训练和推理时的行为不同,这是常见的困惑来源。
训练时:
- 使用当前 mini-batch 的均值和方差
- 同时维护 running_mean 和 running_var(指数移动平均)
推理时:
- 使用训练时积累的 running_mean 和 running_var
- 不使用当前样本的统计量
数学公式:
y = gamma * (x - mean) / sqrt(var + eps) + beta
其中 gamma 和 beta 是可学习参数
"""
def __init__(self, num_features, momentum=0.1, eps=1e-5):
super().__init__()
self.bn = nn.BatchNorm2d(num_features, momentum=momentum, eps=eps)
def forward(self, x):
return self.bn(x)
# BatchNorm vs LayerNorm vs GroupNorm 对比
def compare_normalizations():
"""对比不同的归一化方法"""
x = torch.randn(2, 4, 8, 8) # (batch, channels, height, width)
# BatchNorm2d: 在 batch 和空间维度上归一化,保持通道维度
# 统计量来自同一通道的所有样本和所有空间位置
bn = nn.BatchNorm2d(4)
bn_out = bn(x)
# LayerNorm: 在通道和空间维度上归一化,保持 batch 维度
# 统计量来自同一样本的所有通道和空间位置
ln = nn.LayerNorm([4, 8, 8])
ln_out = ln(x)
# GroupNorm: 将通道分组,在组内归一化
# 不依赖 batch 统计量,适合小 batch 场景
gn = nn.GroupNorm(num_groups=2, num_channels=4)
gn_out = gn(x)
# InstanceNorm: 每个通道、每个样本独立归一化
# 常用于风格迁移
in_norm = nn.InstanceNorm2d(4)
in_out = in_norm(x)
print(f"BatchNorm 输出: {bn_out.shape}")
print(f"LayerNorm 输出: {ln_out.shape}")
print(f"GroupNorm 输出: {gn_out.shape}")
print(f"InstanceNorm 输出: {in_out.shape}")
# 何时使用哪种归一化?
print("\n选择建议:")
print(" 大 batch (>16): BatchNorm2d — 效果最好")
print(" 小 batch (1-4): GroupNorm — 不依赖 batch 统计量")
print(" NLP/Transformer: LayerNorm — 对序列数据更自然")
print(" 风格迁移: InstanceNorm — 去除风格信息")
compare_normalizations()深入理解:经典 CNN 架构演进
VGGNet:用小卷积核堆叠
class VGGBlock(nn.Module):
"""VGG 网络的设计哲学:使用连续的 3x3 卷积替代大卷积核
两个 3x3 卷积的堆叠等效于一个 5x5 卷积(感受野相同),但参数更少:
- 两个 3x3 卷积: 2 × (3×3×C²) = 18C²
- 一个 5x5 卷积: 5×5×C² = 25C²
同时增加了非线性激活,表达能力更强。
三个 3x3 卷积等效于一个 7x7 卷积,参数从 49C² 减少到 27C²。
"""
def __init__(self, in_channels, out_channels, num_convs):
super().__init__()
layers = []
for i in range(num_convs):
c_in = in_channels if i == 0 else out_channels
layers.extend([
nn.Conv2d(c_in, out_channels, 3, padding=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
])
layers.append(nn.MaxPool2d(2, 2))
self.block = nn.Sequential(*layers)
def forward(self, x):
return self.block(x)
# 构建类似 VGG-16 的网络
vgg = nn.Sequential(
VGGBlock(3, 64, 2), # 224 -> 112
VGGBlock(64, 128, 2), # 112 -> 56
VGGBlock(128, 256, 3), # 56 -> 28
VGGBlock(256, 512, 3), # 28 -> 14
VGGBlock(512, 512, 3), # 14 -> 7
)
x = torch.randn(1, 3, 224, 224)
print(f"VGG-16 特征提取输出: {vgg(x).shape}")
print(f"VGG-16 参数量: {sum(p.numel() for p in vgg.parameters()):,}")Inception 模块:多尺度特征提取
class InceptionModule(nn.Module):
"""Inception 模块的设计哲学:让网络自己学习使用什么尺寸的卷积核
与其手动选择 1x1、3x3 还是 5x5 卷积,不如将它们全部并行使用,
然后拼接结果。这样网络可以在不同尺度上同时提取特征。
1x1 卷积在这里有两个作用:
1. 直接提取特征
2. 作为降维层,减少后续 3x3 和 5x5 卷积的计算量
"""
def __init__(self, in_channels, ch1x1, ch3x3_reduce, ch3x3, ch5x5_reduce, ch5x5, ch_pool):
super().__init__()
# 分支1:1x1 卷积
self.branch1 = nn.Sequential(
nn.Conv2d(in_channels, ch1x1, 1),
nn.BatchNorm2d(ch1x1),
nn.ReLU(inplace=True)
)
# 分支2:1x1 降维 -> 3x3 卷积
self.branch2 = nn.Sequential(
nn.Conv2d(in_channels, ch3x3_reduce, 1),
nn.BatchNorm2d(ch3x3_reduce),
nn.ReLU(inplace=True),
nn.Conv2d(ch3x3_reduce, ch3x3, 3, padding=1),
nn.BatchNorm2d(ch3x3),
nn.ReLU(inplace=True)
)
# 分支3:1x1 降维 -> 5x5 卷积
self.branch3 = nn.Sequential(
nn.Conv2d(in_channels, ch5x5_reduce, 1),
nn.BatchNorm2d(ch5x5_reduce),
nn.ReLU(inplace=True),
nn.Conv2d(ch5x5_reduce, ch5x5, 5, padding=2),
nn.BatchNorm2d(ch5x5),
nn.ReLU(inplace=True)
)
# 分支4:3x3 最大池化 -> 1x1 卷积
self.branch4 = nn.Sequential(
nn.MaxPool2d(3, stride=1, padding=1),
nn.Conv2d(in_channels, ch_pool, 1),
nn.BatchNorm2d(ch_pool),
nn.ReLU(inplace=True)
)
def forward(self, x):
b1 = self.branch1(x)
b2 = self.branch2(x)
b3 = self.branch3(x)
b4 = self.branch4(x)
# 沿通道维度拼接所有分支的输出
return torch.cat([b1, b2, b3, b4], dim=1)
inception = InceptionModule(192, 64, 96, 128, 16, 32, 32)
x = torch.randn(1, 192, 28, 28)
print(f"Inception 模块输出: {inception(x).shape}") # 64+128+32+32=256DenseNet:密集连接
class DenseBlock(nn.Module):
"""DenseNet 的核心思想:每一层的输入是所有前面层的输出的拼接
与 ResNet 的逐元素相加不同,DenseNet 采用通道维度的拼接。
这种设计有以下几个优势:
1. 特复用:每层都可以直接访问前面所有层的特征图
2. 梯度流畅:缓解梯度消失问题
3. 参数高效:每层只需较少的滤波器(通常 12-32 个)
数学表示:
x_l = H_l([x_0, x_1, ..., x_{l-1}])
其中 [...] 表示通道维度的拼接
"""
def __init__(self, in_channels, growth_rate, num_layers):
super().__init__()
self.layers = nn.ModuleList()
for i in range(num_layers):
# 每层使用 BN-ReLU-Conv(1x1)-BN-ReLU-Conv(3x3) 的瓶颈结构
self.layers.append(nn.Sequential(
nn.BatchNorm2d(in_channels + i * growth_rate),
nn.ReLU(inplace=True),
nn.Conv2d(in_channels + i * growth_rate, growth_rate * 4, 1, bias=False),
nn.BatchNorm2d(growth_rate * 4),
nn.ReLU(inplace=True),
nn.Conv2d(growth_rate * 4, growth_rate, 3, padding=1, bias=False),
))
self.num_layers = num_layers
def forward(self, x):
features = [x]
for layer in self.layers:
out = layer(torch.cat(features, dim=1))
features.append(out)
return torch.cat(features, dim=1)
dense = DenseBlock(64, growth_rate=32, num_layers=4)
x = torch.randn(1, 64, 32, 32)
out = dense(x)
print(f"DenseBlock 输入通道: 64 -> 输出通道: {out.shape[1]}") # 64 + 4*32 = 192EfficientNet:复合缩放策略
class EfficientNetScaling:
"""EfficientNet 的核心创新:统一缩放网络的深度、宽度和分辨率
之前的方法只缩放其中一个维度:
- 更深的网络(ResNet-18 -> ResNet-152)
- 更宽的网络(WideResNet)
- 更高分辨率(从 224 到 331)
EfficientNet 通过神经架构搜索(NAS)找到基础网络 EfficientNet-B0,
然后使用复合缩放系数 α(深度)、β(宽度)、γ(分辨率)来缩放:
depth = α^φ × d_0
width = β^φ × w_0
resolution = γ^φ × r_0
约束条件:α × β² × γ² ≈ 2(保持总计算量约为 2 倍增长)
实际使用中直接用 torchvision.models.efficientnet_b0~b7
"""
# 各版本的缩放系数
COEFFICIENTS = {
'b0': (1.0, 1.0, 224, 0.04),
'b1': (1.0, 1.0, 240, 0.05),
'b2': (1.1, 1.2, 260, 0.06),
'b3': (1.2, 1.4, 300, 0.08),
'b4': (1.4, 1.8, 380, 0.09),
'b5': (1.6, 2.2, 456, 0.11),
'b6': (1.8, 2.6, 528, 0.13),
'b7': (2.0, 3.1, 600, 0.14),
}
@classmethod
def get_scaling_info(cls, version='b0'):
d, w, r, dropout = cls.COEFFICIENTS[version]
print(f"EfficientNet-{version}: 深度系数={d}, 宽度系数={w}, 分辨率={r}, dropout={dropout}")
return d, w, r, dropout
EfficientNetScaling.get_scaling_info('b0')
EfficientNetScaling.get_scaling_info('b7')深入理解:现代 CNN 的改进技巧
激活函数的选择
import torch
import torch.nn as nn
# ReLU: 最常用的激活函数,简单高效,但有"死亡ReLU"问题
# 当输入为负时梯度为0,可能导致神经元永久失活
relu = nn.ReLU()
# LeakyReLU: 负半轴保留小斜率,缓解死亡ReLU问题
leaky_relu = nn.LeakyReLU(negative_slope=0.01)
# GELU: Gaussian Error Linear Unit,Transformer 中广泛使用
# 更平滑的近似,比 ReLU 的梯度性质更好
gelu = nn.GELU()
# SiLU / Swish: x * sigmoid(x),在 EfficientNet 和现代架构中广泛使用
silu = nn.SiLU()
# 对比不同激活函数
x = torch.linspace(-5, 5, 100)
print("激活函数值域对比:")
print(f" ReLU 在 x=-3: {relu(torch.tensor(-3.0)):.4f}")
print(f" LeakyReLU 在 x=-3: {leaky_relu(torch.tensor(-3.0)):.4f}")
print(f" GELU 在 x=-3: {gelu(torch.tensor(-3.0)):.4f}")
print(f" SiLU 在 x=-3: {silu(torch.tensor(-3.0)):.4f}")
print(f" SiLU 在 x=3: {silu(torch.tensor(3.0)):.4f}")现代正则化技术
import torch
import torch.nn as nn
# 1. Dropout: 随机丢弃神经元,防止过拟合
# 常用于全连接层之间
class CNNWithDropout(nn.Module):
def __init__(self, num_classes=10, dropout_rate=0.5):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
)
self.classifier = nn.Sequential(
nn.Dropout(p=dropout_rate), # 训练时随机丢弃
nn.Linear(128, num_classes)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
return self.classifier(x)
# 2. DropBlock: 随机丢弃连续区域,比 Dropout 更适合卷积层
# Dropout 随机丢弃独立像素,而卷积的特征具有空间连续性
# DropBlock 丢弃整个区域,迫使网络学习更鲁棒的特征
class DropBlock2d(nn.Module):
"""DropBlock: 对卷积层的正则化,随机丢弃连续区域"""
def __init__(self, block_size=7, drop_prob=0.1):
super().__init__()
self.block_size = block_size
self.drop_prob = drop_prob
def forward(self, x):
if not self.training or self.drop_prob == 0:
return x
_, _, h, w = x.shape
gamma = (self.drop_prob * h * w) / (
self.block_size**2 * (h - self.block_size + 1) * (w - self.block_size + 1)
)
mask = torch.bernoulli(torch.ones_like(x) * (1 - gamma))
# 用最大池化实现块状 mask
block_mask = 1 - nn.functional.max_pool2d(
mask, kernel_size=self.block_size, stride=1, padding=self.block_size // 2
)
# 保持期望值不变
count = block_mask.numel()
if count > 0:
count = block_mask.sum()
block_mask = block_mask * count / block_mask.numel()
return x * block_mask
# 3. Stochastic Depth: 随机丢弃整层,训练集成效果
# 在推理时使用所有层,相当于集成了多个不同深度的子网络
class StochasticDepth(nn.Module):
def __init__(self, survival_prob=0.8):
super().__init__()
self.survival_prob = survival_prob
def forward(self, x):
if not self.training:
return x
batch_size = x.shape[0]
random_tensor = torch.rand(batch_size, 1, 1, 1, device=x.device)
binary_tensor = torch.floor(random_tensor + self.survival_prob)
return x * binary_tensor / self.survival_prob深入理解:特征可视化与可解释性
import torch
import torch.nn as nn
def visualize_feature_maps(model, image_tensor, layer_name='features.0'):
"""可视化 CNN 中间层的特征图,理解网络学到了什么
浅层特征(前几层卷积):
- 主要检测边缘、纹理、颜色等低级特征
- 边缘检测器类似于 Gabor 滤波器
- 颜色检测器响应特定颜色通道
中层特征:
- 组合低级特征形成纹理模式
- 检测网格、条纹、圆环等重复图案
深层特征:
- 组合形成物体部件(眼睛、轮子、窗户)
- 接近语义层面的表示
"""
features = {}
hooks = []
def get_hook(name):
def hook(module, input, output):
features[name] = output.detach()
return hook
# 注册钩子捕获中间层输出
for name, module in model.named_modules():
if isinstance(module, nn.Conv2d):
hooks.append(module.register_forward_hook(get_hook(name)))
if len(hooks) >= 4: # 只看前4个卷积层
break
with torch.no_grad():
_ = model(image_tensor)
# 清理钩子
for hook in hooks:
hook.remove()
print("各层特征图信息:")
for name, feat in features.items():
print(f" {name}: shape={feat.shape}, "
f"均值={feat.mean():.4f}, "
f"标准差={feat.std():.4f}, "
f"最大值={feat.max():.4f}")
return features
# Grad-CAM 思想简介:通过梯度加权特征图来可视化关注区域
def compute_grad_cam(model, image_tensor, target_class):
"""Grad-CAM 的简化实现
核心思想:
1. 前向传播得到预测结果
2. 反向传播得到目标类别对最后一个卷积层特征图的梯度
3. 对梯度做全局平均池化得到每个通道的重要性权重
4. 用权重加权特征图并 ReLU,得到类激活图
Grad-CAM 的优势:
- 不需要修改模型架构
- 可以对任意类别生成热力图
- 可以定位模型关注的区域,帮助诊断错误分类原因
"""
# 获取最后一个卷积层的特征图和梯度
features = []
gradients = []
def forward_hook(module, input, output):
features.append(output)
def backward_hook(module, grad_input, grad_output):
gradients.append(grad_output[0])
# 假设 model.features[-3] 是最后一个卷积层
last_conv = None
for module in model.modules():
if isinstance(module, nn.Conv2d):
last_conv = module
f_hook = last_conv.register_forward_hook(forward_hook)
b_hook = last_conv.register_backward_hook(backward_hook)
# 前向传播
output = model(image_tensor)
class_score = output[0, target_class]
# 反向传播
model.zero_grad()
class_score.backward()
# 计算权重:对梯度做全局平均池化
grad = gradients[0] # (batch, channels, h, w)
weights = grad.mean(dim=(2, 3), keepdim=True) # (batch, channels, 1, 1)
# 加权求和
cam = (weights * features[0]).sum(dim=1, keepdim=True) # (batch, 1, h, w)
cam = torch.relu(cam)
f_hook.remove()
b_hook.remove()
print(f"Grad-CAM 热力图形状: {cam.shape}")
return cam深入理解:卷积操作的数学本质
卷积与互相关的区别
import torch
import torch.nn.functional as F
def explain_conv_vs_correlation():
"""解释 CNN 中的"卷积"实际是互相关操作
严格数学意义上的卷积需要先翻转卷积核:
(f * g)(x) = ∫ f(τ)g(x - τ)dτ
而 CNN 中的操作不翻转卷积核(互相关):
(f ⋆ g)(x) = ∫ f(τ)g(x + τ)dτ
在实际中,由于卷积核的参数是学习的,翻转与否不影响表达能力。
学习到的卷积核可以看作是"翻转后的"数学卷积核。
"""
x = torch.randn(1, 1, 5, 5)
kernel = torch.randn(1, 1, 3, 3)
# PyTorch 的 Conv2d 实际上执行的是互相关
conv_result = F.conv2d(x, kernel)
# 真正的数学卷积需要翻转 kernel
flipped_kernel = torch.flip(kernel, dims=[2, 3])
true_conv_result = F.conv2d(x, flipped_kernel)
print(f"互相关结果和数学卷积结果不同: {not torch.allclose(conv_result, true_conv_result)}")
print("但由于 kernel 是学习的,两者在表达能力上等价")
explain_conv_vs_correlation()输出尺寸的计算公式
def compute_output_size(input_size, kernel_size, stride=1, padding=0, dilation=1):
"""卷积输出尺寸的通用计算公式
output = floor((input + 2*padding - dilation*(kernel-1) - 1) / stride + 1)
参数说明:
- input: 输入的空间尺寸(高或宽)
- kernel_size: 卷积核尺寸
- stride: 步长
- padding: 填充
- dilation: 空洞率
"""
output = (input_size + 2 * padding - dilation * (kernel_size - 1) - 1) // stride + 1
return output
# 各种配置下的输出尺寸
configs = [
{"input": 32, "kernel": 3, "stride": 1, "padding": 1, "dilation": 1},
{"input": 32, "kernel": 3, "stride": 2, "padding": 1, "dilation": 1},
{"input": 32, "kernel": 5, "stride": 1, "padding": 2, "dilation": 1},
{"input": 32, "kernel": 3, "stride": 1, "padding": 2, "dilation": 2}, # 空洞卷积
{"input": 32, "kernel": 3, "stride": 1, "padding": 3, "dilation": 3}, # 更大空洞
]
print("输出尺寸计算:")
for c in configs:
out = compute_output_size(**c)
print(f" 输入={c['input']}, kernel={c['kernel']}, stride={c['stride']}, "
f"padding={c['padding']}, dilation={c['dilation']} -> 输出={out}")优点
缺点
总结
CNN 是计算机视觉的经典基石,理解卷积、池化、残差连接的感受野和特征层次关系,有助于在实际项目中快速选择合适的架构。从 ResNet 到 ConvNeXt,CNN 仍在持续演进。现代 CNN 架构(如 ConvNeXt)已经借鉴了 Transformer 的许多设计理念(如大卷积核、LayerNorm、GELU 激活),在保持卷积操作的高效性的同时,缩小了与 Vision Transformer 的性能差距。
关键知识点
- 卷积核大小、步长、填充和膨胀率决定了输出特征图的空间尺寸和感受野
- BatchNorm 加速收敛并有一定的正则化效果,训练和推理时的行为不同
- 残差连接(Skip Connection)是训练超深网络的关键技术,缓解梯度消失
- 1x1 卷积用于跨通道信息融合和通道降维,是 bottleneck 结构的核心
- 深度可分离卷积将标准卷积分解为 depthwise + pointwise,大幅减少计算量
- 空洞卷积在不增加参数的情况下扩大感受野,是语义分割的常用技术
- SPP 和 AdaptiveAvgPool2d 使网络可以处理可变尺寸的输入
项目落地视角
- 选模型时先考虑部署约束:输入分辨率、推理速度要求和目标硬件算力
- 数据增强(随机裁剪、翻转、颜色抖动)对 CNN 效果提升显著,值得系统设计
- 迁移学习是 CNN 项目落地最实用的手段——在 ImageNet 预训练权重上微调即可获得很好的效果
迁移学习的实践模式
import torch
import torch.nn as nn
import torchvision.models as models
# 模式1:特征提取(冻结骨干网络,只训练分类头)
# 适合数据量少、计算资源有限的场景
def feature_extraction_mode(num_classes=10):
backbone = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
# 冻结所有参数
for param in backbone.parameters():
param.requires_grad = False
# 替换分类头
backbone.fc = nn.Linear(2048, num_classes)
# 只有新分类头的参数需要训练
trainable_params = sum(p.numel() for p in backbone.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in backbone.parameters())
print(f"可训练参数: {trainable_params:,} / 总参数: {total_params:,}")
print(f"冻结比例: {1 - trainable_params/total_params:.2%}")
return backbone
# 模式2:微调(解冻部分层进行微调)
# 适合数据量中等、需要适配新领域的场景
def finetuning_mode(num_classes=10, unfreeze_from='layer3'):
backbone = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
# 冻结前面的层,只微调后面的层
freeze = True
for name, param in backbone.named_parameters():
if unfreeze_from in name:
freeze = False
param.requires_grad = not freeze
backbone.fc = nn.Linear(2048, num_classes)
trainable = sum(p.numel() for p in backbone.parameters() if p.requires_grad)
print(f"微调模式可训练参数: {trainable:,}")
return backbone
# 模式3:全量微调(所有层都训练,但使用较小的学习率)
# 适合数据量大、需要充分适配的场景
def full_finetuning_mode(num_classes=10):
backbone = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
backbone.fc = nn.Linear(2048, num_classes)
# 使用差异学习率:骨干网络用小学习率,分类头用大学习率
param_groups = [
{"params": backbone.fc.parameters(), "lr": 1e-3},
{"params": [p for n, p in backbone.named_parameters()
if n.startswith('layer4')], "lr": 1e-4},
{"params": [p for n, p in backbone.named_parameters()
if not n.startswith('fc') and not n.startswith('layer4')], "lr": 1e-5},
]
print(f"参数组数量: {len(param_groups)}")
return backbone, param_groups
feature_extraction_mode()数据增强的最佳实践
from torchvision import transforms
# 基础数据增强管道
basic_transform = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), # 随机裁剪并缩放
transforms.RandomHorizontalFlip(p=0.5), # 水平翻转
transforms.ColorJitter(brightness=0.2, contrast=0.2, # 颜色抖动
saturation=0.2, hue=0.1),
transforms.RandomRotation(degrees=15), # 随机旋转
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet 归一化
std=[0.229, 0.224, 0.225]),
])
# 高级增强策略:RandAugment + CutMix + MixUp
# RandAugment: 自动搜索的增强策略组合
# CutMix: 将一张图片的区域剪切并粘贴到另一张图片上
# MixUp: 将两张图片进行像素级混合
# 这些策略在 ImageNet 上通常能提升 1-2% 的 Top-1 准确率
# CutMix 实现
def cutmix_data(x, y, alpha=1.0):
"""CutMix 数据增强
原理:
1. 从 Beta 分布中采样混合比例 λ
2. 随机生成一个裁剪区域
3. 将一张图片的裁剪区域替换为另一张图片的对应区域
4. 标签也按面积比例混合
优势:
- 促使模型关注更多区域(而非只关注物体中心)
- 提供更平滑的决策边界
"""
if alpha > 0:
lam = torch.distributions.Beta(alpha, alpha).sample()
else:
lam = 1.0
batch_size = x.size(0)
index = torch.randperm(batch_size, device=x.device)
# 随机生成裁剪框
bbx1, bby1, bbx2, bby2 = rand_bbox(x.size(), lam)
x[:, :, bbx1:bbx2, bby1:bby2] = x[index, :, bbx1:bbx2, bby1:bby2]
# 根据实际裁剪面积调整 λ
lam = 1 - ((bbx2 - bbx1) * (bby2 - bby1) / (x.size(-1) * x.size(-2)))
y_a, y_b = y, y[index]
return x, y_a, y_b, lam
def rand_bbox(size, lam):
"""生成随机裁剪框"""
W = size[2]
H = size[3]
cut_rat = torch.sqrt(torch.tensor(1.0 - lam))
cut_w = int(W * cut_rat)
cut_h = int(H * cut_rat)
cx = torch.randint(W, (1,))[0]
cy = torch.randint(H, (1,))[0]
bbx1 = torch.clamp(cx - cut_w // 2, 0, W)
bby1 = torch.clamp(cy - cut_h // 2, 0, H)
bbx2 = torch.clamp(cx + cut_w // 2, 0, W)
bby2 = torch.clamp(cy + cut_h // 2, 0, H)
return bbx1, bby1, bbx2, bby2常见误区
- 认为更深更宽的模型一定更好——过度堆叠可能带来过拟合和推理开销,ResNet-50 在多数场景已经足够
- 忽略数据增强,直接用原始图片训练
- 不检查输入预处理(均值方差归一化)是否与预训练时一致
- 忽略 BatchNorm 的 train/eval 模式切换——推理时忘记调用 model.eval() 会导致结果不一致
- 在小数据集上不使用预训练权重——从头训练在小数据集上几乎不可能收敛到好的结果
- 盲目使用大输入分辨率——分辨率翻倍带来 4 倍计算量,需要权衡精度和速度
- 混淆空洞卷积和转置卷积的用途——空洞卷积用于保持分辨率扩大感受野,转置卷积用于上采样
进阶路线
- 深入理解 ResNet、DenseNet、EfficientNet 的设计理念和缩放策略
- 学习空洞卷积(Dilated Convolution)和深度可分离卷积(Depthwise Separable Conv)
- 探索 ConvNeXt 等现代 CNN 架构如何借鉴 Transformer 的设计思路
- 了解 CNN 在目标检测(YOLO、FCOS)和分割(U-Net、DeepLab)中的应用
- 学习神经架构搜索(NAS)如何自动化 CNN 的设计过程
- 了解 Vision Transformer (ViT) 和 Hybrid 架构(如 CoAtNet)如何结合 CNN 和 Transformer
适用场景
- 图像分类、目标检测和语义分割等计算机视觉任务
- 任何具有网格拓扑结构的数据(时序、频谱图等)
- 需要高效推理的边缘端视觉应用
- 医学影像分析(CT、MRI、X-ray 等具有网格结构的数据)
- 工业缺陷检测(表面缺陷分类和定位)
落地建议
- 优先使用成熟的预训练模型(torchvision、timm)微调,而非从头训练
- 训练时同步记录 Top-1/Top-5 准确率、混淆矩阵和困难样本
- 上线前在不同光照、角度和分辨率下验证模型鲁棒性
- 使用混合精度训练(AMP)可以加速训练约 2 倍,且几乎不影响精度
- 善用学习率调度器(CosineAnnealing、OneCycleLR),通常比固定学习率效果好
模型选型指南
def model_selection_guide(compute_budget, accuracy_target):
"""根据计算预算和精度目标选择合适的 CNN 架构
算力分级(以 GPU 推理 FPS 为参考):
- 边缘端 (< 30 FPS): MobileNetV3-S, EfficientNet-B0, ShuffleNetV2
- 移动端 (30-100 FPS): MobileNetV3-L, EfficientNet-B1~B2
- 服务端 (100-500 FPS): ResNet-50, EfficientNet-B3~B4
- 高精度 (> 500ms/image): EfficientNet-B5~B7, ConvNeXt-Large
"""
models = {
'MobileNetV3-Small': {'params': '2.5M', 'flops': '56M', 'top1': 67.4},
'MobileNetV3-Large': {'params': '5.4M', 'flops': '219M', 'top1': 75.2},
'EfficientNet-B0': {'params': '5.3M', 'flops': '390M', 'top1': 77.1},
'ResNet-50': {'params': '25.6M', 'flops': '4.1B', 'top1': 80.4},
'EfficientNet-B4': {'params': '19.3M', 'flops': '4.2B', 'top1': 82.9},
'ConvNeXt-Base': {'params': '88.6M', 'flops': '15.4B', 'top1': 83.8},
}
print(f"计算预算: {compute_budget}, 精度目标: {accuracy_target}")
print("\n推荐模型:")
for name, info in models.items():
print(f" {name:25s} 参数={info['params']:>8s} FLOPs={info['flops']:>8s} Top-1={info['top1']:.1f}%")
model_selection_guide("边缘端部署", "70%+")排错清单
- loss 不下降:检查标签对齐、学习率设置和数据增强是否合理
- 训练精度高但验证低:减少模型容量、增加 Dropout 或数据增强
- 推理结果与训练不一致:检查预处理流程是否统一,尤其是归一化参数
- BatchNorm 相关问题:训练时 loss 剧烈震荡——尝试减小学习率或使用 GroupNorm
- 显存不足:减小 batch size、使用梯度累积、或切换为梯度检查点(gradient checkpointing)
- 训练极慢:检查数据加载是否是瓶颈(使用 DataLoader 的 num_workers 和 pin_memory)
- 特定类别精度差:检查该类别的样本数量是否均衡,考虑使用加权损失函数
复盘问题
- 当前使用的 CNN 基础架构是什么?为什么选它而不是其他替代方案?
- 模型在不同类别上的精度分布是否均匀?哪些类别效果最差?
- 如果把输入分辨率减半,精度下降多少?推理速度提升多少?
- 数据增强策略是否经过系统性的消融实验验证?
- 是否尝试过知识蒸馏将大模型的知识迁移到小模型?
