第13课:PyTorch|经典CNN网络模型从零复现【从LeNet到VGG的逐行实现】

文章目录


📖 课前导读

为什么要从零复现经典网络?

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的核心创新

  1. ReLU激活函数:首次在CNN中系统性使用ReLU(虽然ReLU的概念本身提出较早)。相比Sigmoid,ReLU的训练速度提升约6倍------试验中ReLU网络达到86%准确率仅需25轮迭代,而Sigmoid仅78%,且ReLU从根本上缓解了深层网络的梯度消失难题。

  2. Dropout正则化:在全连接层以0.5概率随机丢弃神经元,迫使网络学习更鲁棒、冗余度更高的内部表征。Dropout可将过拟合风险降低约1.2个百分点,相当于训练了多个子网络的集成模型。

  3. 重叠最大池化:采用3×3池化窗口和步长2的重叠池化。相比非重叠(2×2,步长2),重叠池化保留更多特征信息,抗过拟合能力提升约12%。

  4. GPU并行训练:当时的NVIDIA GTX 580单卡显存不足,AlexNet首创将网络切分到两块GPU上并行训练,以此支撑了大卷积核(11×11、5×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:网络结构可视化

使用torchinfosummary()函数,分别打印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 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!

💡 如果本文对您有所启发欢迎:

👍 点赞📌 收藏 📤 分享给更多需要的伙伴。

🗣️ 期待在评论区看到您的想法, 共同进步。

🔔 关注我,持续获取更多干货内容~

🤗 我们下篇文章见~

相关推荐
boxiansheng1631 小时前
408计算机网络
网络·计算机网络·智能路由器
闲云野鹤在人间1 小时前
docker 入门 | 第5章 网络详解
网络·docker·架构·华为云·php
orange....2 小时前
VMware 双网卡配置:宿主机切换网络时 Ubuntu 固定 IP 访问方案
网络·ubuntu·网络安全
yi碗汤园2 小时前
MQTT消息队列遥测传输协议
网络·网络协议·unity
Thomas.Sir3 小时前
第16课:PyTorch|循环神经网络RNN与序列数据处理【让模型拥有“记忆”】
人工智能·pytorch·rnn
吴声子夜歌3 小时前
Shell编程实例——高级脚本编程(一)
linux·运维·网络·shell
深圳光特通信豆子4 小时前
1×9光模块DDM数字诊断监控技术手册
网络·物联网
云边云科技_云网融合4 小时前
从一盘散棋到一张蓝图:某连锁集团的云上重构
网络·科技·云计算
ao-weilai5 小时前
Linux网络编程:Socket UDP
linux·服务器·网络·c++
Quanqiucard5 小时前
监控用物联网卡如何优化管理,降低安防项目通信成本
网络·物联网