
文章目录
-
- [📖 课前导读](#📖 课前导读)
- 一、知识原理:三座里程碑的设计思想
-
- [1.1 LeNet-5:CNN范式的奠基之作](#1.1 LeNet-5:CNN范式的奠基之作)
- [1.2 AlexNet:深度学习的引爆剂](#1.2 AlexNet:深度学习的引爆剂)
- [1.3 VGG:深度化与模块化的极致](#1.3 VGG:深度化与模块化的极致)
- 二、环境搭建与准备
- 三、代码实战:LeNet-5
-
- [3.1 LeNet-5网络结构详解](#3.1 LeNet-5网络结构详解)
- [3.2 LeNet-5数据准备与训练](#3.2 LeNet-5数据准备与训练)
- 四、代码实战:AlexNet
-
- [4.1 AlexNet网络结构详解](#4.1 AlexNet网络结构详解)
- [4.2 AlexNet在CIFAR-10上的训练](#4.2 AlexNet在CIFAR-10上的训练)
- 五、代码实战:VGG16
-
- [5.1 VGG16网络结构详解](#5.1 VGG16网络结构详解)
- [5.2 VGG16在CIFAR-10上的训练](#5.2 VGG16在CIFAR-10上的训练)
- 六、网络性能对比与进阶技巧
-
- [6.1 三网对比表](#6.1 三网对比表)
- [6.2 进阶技巧:使用预训练VGG模型进行迁移学习](#6.2 进阶技巧:使用预训练VGG模型进行迁移学习)
- [6.3 特征图可视化与模型可解释性](#6.3 特征图可视化与模型可解释性)
- 七、难点解析:常见问题与调试技巧
-
- [7.1 训练时长与计算资源](#7.1 训练时长与计算资源)
- [7.2 CIFAR-10输入尺寸的适配问题](#7.2 CIFAR-10输入尺寸的适配问题)
- [7.3 梯度爆炸与VGG16的训练稳定性](#7.3 梯度爆炸与VGG16的训练稳定性)
- [7.4 过拟合的判断与应对](#7.4 过拟合的判断与应对)
- 八、课后总结
- 九、课后作业
- 十、下一课预告
- [🔗《精讲25课|PyTorch 从入门到精通》系列课程导航](#🔗《精讲25课|PyTorch 从入门到精通》系列课程导航)
📖 课前导读
为什么要从零复现经典网络?
LeNet、AlexNet和VGG------这三个名字在深度学习领域如雷贯耳。它们不仅仅是几篇经典论文,更是整个计算机视觉发展的关键节点:
- LeNet-5(1998年,Yann LeCun):现代CNN的鼻祖,首次将卷积、池化、全连接端到端训练范式固定下来,被广泛用于美国的支票手写数字识别系统。
- AlexNet(2012年,Alex Krizhevsky):引爆深度学习革命的导火索。在ImageNet上将Top-5错误率从26%骤降至15.3%,让全世界第一次看到了深度卷积网络的潜力。
- VGG(2014年,牛津大学VGG组):将网络推向更深,系统性地验证了"堆叠小卷积核"的有效性,其模块化设计思想至今仍影响着ResNet、MobileNet等现代架构。
从零复现这些经典网络,是深度学习进阶路上的必修课。它不仅能让你理解CNN的演进逻辑,更能让你学会如何将论文中的架构图转化为可运行的PyTorch代码------这项技能在你日后阅读前沿论文时至关重要。
💡 学习路径:本课提供三种不同的实现难度------LeNet适合入门级的MNIST任务,AlexNet引入了ReLU和Dropout等关键技术,VGG则是模块化设计的典范。建议按照LeNet→AlexNet→VGG的顺序逐级挑战,感受CNN设计从简到繁的脉络。
学完这一课,你将能够:
- ✅ 根据论文描述,独立构建LeNet-5、AlexNet、VGG16的网络结构
- ✅ 理解不同经典网络的设计动机和创新点(局部感受野、大卷积核、小卷积核堆叠等)
- ✅ 在MNIST、CIFAR-10等数据集上完整训练这些网络并评估性能
- ✅ 对比三个网络的参数量、计算复杂度与分类精度,形成对CNN演进规律的系统认知
- ✅ 使用预训练的VGG模型进行迁移学习(Fine-tuning),在小数据集上获得不错的性能
- ✅ 可视化特征图,理解网络各层学到的抽象特征
一、知识原理:三座里程碑的设计思想
在开始写代码之前,我们先来理解这三个网络的核心设计思想和演进脉络。
1.1 LeNet-5:CNN范式的奠基之作
LeNet-5诞生于1998年,是Yann LeCun等人在AT&T贝尔实验室开发的手写数字识别系统。它的历史意义在于:它第一次将"卷积层提取特征、池化层降低维度、全连接层进行分类"这一完整范式固化为现代CNN的标准结构。
LeNet-5的四大设计思想:
| 思想 | 含义 | 价值 |
|---|---|---|
| 局部感受野 | 卷积核只连接输入的一小片区域 | 大幅减少参数量,捕捉局部特征 |
| 权值共享 | 同一卷积核在所有位置使用相同参数 | 检测同类型特征的位置无关性,参数量从连接数压缩到核大小×通道数 |
| 空间下采样 | 池化层降低特征图分辨率 | 减少计算量,增强平移和轻微形变的鲁棒性 |
| 层次化特征提取 | 浅层提取边缘/纹理,深层组合成完整形状 | 层层抽象,逐步构建语义理解 |
LeNet-5的原始结构较为复杂,包含特殊的稀疏连接(C3层并非全连接所有S2通道,而是选择性组合)。但在现代实现中,为简化教学并提升性能,通常会将原始的Sigmoid激活替换为ReLU、将平均池化替换为最大池化。本课将采用这种经过优化的现代版本,同时保留LeNet-5的核心结构精神------这种"保留主干、优化细节"的思路,也是你日后复现经典论文时应该掌握的技巧。
1.2 AlexNet:深度学习的引爆剂
2012年,Alex Krizhevsky、Ilya Sutskever和Geoffrey Hinton团队的AlexNet在ImageNet竞赛中以远超第二名的成绩夺冠,Top-5错误率降至15.3%,传统方法(26%)和浅层CNN无法企及。这一突破直接引爆了深度学习革命,也奠定了GPU在深度学习训练中的核心地位。
AlexNet的核心创新:
-
ReLU激活函数:首次在CNN中系统性使用ReLU(虽然ReLU的概念本身提出较早)。相比Sigmoid,ReLU的训练速度提升约6倍------试验中ReLU网络达到86%准确率仅需25轮迭代,而Sigmoid仅78%,且ReLU从根本上缓解了深层网络的梯度消失难题。
-
Dropout正则化:在全连接层以0.5概率随机丢弃神经元,迫使网络学习更鲁棒、冗余度更高的内部表征。Dropout可将过拟合风险降低约1.2个百分点,相当于训练了多个子网络的集成模型。
-
重叠最大池化:采用3×3池化窗口和步长2的重叠池化。相比非重叠(2×2,步长2),重叠池化保留更多特征信息,抗过拟合能力提升约12%。
-
GPU并行训练:当时的NVIDIA GTX 580单卡显存不足,AlexNet首创将网络切分到两块GPU上并行训练,以此支撑了大卷积核(11×11、5×5)所产生的海量计算。
-
数据增强:在训练时对256×256的原始图像随机裁剪出227×227的块,并采用水平翻转和PCA颜色扰动,使训练样本规模倍增,有效对抗过拟合。
1.3 VGG:深度化与模块化的极致
2014年,牛津大学视觉几何组的VGGNet在ImageNet上将Top-5错误率进一步降至7.32%,其核心贡献在于系统性地探索了网络深度对性能的影响,并首次通过堆叠3×3小卷积核实现了深层网络的稳定训练。
VGG的核心设计哲学:堆叠小卷积核
VGG彻底放弃了AlexNet中11×11和5×5的大卷积核,在所有卷积层统一使用3×3的小卷积核。这个看似简单的改变背后有着深刻的数学原理:
- 两个3×3卷积核堆叠,感受野等效于一个5×5卷积核;三个3×3卷积核堆叠,感受野等效于一个7×7卷积核
- 参数效率更高:一个5×5卷积核有25个参数,而两个3×3卷积核只有18个参数,减少28%
- 非线性表达能力更强:每个3×3卷积层后都接ReLU,两个堆叠意味着两次非线性变换,比单层5×5卷积具有更强的拟合能力
VGG设计了从11层(VGG11)到19层(VGG19)的多个变体。其中VGG16(13个卷积层+3个全连接层)在计算资源与模型性能之间取得了最佳平衡,因此成为最常使用的版本。
VGG的模块化结构:VGG将网络划分为5个卷积块(Block 1~5),每个块由2~3个Conv3×3-ReLU层和一个MaxPool2×2层组成。块内通道数逐块翻倍:64 → 128 → 256 → 512 → 512。这种模块化的设计理念后来被ResNet、MobileNet等几乎所有现代CNN所继承。
二、环境搭建与准备
python
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms, models
import matplotlib.pyplot as plt
import numpy as np
import os
import time
from collections import OrderedDict
# 设置随机种子确保可复现性
torch.manual_seed(42)
np.random.seed(42)
# 检查GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
# 创建保存模型的目录
os.makedirs("./checkpoints", exist_ok=True)
os.makedirs("./logs", exist_ok=True)
可视化辅助函数:后续我们将多次使用这些函数来观察训练过程和特征图。
python
def plot_history(history, title):
"""绘制训练曲线"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
epochs = range(1, len(history['train_loss']) + 1)
ax1.plot(epochs, history['train_loss'], label='Train Loss')
ax1.plot(epochs, history['val_loss'], label='Val Loss')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title(f'{title} - Loss')
ax1.legend()
ax1.grid(True)
ax2.plot(epochs, history['val_acc'], label='Val Acc')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy')
ax2.set_title(f'{title} - Validation Accuracy')
ax2.legend()
ax2.grid(True)
plt.tight_layout()
plt.savefig(f"./logs/{title}_history.png")
plt.show()
def visualize_feature_maps(model, image_tensor, layer_names, device):
"""可视化指定层的特征图"""
model.eval()
features = {}
hooks = []
def hook_fn(name):
def hook(module, input, output):
features[name] = output.detach()
return hook
for name, module in model.named_modules():
if any(layer in name for layer in layer_names):
hooks.append(module.register_forward_hook(hook_fn(name)))
with torch.no_grad():
_ = model(image_tensor.unsqueeze(0).to(device))
for hook in hooks:
hook.remove()
for name, feat in features.items():
# 显示前8个通道
num_channels = min(8, feat.size(1))
fig, axes = plt.subplots(1, num_channels, figsize=(12, 3))
if num_channels == 1:
axes = [axes]
for i in range(num_channels):
axes[i].imshow(feat[0, i].cpu(), cmap='gray')
axes[i].axis('off')
axes[i].set_title(f'ch{i}')
fig.suptitle(f'{name} 特征图')
plt.show()
三、代码实战:LeNet-5
3.1 LeNet-5网络结构详解
LeNet-5的原始结构包含7层(不算输入层):
| 层 | 类型 | 核大小/步长/填充 | 输出尺寸 | 参数量 |
|---|---|---|---|---|
| 输入 | 灰度图像 | - | 32×32×1 | - |
| C1 | 卷积层 | 6个5×5核,stride=1,padding=2 | 28×28×6 | 156 |
| S2 | 平均池化 | 2×2,stride=2 | 14×14×6 | 12 |
| C3 | 卷积层 | 16个5×5核,stride=1 | 10×10×16 | 1,516 |
| S4 | 平均池化 | 2×2,stride=2 | 5×5×16 | 32 |
| C5 | 卷积层 | 120个5×5核 | 1×1×120 | 48,120 |
| F6 | 全连接 | 84个神经元 | 84 | 10,164 |
| 输出 | 全连接 | 10个神经元 | 10 | 850 |
参数量总计:约6万,与全连接网络相比极其轻量。
python
class LeNet5(nn.Module):
"""
LeNet-5的现代实现
原始论文使用Sigmoid激活和平均池化,现代实现替换为ReLU和最大池化以提升性能
输入尺寸:32×32(MNIST原本是28×28,这里通过padding达到32×32的原始设计)
"""
def __init__(self, num_classes=10):
super().__init__()
# 特征提取部分
self.features = nn.Sequential(OrderedDict([
# C1: 卷积层,输入1通道(灰度),输出6通道,核5×5
('conv1', nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=2)), # padding=2使输入32→28×28
('relu1', nn.ReLU(inplace=True)),
# S2: 池化层,2×2平均池化(现代常用最大池化,效果更好)
('pool1', nn.MaxPool2d(kernel_size=2, stride=2)), # 28×28 → 14×14
# C3: 卷积层,6→16通道,核5×5,原始论文采用稀疏连接,这里简化为标准卷积
('conv2', nn.Conv2d(6, 16, kernel_size=5, stride=1)), # 14×14 → 10×10
('relu2', nn.ReLU(inplace=True)),
# S4: 池化层
('pool2', nn.MaxPool2d(kernel_size=2, stride=2)), # 10×10 → 5×5
# C5: 卷积层(在原始设计中等效为全连接),5×5输入,输出120通道,核5×5 → 1×1
('conv3', nn.Conv2d(16, 120, kernel_size=5, stride=1)), # 5×5 → 1×1
('relu3', nn.ReLU(inplace=True))
]))
# 分类器部分
self.classifier = nn.Sequential(OrderedDict([
('flatten', nn.Flatten()),
('fc1', nn.Linear(120, 84)), # F6层
('relu4', nn.ReLU(inplace=True)),
('fc2', nn.Linear(84, num_classes)) # 输出层
]))
def forward(self, x):
# 如果输入是28×28,填充到32×32以匹配原始设计
if x.shape[-1] == 28:
x = F.pad(x, (2, 2, 2, 2))
x = self.features(x)
x = self.classifier(x)
return x
# 创建模型实例
lenet = LeNet5().to(device)
print(lenet)
# 统计参数量
def count_params(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"LeNet-5参数量: {count_params(lenet):,}")
# 输出约 6.1万 参数
3.2 LeNet-5数据准备与训练
python
# MNIST数据预处理
mnist_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
# 加载MNIST数据集
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=mnist_transform)
test_dataset = datasets.MNIST('./data', train=False, download=True, transform=mnist_transform)
# 从训练集中划分验证集(80/20)
train_size = int(0.8 * len(train_dataset))
val_size = len(train_dataset) - train_size
train_subset, val_subset = random_split(train_dataset, [train_size, val_size])
batch_size = 64
train_loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True, num_workers=2)
val_loader = DataLoader(val_subset, batch_size=batch_size, shuffle=False, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=2)
print(f"训练集: {len(train_subset)}, 验证集: {len(val_subset)}, 测试集: {len(test_dataset)}")
# 训练函数
def train_model(model, train_loader, val_loader, epochs, lr=0.001, model_name="model"):
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=3, factor=0.5)
history = {'train_loss': [], 'val_loss': [], 'val_acc': []}
best_acc = 0.0
start_time = time.time()
for epoch in range(1, epochs + 1):
# 训练阶段
model.train()
train_loss = 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()
train_loss += loss.item() * images.size(0)
train_loss /= len(train_loader.dataset)
# 验证阶段
model.eval()
val_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for images, labels in val_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
val_loss += loss.item() * images.size(0)
_, pred = torch.max(outputs, 1)
total += labels.size(0)
correct += (pred == labels).sum().item()
val_loss /= len(val_loader.dataset)
val_acc = correct / total
scheduler.step(val_loss)
history['train_loss'].append(train_loss)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
if val_acc > best_acc:
best_acc = val_acc
torch.save(model.state_dict(), f"./checkpoints/{model_name}_best.pth")
current_lr = optimizer.param_groups[0]['lr']
print(f"Epoch {epoch:2d}/{epochs} | LR: {current_lr:.6f} | "
f"Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}")
elapsed = time.time() - start_time
print(f"训练完成!最佳验证准确率: {best_acc:.4f},耗时: {elapsed:.2f}s")
return history, best_acc
# 训练LeNet-5
print("=" * 50)
print("开始训练 LeNet-5")
print("=" * 50)
lenet = LeNet5().to(device)
history_lenet, acc_lenet = train_model(lenet, train_loader, val_loader, epochs=20, lr=0.001, model_name="lenet5")
plot_history(history_lenet, "LeNet-5")
# 测试集评估
lenet.load_state_dict(torch.load("./checkpoints/lenet5_best.pth"))
lenet.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = lenet(images)
_, pred = torch.max(outputs, 1)
total += labels.size(0)
correct += (pred == labels).sum().item()
print(f"LeNet-5 测试集准确率: {100 * correct / total:.2f}%")
# 预期:约 98.5%-99.0% 的准确率
四、代码实战:AlexNet
4.1 AlexNet网络结构详解
AlexNet是第一个真正意义上的现代CNN,共有8个带权重的层:5个卷积层 + 3个全连接层。
| 层 | 类型 | 详细参数 | 输出尺寸 |
|---|---|---|---|
| Conv1 | 卷积 | 96个11×11核,stride=4 | 55×55×96 |
| Pool1 | 最大池化 | 3×3窗口,stride=2 | 27×27×96 |
| Conv2 | 卷积 | 256个5×5核,padding=2 | 27×27×256 |
| Pool2 | 最大池化 | 3×3窗口,stride=2 | 13×13×256 |
| Conv3 | 卷积 | 384个3×3核,padding=1 | 13×13×384 |
| Conv4 | 卷积 | 384个3×3核,padding=1 | 13×13×384 |
| Conv5 | 卷积 | 256个3×3核,padding=1 | 13×13×256 |
| Pool5 | 最大池化 | 3×3窗口,stride=2 | 6×6×256 |
| FC6 | 全连接 | 4096个神经元 | 4096 |
| Dropout6 | Dropout | 0.5丢弃率 | - |
| FC7 | 全连接 | 4096个神经元 | 4096 |
| Dropout7 | Dropout | 0.5丢弃率 | - |
| FC8 | 全连接 | num_classes个神经元 | num_classes |
AlexNet的参数量分布:绝大部分参数集中在全连接层FC6和FC7(约6000万),占总参数的90%以上。这也是后续轻量化网络着力压缩的部分。
python
class AlexNet(nn.Module):
"""
AlexNet的PyTorch实现
原始论文使用双GPU架构,这里简化为单GPU版本
输入尺寸:227×227×3(或224×224,取决于预处理)
"""
def __init__(self, num_classes=10, dropout=0.5):
super().__init__()
# 特征提取器:5个卷积层
self.features = nn.Sequential(
# Conv1: 输入3通道,输出96通道,核11×11,stride=4
nn.Conv2d(3, 96, kernel_size=11, stride=4, padding=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2), # 重叠池化
# Conv2: 96 → 256通道,核5×5,padding=2
nn.Conv2d(96, 256, kernel_size=5, stride=1, padding=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
# Conv3: 256 → 384通道,核3×3
nn.Conv2d(256, 384, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
# Conv4: 384 → 384通道,核3×3
nn.Conv2d(384, 384, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
# Conv5: 384 → 256通道,核3×3
nn.Conv2d(384, 256, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2) # 6×6×256
)
# 分类器:3个全连接层
self.classifier = nn.Sequential(
nn.Dropout(p=dropout), # 防止过拟合
nn.Linear(256 * 6 * 6, 4096), # 展开后连接
nn.ReLU(inplace=True),
nn.Dropout(p=dropout),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Linear(4096, num_classes)
)
def forward(self, x):
x = self.features(x)
x = torch.flatten(x, 1) # 展平
x = self.classifier(x)
return x
# 针对CIFAR-10的AlexNet适配版本(输入32×32)
class AlexNetForCIFAR(nn.Module):
"""
适配CIFAR-10数据集的AlexNet(输入32×32)
将第一个卷积层的核大小和步长调小,避免特征图过早缩小
"""
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(192, 384, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(384, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
)
self.classifier = nn.Sequential(
nn.Dropout(0.5),
nn.Linear(256 * 4 * 4, 1024),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(1024, 1024),
nn.ReLU(inplace=True),
nn.Linear(1024, num_classes)
)
def forward(self, x):
x = self.features(x)
x = torch.flatten(x, 1)
x = self.classifier(x)
return x
# 创建模型
alexnet_cifar = AlexNetForCIFAR().to(device)
print(alexnet_cifar)
print(f"AlexNet(CIFAR适配版)参数量: {count_params(alexnet_cifar):,}")
4.2 AlexNet在CIFAR-10上的训练
python
# CIFAR-10数据预处理
cifar_transform = transforms.Compose([
transforms.Resize(227), # 原始AlexNet输入尺寸
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
train_cifar = datasets.CIFAR10('./data', train=True, download=True, transform=cifar_transform)
test_cifar = datasets.CIFAR10('./data', train=False, download=True, transform=cifar_transform)
# 划分验证集
train_size = int(0.8 * len(train_cifar))
val_size = len(train_cifar) - train_size
train_subset_cifar, val_subset_cifar = random_split(train_cifar, [train_size, val_size])
batch_size = 64
train_loader_cifar = DataLoader(train_subset_cifar, batch_size=batch_size, shuffle=True, num_workers=2)
val_loader_cifar = DataLoader(val_subset_cifar, batch_size=batch_size, shuffle=False, num_workers=2)
test_loader_cifar = DataLoader(test_cifar, batch_size=batch_size, shuffle=False, num_workers=2)
print(f"CIFAR-10 - 训练集: {len(train_subset_cifar)}, 验证集: {len(val_subset_cifar)}, 测试集: {len(test_cifar)}")
# 训练AlexNet(原版,输入227×227)
print("=" * 50)
print("开始训练 AlexNet (原始版本)")
print("=" * 50)
alexnet = AlexNet(num_classes=10).to(device)
history_alexnet, acc_alexnet = train_model(alexnet, train_loader_cifar, val_loader_cifar,
epochs=30, lr=0.001, model_name="alexnet")
plot_history(history_alexnet, "AlexNet")
# 测试集评估
alexnet.load_state_dict(torch.load("./checkpoints/alexnet_best.pth"))
alexnet.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in test_loader_cifar:
images, labels = images.to(device), labels.to(device)
outputs = alexnet(images)
_, pred = torch.max(outputs, 1)
total += labels.size(0)
correct += (pred == labels).sum().item()
print(f"AlexNet 测试集准确率: {100 * correct / total:.2f}%")
# 预期:约75%-80%的准确率(CIFAR-10的类别较MNIST更复杂)
💡 扩展提示 :AlexNet的原始代码中实现了LRN(局部响应归一化),后来的研究认为LRN对精度帮助有限,但如果你想完整复现论文,可以在Conv2后插入
nn.LocalResponseNorm(size=5, alpha=0.0001, beta=0.75, k=2)。本课为聚焦网络主体结构,将此步骤作为可选优化项。
五、代码实战:VGG16
5.1 VGG16网络结构详解
VGG16由13个卷积层和3个全连接层组成,分为5个卷积块:
| 块 | 卷积层配置 | 通道变化 | 池化后尺寸 |
|---|---|---|---|
| Block 1 | Conv(64)-Conv(64) | 3 → 64 | 112×112 |
| Block 2 | Conv(128)-Conv(128) | 64 → 128 | 56×56 |
| Block 3 | Conv(256)-Conv(256)-Conv(256) | 128 → 256 | 28×28 |
| Block 4 | Conv(512)-Conv(512)-Conv(512) | 256 → 512 | 14×14 |
| Block 5 | Conv(512)-Conv(512)-Conv(512) | 512 → 512 | 7×7 |
随后是Flatten → FC 4096 → Dropout → FC 4096 → Dropout → FC 1000
VGG16使用了**全局平均池化(GAP)**前固定特征图尺寸的技术。第5个卷积块的输出经过两次MaxPool后,从224×224的输入降维到7×7×512,然后展开为25088维向量送入全连接层。
python
class VGG16(nn.Module):
"""
VGG16的PyTorch实现
输入尺寸:224×224×3(可调整)
结构:5个卷积块 + 3个全连接层
"""
def __init__(self, num_classes=10, use_batch_norm=False):
super().__init__()
self.use_batch_norm = use_batch_norm
# 卷积层配置:[卷积核数量, 卷积层数]
cfg = [(64, 2), (128, 2), (256, 3), (512, 3), (512, 3)]
self.features = self._make_layers(cfg, in_channels=3)
# 分类器:3个全连接层
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(512 * 7 * 7, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, num_classes)
)
# 权重初始化(Kaiming初始化,适合ReLU)
self._initialize_weights()
def _make_layers(self, cfg, in_channels):
"""按配置构建卷积块"""
layers = []
for out_channels, num_convs in cfg:
for _ in range(num_convs):
layers.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
if self.use_batch_norm:
layers.append(nn.BatchNorm2d(out_channels))
layers.append(nn.ReLU(inplace=True))
in_channels = out_channels
# 每个卷积块后添加池化层
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
return nn.Sequential(*layers)
def _initialize_weights(self):
"""Kaiming初始化"""
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.constant_(m.bias, 0)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
def make_vgg16(num_classes=10):
"""便捷创建函数"""
return VGG16(num_classes=num_classes, use_batch_norm=True) # BN版本收敛更快
# 创建VGG16模型
vgg = make_vgg16(num_classes=10).to(device)
print(f"VGG16参数量: {count_params(vgg):,}")
# VGG16大约有1.38亿参数(原始ImageNet版本),由于num_classes=10且使用BN,略有不同
5.2 VGG16在CIFAR-10上的训练
python
# VGG16特定数据预处理
vgg_transform = transforms.Compose([
transforms.Resize(224), # VGG16期望224×224输入
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
train_vgg = datasets.CIFAR10('./data', train=True, download=True, transform=vgg_transform)
test_vgg = datasets.CIFAR10('./data', train=False, download=True, transform=vgg_transform)
# 划分验证集
train_size = int(0.8 * len(train_vgg))
val_size = len(train_vgg) - train_size
train_subset_vgg, val_subset_vgg = random_split(train_vgg, [train_size, val_size])
batch_size = 64
train_loader_vgg = DataLoader(train_subset_vgg, batch_size=batch_size, shuffle=True, num_workers=2)
val_loader_vgg = DataLoader(val_subset_vgg, batch_size=batch_size, shuffle=False, num_workers=2)
test_loader_vgg = DataLoader(test_vgg, batch_size=batch_size, shuffle=False, num_workers=2)
print("=" * 50)
print("开始训练 VGG16")
print("=" * 50)
# 训练VGG16(可能需要较长时间,建议使用GPU)
vgg = make_vgg16(num_classes=10).to(device)
# 使用更大的学习率,配合StepLR阶梯衰减
optimizer = optim.SGD(vgg.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)
criterion = nn.CrossEntropyLoss()
def train_vgg(vgg, train_loader, val_loader, epochs=30):
vgg.train()
best_acc = 0.0
history = {'train_loss': [], 'val_loss': [], 'val_acc': []}
for epoch in range(1, epochs + 1):
train_loss = 0.0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = vgg(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
train_loss += loss.item() * images.size(0)
train_loss /= len(train_loader.dataset)
# 验证
vgg.eval()
val_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for images, labels in val_loader:
images, labels = images.to(device), labels.to(device)
outputs = vgg(images)
loss = criterion(outputs, labels)
val_loss += loss.item() * images.size(0)
_, pred = torch.max(outputs, 1)
total += labels.size(0)
correct += (pred == labels).sum().item()
val_loss /= len(val_loader.dataset)
val_acc = correct / total
scheduler.step()
history['train_loss'].append(train_loss)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
if val_acc > best_acc:
best_acc = val_acc
torch.save(vgg.state_dict(), "./checkpoints/vgg16_best.pth")
print(f"Epoch {epoch:2d}/{epochs} | LR: {optimizer.param_groups[0]['lr']:.6f} | "
f"Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}")
vgg.train()
return history, best_acc
history_vgg, best_acc_vgg = train_vgg(vgg, train_loader_vgg, val_loader_vgg, epochs=30)
plot_history(history_vgg, "VGG16")
# 测试集评估
vgg.load_state_dict(torch.load("./checkpoints/vgg16_best.pth"))
vgg.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in test_loader_vgg:
images, labels = images.to(device), labels.to(device)
outputs = vgg(images)
_, pred = torch.max(outputs, 1)
total += labels.size(0)
correct += (pred == labels).sum().item()
print(f"VGG16 测试集准确率: {100 * correct / total:.2f}%")
# 预期:约85%-90%的准确率(使用BN版本)
六、网络性能对比与进阶技巧
6.1 三网对比表
| 网络 | 提出年份 | 深度 | 参数量(ImageNet版) | 特点 | 关键创新 |
|---|---|---|---|---|---|
| LeNet-5 | 1998 | 7层 | 约6万 | 轻量,适合MNIST | 确立CNN基本范式 |
| AlexNet | 2012 | 8层 | 约6000万 | 大卷积核,GPU并行 | ReLU, Dropout, 数据增强 |
| VGG16 | 2014 | 16层 | 约1.38亿 | 小核堆叠,模块化 | 3×3卷积堆叠,统一结构 |
各网络参数量差异悬殊:LeNet-5的核心目标是以最小的参数量完成手写数字识别,参数量只有6万;AlexNet将网络推向真正的大规模图像识别,参数量膨胀至6000万;VGG16更进一步强调"深度为王",通过堆叠3×3卷积层构建了深度达16层的网络,参数量飙升至1.38亿。
6.2 进阶技巧:使用预训练VGG模型进行迁移学习
python
def use_pretrained_vgg16(num_classes=10):
"""
使用torchvision预训练的VGG16权重进行迁移学习
主要应用场景:数据集较小(如数百张图片),不足以从头训练VGG16
"""
# 加载预训练模型
pretrained_vgg = models.vgg16(pretrained=True)
print("加载ImageNet预训练VGG16成功")
# 冻结特征提取层(不更新参数)
for param in pretrained_vgg.features.parameters():
param.requires_grad = False
# 修改分类器部分
# VGG16原始分类器:Linear(25088,4096) -> ReLU -> Dropout(0.5) -> Linear(4096,4096) -> ReLU -> Dropout -> Linear(4096,1000)
# 根据任务需求保留相同结构,仅修改最后输出层维度
in_features = pretrained_vgg.classifier[6].in_features
pretrained_vgg.classifier[6] = nn.Linear(in_features, num_classes)
# 只对修改后的层设置较小的学习率进行微调
for param in pretrained_vgg.classifier.parameters():
param.requires_grad = True
# 建议用较小的学习率微调,避免破坏预训练权重
optimizer = optim.SGD(filter(lambda p: p.requires_grad, pretrained_vgg.parameters()),
lr=0.001, momentum=0.9)
return pretrained_vgg
# 使用预训练VGG16
# vgg_pretrained = use_pretrained_vgg16(num_classes=10).to(device)
预训练迁移学习适用场景:
- 目标任务的数据量很小(几百到几千张图片)
- 目标任务与ImageNet的类别在语义上有共通性(如物体识别、场景分类)
6.3 特征图可视化与模型可解释性
python
# 使用LeNet-5展示特征图可视化
sample_img, sample_label = train_subset[0]
# 可视化卷积层特征
visualize_feature_maps(lenet, sample_img, ['conv1', 'conv2', 'conv3'], device)
观察结论:
- 浅层特征图(conv1):主要检测边缘、角点等基础视觉元素
- 深层特征图(conv3):开始出现对数字结构和局部形状的响应
七、难点解析:常见问题与调试技巧
7.1 训练时长与计算资源
| 网络 | 训练时长(CIFAR-10,30轮) | 建议硬件 |
|---|---|---|
| LeNet-5 | 2-3分钟 | CPU亦可 |
| AlexNet | 15-20分钟 | 入门级GPU |
| VGG16 | 1-2小时 | 6GB+显存GPU |
VGG16的训练在CPU上很难完成,建议有NVIDIA GPU的用户才尝试完整训练。如果没有GPU,可以跳过VGG16的训练部分,只阅读原理和预训练权重的使用章节。
7.2 CIFAR-10输入尺寸的适配问题
原始的LeNet-5设计是针对32×32输入,AlexNet期待227×227,VGG16期待224×224。当这三者分别迁移到CIFAR-10(32×32),处理方案完全不同:
- LeNet-5:最直接的选择------几乎无需修改,因为32×32正好匹配其原始设计
- AlexNet适配版:建议将第一个卷积层的核从11×11换为3×3,步长从4换为1
- VGG16 :必须通过
transforms.Resize(224)将图片强行拉大。这个过程会引入额外的计算开销和一定程度的图像失真
7.3 梯度爆炸与VGG16的训练稳定性
VGG16深度大,尤其在使用SGD优化器时容易出现梯度爆炸。解决方案依次包括:使用BatchNorm(VGG16_BN版本)、使用Adam替代SGD、降低初始学习率(从0.1甚至1.0降到0.01-0.001)、以及使用梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。
7.4 过拟合的判断与应对
三款网络在CIFAR-10或MNIST上的过拟合表现形式各不相同。判断方法:如果训练损失持续下降,但验证损失开始回升,且验证准确率震荡下降,说明过拟合已经发生。应对方法包括:增大Dropout比例(从0.2-0.3提升到0.5-0.7)、增加Weight Decay(如5e-4)、使用更强力的数据增强(随机旋转、裁剪)、以及适当地减少模型容量(降低全连接层维度或卷积层通道数)。
八、课后总结
核心知识点回顾
三网设计思想对比:
| 网络 | 核心原则 | 关键参数选择 | 对后续网络的启发 |
|---|---|---|---|
| LeNet-5 | 浅层卷积+全连接 | 5×5卷积核,平均池化 | CNN基本范式 |
| AlexNet | 深层网络+正则化 | 11×11大核,Dropout, ReLU | GPU并行训练,深度价值验证 |
| VGG16 | 模块化+小核堆叠 | 3×3小核,5个卷积块 | 结构统一化,残差连接的前奏 |
检查清单
- 能够根据论文描述逐层实现LeNet-5的网络结构
- 理解AlexNet相比LeNet-5的改进点及其动机(ReLU、Dropout、数据增强、重叠池化)
- 理解VGG的"堆叠小卷积核"设计哲学及其数学原理
- 在MNIST/CIFAR-10上完成三个网络的训练并评估准确率
- 能够区分三个网络的参数量级和应用场景
- 知道如何加载并使用预训练的VGG16模型进行迁移学习
- 遇到训练不收敛时,能从初始化、学习率、归一化等方向排查
九、课后作业
作业1:LeNet-5的激活函数和池化对比
在LeNet-5中,分别使用以下配置训练并记录测试准确率:
- (a) 原始配置:Sigmoid + 平均池化
- (b) 现代配置:ReLU + 最大池化
- © ReLU + 平均池化
- (d) 混合配置:Sigmoid + 最大池化
对比四组实验的收敛速度和最终准确率,分析激活函数和池化方式各自对性能的影响。
作业2:AlexNet的Dropout消融实验
在AlexNet训练中,分别设置dropout率:0(无Dropout)、0.3、0.5、0.7。观察训练集和验证集准确率的差距变化,判断不同Dropout强度对过拟合的抑制效果。尝试画出不同dropout率下的训练-验证准确率差值曲线。
作业3:VGG16感受野计算
证明:为什么两个3×3卷积核堆叠等效于一个5×5卷积核的感受野?扩展计算:三个3×3卷积核堆叠的感受野是多少?用数学推导和Python代码分别验证。
作业4:预训练VGG16迁移学习
使用torchvision.models.vgg16(pretrained=True)加载预训练权重,修改最后的分类层以适应一个自定义数据集(可自制一个小型图像分类数据集,如10类小型动物图片)。分别训练:
- 仅训练最后分类层(冻结特征提取层)
- 微调最后两个卷积块和分类层
- 全部参数微调(不冻结)
对比三种策略下的收敛速度和测试准确率。
作业5:网络结构可视化
使用torchinfo的summary()函数,分别打印LeNet-5、AlexNet和VGG16的网络结构、每一层的输出形状和参数量。统计三个网络的总参数量,计算卷积层和全连接层的参数占比,分析不同网络结构的参数分布特征。
作业6:性能对比与改进
在CIFAR-10上,从以下维度对比三个网络:
- 训练总耗时
- 最终测试准确率
- 训练过程中验证准确率的收敛速度(达到70%准确率所需的epoch数)
尝试在AlexNet或VGG16中加入BatchNorm,观察是否对收敛速度和最终准确率有提升。
十、下一课预告
第14课我们将学习轻量化CNN网络与移动端模型设计,内容包括:
- ResNet的残差结构原理------解决梯度消失的关键创新
- MobileNet的深度可分离卷积------参数量爆降10倍的秘诀
- ShuffleNet的分组卷积与通道重排
- 模型参数量压缩思路与嵌入式部署适配
轻量化网络是CNN从实验室走向工业应用(手机、无人机、边缘计算)的关键。学完第14课,你将能设计出可实机部署的小模型。
附录:三个经典网络的PyTorch官方调用方式
python
from torchvision import models
# 官方预训练模型(推荐在ImageNet任务上使用)
lenet_official = None # torchvision没有内置LeNet-5
alexnet_official = models.alexnet(pretrained=True) # AlexNet
vgg16_official = models.vgg16(pretrained=True) # VGG16
vgg16_bn_official = models.vgg16_bn(pretrained=True) # VGG16 with BatchNorm
🔗《精讲25课|PyTorch 从入门到精通》系列课程导航
🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~