LeNet-5(详解)—— 从原理到 PyTorch 实现(含训练示例)
文章目录
标题
MobileNet V1 & V2(详解)—— 从原理到 PyTorch 实现(含训练示例与对比实验)
简介:为什么要学习 MobileNet 系列
MobileNet 系列是为移动端/嵌入式场景设计的轻量级卷积神经网络家族。它们通过结构设计(Depthwise Separable Convolution、宽度/分辨率缩放、倒置残差与线性瓶颈等)在“精度 ↔ 计算/内存”之间做出高效平衡,是实际工程中非常实用的模型。本文目标是:讲清核心思想、给出逐层计算举例、并提供可直接运行的 PyTorch 实现与训练/评估流程,便于你上手学习与改进。
核心思想(总结)
- Depthwise separable convolution(深度可分离卷积):把标准卷积分解为“depthwise” + “pointwise”,从而大幅降低参数与计算量(论文给出 3×3 的情形通常能节省约 8–9 倍计算)。
- 模型缩放超参(MobileNet V1):使用 width multiplier(宽度缩放 α) 和 resolution multiplier(分辨率缩放 ρ) 来在线性权衡精度/速度/内存。
- 倒置残差 + 线性瓶颈(MobileNetV2):在瓶颈处使用扩展(先扩张通道再做深度卷积,最后线性投影回小通道),并在窄层做残差连接,从而保持信息流并节约内存,同时使用 ReLU6 来增强低精度场景的鲁棒性。
逐层结构(概要表)
MobileNet V1(简化版结构)
(以标准 224×224×3
输入,最后 FC 输出 1000 类为例)
- conv3×3, stride=2, 32
- dw-sep(3×3) → 64 (s=1)
- dw-sep(3×3) → 128 (s=2)
- dw-sep(3×3) → 128 (s=1)
- dw-sep(3×3) → 256 (s=2)
- dw-sep(3×3) → 256 (s=1)
- dw-sep(3×3) → 512 (s=2)
- dw-sep(3×3) → 512 (s=1) ×5(重复5次)
- dw-sep(3×3) → 1024 (s=2)
- dw-sep(3×3) → 1024 (s=1)
- global avg pool → FC(1000)
(详细表可查论文 Table 1)
MobileNet V2(关键层次表,paper Table 2)
MobileNetV2 用 bottleneck(倒置残差块) 做堆叠,每个 block 有扩张系数 t
、输出通道 c
、重复次数 n
、stride s
,示例(部分):
- conv3×3, 32, s=2
- bottleneck t=1, c=16, n=1, s=1
- bottleneck t=6, c=24, n=2, s=2
- bottleneck t=6, c=32, n=3, s=2
- …
- 最后 1×1 conv → 1280 → avgpool → FC
详表见论文 Table 2(本文后面给出 PyTorch 实现)。
逐层计算举例(含卷积输出尺寸与计算量公式)
1) 空间尺寸计算(单层示例)
标准二维卷积输出尺寸公式(以 0-based padding 表述):
out_H = floor((in_H + 2*pad - kernel_size) / stride) + 1
out_W = floor((in_W + 2*pad - kernel_size) / stride) + 1
举例:输入 224×224
,第一层 conv3×3, stride=2, padding=1
:
out_H = floor((224 + 2*1 - 3)/2) + 1 = floor(224/2) + 1 = 112
(等价于常用“保持 same” 的设置)
所以空间变为112×112
。
2) 参数与计算量(FLOPs / Multiply-Adds)对比:标准卷积 vs 深度可分离卷积
标准卷积(kernel K×K
, 输入通道 M
, 输出通道 N
, 特征图 D_F × D_F
)的计算量(multiply-adds):
cost_standard = K*K * M * N * D_F * D_F
Depthwise separable = depthwise + pointwise:
- depthwise cost =
K*K * M * D_F * D_F
- pointwise cost =
1*1 * M * N * D_F * D_F = M * N * D_F * D_F
- 总和:
cost_ds = K*K * M * D_F * D_F + M * N * D_F * D_F
节省比率(cost_ds / cost_standard):
ratio = (K^2 * M + M*N) / (K^2 * M * N) = 1/N + 1/(K^2)
对于 K=3,K^2=9
,当 N
很大(常见情况)时,主要项约为 1/9 ≈ 0.111
,也就是约 8~9 倍 的减少(与论文结论一致)。
数值示例(手算/演示)
令 K=3
, M=128
, N=256
, D_F=56
(比较常见的一层尺寸):
- 标准卷积 MAdds =
3*3*128*256*56*56 = 924,844,032
(约 9.25e8) - Depthwise separable MAdds =
3*3*128*56*56 + 128*256*56*56 = 106,373,120
(约 1.06e8) - 比例 ≈
106,373,120 / 924,844,032 ≈ 0.115
→ 约 8.7× 更少计算(论文中也得出 8–9 倍的结论)。
(上面数字为逐项展开后的乘法结果,展示了深度可分离卷积带来的实际计算节省。)
MobileNet V1:关键设计点细解
- Depthwise + Pointwise:把“滤波(spatial)”和“通道融合”分离开。滤波放到 depthwise(每通道一个 filter),channel 则由 1×1 pointwise 完成。
- 宽度/分辨率超参(α, ρ):通过 α 缩减通道数,通过 ρ 缩减输入分辨率,从而在推断速度/模型大小/精度之间平滑权衡。论文中说明了多组 α/ρ 的实验。
- BN + ReLU:每个 depthwise 和 pointwise 后接 BatchNorm 与 ReLU(paper 中使用 ReLU)。
MobileNet V2:核心创新
- 倒置残差(Inverted Residual):残差连接不再连接“宽”通道,而是连接“窄(bottleneck)”通道;基本单元是先用 1×1 扩展通道(ReLU6),再 depthwise 3×3(ReLU6),最后用 1×1 线性投影回窄通道(无激活)。该结构在表达能力与内存效率之间取得平衡。
- 线性瓶颈(Linear Bottleneck):最后投影回低维时不使用非线性(no ReLU),以避免信息在低维空间被 ReLU 非线性“毁损”。
- ReLU6:在窄通道使用 ReLU6(对低精度设备更鲁棒)。
MobileNetV2 在效率/准确率曲线上相比 V1 有明显改进(paper 给出在 ImageNet 上 MobileNetV2 (1.0) top-1 ≈ 72.0%,参数 ≈ 3.4M,MAdds ≈ 300M 的典型点)。
PyTorch 实现(完整代码块)
说明:下面给出可直接复制运行的简洁实现(适合教学与小改动)。在工程中也可直接使用
torchvision.models.mobilenet_v2
(可加载预训练权重)。([PyTorch][1])
MobileNet V1(精简实现)
# mobilenet_v1.py
import torch
import torch.nn as nn
import torch.nn.functional as F
def _make_divisible(v, divisor=8, min_value=None):
if min_value is None:
min_value = divisor
new_v = max(min_value, int(v + divisor / 2) // divisor * divisor)
# make sure that round down does not go down by more than 10%
if new_v < 0.9 * v:
new_v += divisor
return new_v
class DepthwiseSeparableConv(nn.Module):
def __init__(self, in_ch, out_ch, stride=1):
super().__init__()
self.depthwise = nn.Conv2d(in_ch, in_ch, kernel_size=3, stride=stride,
padding=1, groups=in_ch, bias=False)
self.bn1 = nn.BatchNorm2d(in_ch)
self.pointwise = nn.Conv2d(in_ch, out_ch, kernel_size=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_ch)
self.relu = nn.ReLU(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
class MobileNetV1(nn.Module):
def __init__(self, num_classes=1000, width_mult=1.0):
super().__init__()
# base channel settings from paper
base_cfg = [
# t: (out_ch, stride)
(32, 2),
(64, 1),
(128, 2),
(128, 1),
(256, 2),
(256, 1),
(512, 2),
(512, 1), (512, 1), (512, 1), (512, 1), (512, 1),
(1024, 2),
(1024, 1),
]
input_channel = _make_divisible(32 * width_mult)
self.features = []
# first conv (not depthwise)
self.features.append(nn.Conv2d(3, input_channel, kernel_size=3, stride=2, padding=1, bias=False))
self.features.append(nn.BatchNorm2d(input_channel))
self.features.append(nn.ReLU(inplace=True))
# add depthwise separable blocks
for out_ch, stride in base_cfg[1:]:
out_ch = _make_divisible(out_ch * width_mult)
self.features.append(DepthwiseSeparableConv(input_channel, out_ch, stride))
input_channel = out_ch
self.features = nn.Sequential(*self.features)
self.avgpool = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Linear(input_channel, num_classes)
# weight init
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out')
elif isinstance(m, nn.BatchNorm2d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.zeros_(m.bias)
def forward(self, x):
x = self.features(x)
x = self.avgpool(x).view(x.size(0), -1)
x = self.classifier(x)
return x
MobileNet V2(关键模块 + 简洁实现)
# mobilenet_v2.py
import torch
import torch.nn as nn
def _make_divisible(v, divisor=8, min_value=None):
if min_value is None:
min_value = divisor
new_v = max(min_value, int(v + divisor / 2) // divisor * divisor)
if new_v < 0.9 * v:
new_v += divisor
return new_v
class InvertedResidual(nn.Module):
def __init__(self, inp, oup, stride, expand_ratio):
super().__init__()
self.stride = stride
self.use_res_connect = (self.stride == 1 and inp == oup)
hidden_dim = int(round(inp * expand_ratio))
layers = []
if expand_ratio != 1:
# pointwise
layers.append(nn.Conv2d(inp, hidden_dim, 1, 1, 0, bias=False))
layers.append(nn.BatchNorm2d(hidden_dim))
layers.append(nn.ReLU6(inplace=True))
# depthwise
layers.append(nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1, groups=hidden_dim, bias=False))
layers.append(nn.BatchNorm2d(hidden_dim))
layers.append(nn.ReLU6(inplace=True))
# linear projection
layers.append(nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False))
layers.append(nn.BatchNorm2d(oup))
self.conv = nn.Sequential(*layers)
def forward(self, x):
if self.use_res_connect:
return x + self.conv(x)
else:
return self.conv(x)
class MobileNetV2(nn.Module):
def __init__(self, num_classes=1000, width_mult=1.0):
super().__init__()
# setting as paper's table
inverted_residual_setting = [
# t, c, n, s
(1, 16, 1, 1),
(6, 24, 2, 2),
(6, 32, 3, 2),
(6, 64, 4, 2),
(6, 96, 3, 1),
(6, 160, 3, 2),
(6, 320, 1, 1),
]
input_channel = _make_divisible(32 * width_mult)
last_channel = _make_divisible(1280 * max(1.0, width_mult))
features = []
# first layer
features.append(nn.Conv2d(3, input_channel, kernel_size=3, stride=2, padding=1, bias=False))
features.append(nn.BatchNorm2d(input_channel))
features.append(nn.ReLU6(inplace=True))
# bottlenecks
for t, c, n, s in inverted_residual_setting:
output_channel = _make_divisible(c * width_mult)
for i in range(n):
stride = s if i == 0 else 1
features.append(InvertedResidual(input_channel, output_channel, stride, expand_ratio=t))
input_channel = output_channel
# last layers
features.append(nn.Conv2d(input_channel, last_channel, kernel_size=1, bias=False))
features.append(nn.BatchNorm2d(last_channel))
features.append(nn.ReLU6(inplace=True))
self.features = nn.Sequential(*features)
self.avgpool = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Linear(last_channel, num_classes)
# weight init
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out')
elif isinstance(m, nn.BatchNorm2d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.zeros_(m.bias)
def forward(self, x):
x = self.features(x)
x = self.avgpool(x).view(x.size(0), -1)
x = self.classifier(x)
return x
实现注释:
groups=in_channels
的 Conv2d 即为 depthwise 卷积(PyTorch 标准实现)。关于如何用groups
做 depthwise,请参见 PyTorch 讨论与示例。([PyTorch Forums][2])- MobileNetV2 的实现与 torchvision 的实现思想一致;在工程中可以直接用
torchvision.models.mobilenet_v2(pretrained=True)
加载预训练模型并微调。([PyTorch][1])
训练与评估(示例流程 + 超参建议)
数据与预处理(以 ImageNet/或 CIFAR-10 为例)
- ImageNet 推荐
Resize(256) -> CenterCrop(224)
(或训练时RandomResizedCrop(224)
),标准 ImageNet 归一化。 - CIFAR-10 推荐
RandomCrop(32, padding=4)
,RandomHorizontalFlip()
、Normalize。
超参(示例)
- Optimizer:SGD(momentum=0.9, weight_decay=1e-4)
- LR schedule:初始 LR=0.1(ImageNet)或 0.01(小数据集),使用 StepLR 或 CosineAnnealing,训练 90 epoch(ImageNet)或 200 epoch(CIFAR-10)
- Batch size:根据显存设定(常见 128/256 for ImageNet)
- loss:CrossEntropyLoss
- 预训练:对小数据集用 ImageNet 预训练权重做 fine-tune 可以大幅加速与提高最终精度(若可用)。
简单训练循环框架(伪代码)
# 假设 model, train_loader, val_loader 已准备
optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
criterion = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
for x,y in train_loader:
x,y = x.to(device), y.to(device)
pred = model(x)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
# 验证
model.eval()
correct = 0
total = 0
with torch.no_grad():
for x,y in val_loader:
x,y = x.to(device), y.to(device)
pred = model(x)
correct += (pred.argmax(1) == y).sum().item()
total += y.size(0)
print(f"Epoch {epoch}: val_acc = {correct/total:.4f}")
训练曲线与结果(参考)
论文给出的 ImageNet 对比(paper 中的表格)显示:
- MobileNetV1 (1.0): Top-1 ≈ 70.6%, params ≈ 4.2M, MAdds ≈ 575M。
- MobileNetV2 (1.0): Top-1 ≈ 72.0%, params ≈ 3.4M, MAdds ≈ 300M(更高效)。
你的实际训练结果会根据数据增强、优化器与训练时长而变化;上面是论文在 ImageNet 上的基准值,可作为对照。
实验扩展(可以做的对比实验)
- Width multiplier α 的影响:α ∈ {1.4, 1.0, 0.75, 0.5, 0.35},对比参数量 / MAdds / Top-1 精度的 trade-off(论文有完整曲线)。
- 输入分辨率 ρ 的影响:224 vs 192 vs 160 的效果对比(影响计算量与精度)。
- V1 vs V2 基于相同 MAdds 的精度对比:论文显示 V2 在相同预算下通常更优。
- 激活函数对比:ReLU6(V2) vs ReLU(V1),在低精度量化(8-bit)场景下通常 ReLU6 更稳健。
- 数据增强 / AutoAugment / MixUp / CutMix:尝试这些增广能在给定模型上提升 1~3% 精度。
- 替换 Depthwise 的实现或优化:不同后端(e.g., cuDNN, TensorRT, TF-Lite)对 depthwise 卷积支持差异会导致实际运行时间差异,注意工程部署时的算子支持。
部署注意与工程实践
- 虽然 depthwise separable 在理论 FLOPs 大幅减少,但实际 延迟/吞吐 受后端对
groups
支持、内存访问与并发实现影响。不同设备上需要做针对性 benchmark(TF-Lite / ONNX / TensorRT / CoreML)。 - 若目标是极致压缩,可结合量化(8-bit)、剪枝、知识蒸馏等手段进一步减小模型与延迟。
总结
- MobileNet 系列通过结构设计(深度可分离卷积、宽度/分辨率缩放、倒置残差与线性瓶颈)实现了在资源受限场景下优异的“精度 ↔ 计算/内存”折中。MobileNetV2 在 V1 的基础上引入了倒置残差和线性瓶颈,进一步提高了性能与效率。论文中给出的基准(ImageNet 上)可作为工程选择模型规模的参考。