CNN 卷积神经网络原理与 PyTorch 实现笔记
本文最后更新于 22 天前,如有失效请评论区留言。

之前我们聊过了 Transformer 架构和 PyTorch 框架的基础,今天来聊聊深度学习里的另一个经典架构 —— CNN (Convolutional Neural Network,卷积神经网络)。如果说 Transformer 是 NLP 领域的王者,那 CNN 就是计算机视觉 (Computer Vision) 的开山鼻祖。虽然现在 Vision Transformer (ViT) 也很火,但 CNN 的思想依然是理解深度学习视觉模型的基础,很多概念在后续的架构中都能看到影子。

这篇文章会从直觉出发,一步步拆解 CNN 的核心组件,最后用 PyTorch 写一个完整的图像分类模型。走起!

为什么需要 CNN?

先想一个问题:如果我们用最朴素的全连接层 (Fully Connected Layer / Dense Layer) 来处理一张 224×224 的 RGB 图片,输入层就有 224 × 224 × 3 = 150,528 个神经元。假设第一个隐藏层有 1000 个神经元,光这一层就有 1.5 亿个参数!这不仅计算量爆炸,而且很容易 overfitting。

更关键的是,全连接层完全忽略了图像的空间结构。图片里一只猫在左上角还是右下角,对于全连接层来说完全是不同的输入,它学不到「这都是猫」这种平移不变性 (translation invariance)。

CNN 的核心思想其实很简单:用小的卷积核 (kernel/filter) 在图像上滑动,只看局部区域,然后层层叠加提取越来越抽象的特征。 这样做的好处是:

  • 参数共享 (Parameter Sharing):同一个卷积核在整个图像上滑动,大大减少参数量
  • 局部连接 (Local Connectivity):每个神经元只关注局部区域,符合图像的局部相关性
  • 平移等变性 (Translation Equivariance):猫在图片任何位置,卷积核都能检测到同样的特征

卷积操作 (Convolution)

卷积操作是 CNN 的灵魂。想象你有一个 3×3 的小窗口(卷积核/kernel/filter),它在图像上从左到右、从上到下滑动,每到一个位置就做一次元素对应相乘再求和的操作。

具体来说,假设输入是一个 5×5 的灰度图,卷积核是 3×3:

# 卷积操作的直觉理解
# 输入: 5x5 灰度图
# 卷积核: 3x3
# 输出: 3x3 的 feature map (当 stride=1, no padding 时)

# 输出尺寸公式:
# output_size = (input_size - kernel_size + 2 * padding) / stride + 1
# 例: (5 - 3 + 2*0) / 1 + 1 = 3

几个重要的超参数:

  • Kernel Size:卷积核大小,常用 3×3、5×5、1×1。VGGNet 证明了用两个 3×3 卷积核堆叠等效于一个 5×5 的感受野 (receptive field),但参数更少
  • Stride:步长,即卷积核每次移动的距离。stride=2 时输出尺寸减半,可以代替 pooling 做下采样
  • Padding:在输入边缘补零。「same」padding 可以保持输出尺寸不变
  • Channels:卷积核的通道数必须和输入通道数一致,一个卷积核输出一个 feature map。想要 N 个 feature map 就用 N 个卷积核

池化层 (Pooling Layer)

Pooling 的作用是降低空间维度,减少计算量,同时增加一些平移不变性。最常见的两种:

  • Max Pooling:取窗口内的最大值。比如 2×2 的 max pooling 会让 feature map 的长宽各减半。这是最常用的
  • Average Pooling:取窗口内的平均值。在一些轻量级网络(如 MobileNet)或 Global Average Pooling (GAP) 中比较常见

值得注意的是,最近的趋势是用 stride=2 的卷积代替 pooling(比如 ResNet 的一些变体),因为 pooling 会丢弃信息,而 stride convolution 可以学习如何更好地下采样。

经典 CNN 架构演进

LeNet-5 (1998)

Yann LeCun 的开山之作,用于手写数字识别。结构很简单:两层卷积 + 两层池化 + 三层全连接。虽然现在看起来很原始,但它奠定了 CNN 的基本范式:卷积 → 激活 → 池化的堆叠。

AlexNet (2012)

AlexNet 在 ImageNet 上把 top-5 error 从 26% 干到了 15%,直接引爆了深度学习革命。关键创新:ReLU 激活函数、Dropout、Data Augmentation、GPU 训练。

VGGNet (2014)

VGG 的哲学是「更深更小」:全部用 3×3 卷积核,通过堆叠层数来增加感受野。VGG-16 有 16 层,VGG-19 有 19 层。虽然参数量很大,但结构非常优雅,到现在还经常被用作 feature extractor。

ResNet (2015)

ResNet 引入了 残差连接 (skip connection / residual connection),解决了深层网络的退化问题。核心公式:y = F(x) + x,让网络学习残差映射而不是直接映射。这个思想后来影响了几乎所有深度学习架构,包括 Transformer。

ResNet 证明了:网络不是越深越好,但有了残差连接,可以做到又深又好。ResNet-152 有 152 层,至今仍是很多任务的 baseline。

PyTorch 实现 CNN

说了这么多理论,终于到写代码的时候了。我们用 PyTorch 在 CIFAR-10 数据集上实现一个简单的 CNN 分类器。

Step 1: 数据准备

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms

# CIFAR-10 是 32x32 的彩色图片,10 个类别
# 数据预处理:ToTensor + Normalize
transform = transforms.Compose([
    transforms.RandomHorizontalFlip(),       # 随机水平翻转,数据增强
    transforms.RandomCrop(32, padding=4),    # 随机裁剪
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465),   # CIFAR-10 的均值
                         (0.2023, 0.1994, 0.2010)),   # CIFAR-10 的标准差
])

# 下载并加载训练集和测试集
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128,
                                          shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                       download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=128,
                                         shuffle=False, num_workers=2)

classes = ('plane', 'car', 'bird', 'cat', 'deer',
           'dog', 'frog', 'horse', 'ship', 'truck')

Step 2: 定义模型

class SimpleCNN(nn.Module):
    """
    一个简单的 CNN 模型,结构:
    Conv(3->32) -> BN -> ReLU -> MaxPool
    Conv(32->64) -> BN -> ReLU -> MaxPool
    Conv(64->128) -> BN -> ReLU -> MaxPool
    FC(128*4*4 -> 256) -> Dropout -> ReLU
    FC(256 -> 10)
    """
    def __init__(self, num_classes=10):
        super(SimpleCNN, self).__init__()
        
        # 特征提取部分 (Feature Extractor)
        self.features = nn.Sequential(
            # Block 1: 32x32x3 -> 16x16x32
            nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
            
            # Block 2: 16x16x32 -> 8x8x64
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            
            # Block 3: 8x8x64 -> 4x4x128
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
        )
        
        # 分类器部分 (Classifier)
        self.classifier = nn.Sequential(
            nn.Flatten(),                    # 展平: 128*4*4 = 2048
            nn.Linear(128 * 4 * 4, 256),
            nn.Dropout(0.5),                 # 防止过拟合
            nn.ReLU(inplace=True),
            nn.Linear(256, num_classes),
        )
    
    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

# 创建模型,放到 GPU (如果有的话)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleCNN(num_classes=10).to(device)
print(f'模型参数量: {sum(p.numel() for p in model.parameters()):,}')
print(f'使用设备: {device}')

Step 3: 训练

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)

# 训练循环
num_epochs = 50
for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    
    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)
        
        # 前向传播
        outputs = model(images)
        loss = criterion(outputs, labels)
        
        # 反向传播和优化
        optimizer.zero_grad()   # 清零梯度
        loss.backward()         # 计算梯度
        optimizer.step()        # 更新参数
        
        # 统计
        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()
    
    scheduler.step()
    train_acc = 100. * correct / total
    
    # 每 10 个 epoch 打印一次
    if (epoch + 1) % 10 == 0:
        print(f'Epoch [{epoch+1}/{num_epochs}], '
              f'Loss: {running_loss/len(trainloader):.4f}, '
              f'Train Acc: {train_acc:.2f}%')

Step 4: 测试评估

# 测试
model.eval()
correct = 0
total = 0

with torch.no_grad():  # 测试时不需要计算梯度
    for images, labels in testloader:
        images, labels = images.to(device), labels.to(device)
        outputs = model(images)
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

print(f'测试集准确率: {100. * correct / total:.2f}%')
# 这个简单的模型在 CIFAR-10 上通常能达到 85-90% 的准确率

CNN 的感受野计算

理解感受野 (Receptive Field) 对设计网络很重要。感受野指的是输出 feature map 上某个位置对应的输入图像区域大小。简单理解:第 N 层的一个神经元能「看到」原始图像中多大的区域

# 感受野计算公式 (简化版,假设 stride=1)
# RF_l = RF_{l-1} + (kernel_size - 1) * stride_product_{l-1}

# 以我们的 SimpleCNN 为例:
# Layer              kernel   stride   Receptive Field
# Conv1 (3x3)        3        1        3
# MaxPool1 (2x2)     2        2        5
# Conv2 (3x3)        3        1        9
# MaxPool2 (2x2)     2        2        13
# Conv3 (3x3)        3        1        21
# MaxPool3 (2x2)     2        2        25

# 也就是说,输出层的一个神经元可以看到输入图像中 25x25 的区域
# 对于 32x32 的 CIFAR-10 图片,这已经覆盖了大部分区域

使用预训练模型 (Transfer Learning)

在实际项目中,我们很少从头训练 CNN。更常见的做法是用迁移学习 (Transfer Learning):加载在 ImageNet 上预训练好的模型,然后 fine-tune 到自己的任务上。PyTorch 的 torchvision.models 提供了大量预训练模型。

import torchvision.models as models

# 加载预训练的 ResNet-18
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

# 冻结所有层的参数
for param in model.parameters():
    param.requires_grad = False

# 只替换最后的全连接层(适配你的任务)
num_classes = 10  # 比如 CIFAR-10 有 10 个类别
model.fc = nn.Linear(model.fc.in_features, num_classes)

# 只有新的 fc 层会被训练
print(f'可训练参数: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}')

# 用更小的学习率 fine-tune
optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)

# 如果想 fine-tune 整个网络(推荐在训练几轮后再解冻):
# for param in model.parameters():
#     param.requires_grad = True
# optimizer = optim.Adam(model.parameters(), lr=1e-5)  # 更小的学习率

一些实用技巧

  • Data Augmentation 很重要:RandomCrop、RandomHorizontalFlip、ColorJitter、Cutout、Mixup 等,能显著提升泛化能力
  • Batch Normalization 是标配:加速收敛、允许更大学习率、有轻微正则化效果
  • 用 AdamW 替代 Adam:weight decay 的实现更正确,配合 cosine annealing 效果更好
  • Label Smoothing:把 hard label 变成 soft label,CrossEntropyLoss(label_smoothing=0.1),防止模型过度自信
  • 混合精度训练 (AMP):用 torch.cuda.amp 可以加速训练且几乎不影响精度

CNN vs Transformer

最后简单对比一下 CNN 和 Vision Transformer:

  • 归纳偏置 (Inductive Bias):CNN 有局部性和平移等变性的先验,ViT 几乎没有,需要大量数据才能学到
  • 计算效率:在小数据集上 CNN 通常更快更好;大数据集上 ViT 有优势
  • 可解释性:CNN 的卷积核可以可视化,直觉上更容易理解
  • 趋势:现在很多工作在融合两者优势,比如 ConvNeXt(用 CNN 的架构模仿 ViT 的设计),或者在 ViT 中加入卷积操作

总结

这篇文章从直觉出发,聊了 CNN 的核心组件:卷积层、池化层、全连接层,回顾了经典架构的演进(LeNet → AlexNet → VGG → ResNet),然后用 PyTorch 实现了一个完整的 CNN 图像分类器。

几个关键 takeaways:

  • CNN 通过局部连接 + 参数共享大幅减少参数量,同时保留空间信息
  • 现代 CNN 的标配是 Conv + BN + ReLU 的组合
  • 实际项目中优先考虑迁移学习,用预训练模型 fine-tune
  • CNN 的思想(局部特征提取、层次化表示)在深度学习中无处不在

下一篇可能会写一下目标检测 (Object Detection) 或者语义分割 (Semantic Segmentation) 的内容,毕竟 CNN 在 CV 领域的应用实在太广了。Stay tuned 🚀

参考资料:

版权声明:除特殊说明,博客文章均为原创,依据CC BY-SA 4.0许可证进行授权,转载请附上出处链接及本声明。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇