回顾CNN,RNN,Transformer(1)——CNN

2026-04-08

0 前言

最近我又突发奇想,想要对过往学过的一些基础知识做回顾与梳理。

此处过往知识的定义很宽泛,不管是一些对计算机学生都很重要的专业课(比如数据结构与算法、语言基础甚至一些数学基础课),还是说一些深度学习相关的基础内容(比如Transformer架构,CNN架构什么的)。在前者上更像是“代码随想录学习笔记”,后者则是一些基础理论上的温故而知新,对第一次学习相关知识的一些数学推导做更深入的理解而不是单纯的概念介绍/名词解释。

作为本系列的第一篇文章,我还是希望从最近更加熟悉的深度学习说起,那么深度学习系列的开头用CNN,RNN与Transformer也是相当合理。

1 有关CNN的基础概念

卷积神经网络(Convolutional Neural Networks)在某种意义上是多层感知机的变种。一般认为CNN最早用Yann LeCun提出用于识别手写数字问题。CNN按照顺序由以下几个组件构成

  • 数据输入层

  • 卷积层

  • 激活函数

  • 池化层

  • 全连接层

  • 输出层

其主要特点为局部连接(减少连接数和参数量)和权值共享(减少参数量)。下面用一张图片说明局部连接。从这张图我们可以很明显的看到,相比较于上面的全连接层,卷积层中的“运算单元”并不会一股脑的接受所有的上游数据进行计算,只会选择性的选取部分内容

局部连接

2 CNN的内部各层

2.1 卷积层

作为CNN中的核心运算方式,我们还需要简单介绍卷积计算,在这里我们借用deepseek对卷积计算的介绍:

卷积本质上是一种“滑动加权求和”运算:让一个小的权重模板(卷积核/滤波器)在输入数据上按一定步长滑动,每次将模板覆盖区域的数值与对应权重相乘后累加,得到输出特征图上的一个点

下面让我们以二维卷积为例,详细的展示卷积计算的过程。可以看到,卷积的计算就是从左到右,从上到下的用卷积核与目标进行对位相乘并相加。

卷积运算

在python代码上,不依赖任何外部库的实现如下所示

def convolve2d(input_matrix, kernel, stride=1, padding=0):
    """
    对二维矩阵进行卷积运算(互相关,不翻转核)。

    参数:
        input_matrix : list[list[float]]  输入矩阵 (H_in, W_in)
        kernel       : list[list[float]]  卷积核   (H_k,  W_k)
        stride       : int                滑动步长
        padding      : int                边缘零填充圈数

    返回:
        output : list[list[float]]  输出特征图 (H_out, W_out)
    """
    # 1. 获取尺寸
    H_in, W_in = len(input_matrix), len(input_matrix[0])
    H_k, W_k = len(kernel), len(kernel[0])

    # 2. 添加零填充(若 padding > 0)
    if padding > 0:
        # 创建填充后的新矩阵
        H_pad = H_in + 2 * padding
        W_pad = W_in + 2 * padding
        padded = [[0.0] * W_pad for _ in range(H_pad)]
        for i in range(H_in):
            for j in range(W_in):
                padded[i + padding][j + padding] = input_matrix[i][j]
    else:
        padded = input_matrix
        H_pad, W_pad = H_in, W_in

    # 3. 计算输出尺寸
    H_out = (H_pad - H_k) // stride + 1
    W_out = (W_pad - W_k) // stride + 1

    # 4. 初始化输出矩阵
    output = [[0.0] * W_out for _ in range(H_out)]

    # 5. 滑动窗口进行卷积运算
    for i in range(0, H_out):
        for j in range(0, W_out):
            # 当前窗口左上角在 padded 中的位置
            start_i = i * stride
            start_j = j * stride

            # 对窗口内元素加权求和
            s = 0.0
            for ki in range(H_k):
                for kj in range(W_k):
                    s += padded[start_i + ki][start_j + kj] * kernel[ki][kj]
            output[i][j] = s

    return output

2.2 激活层

激活层负责对卷积层抽取的特征进行激活。“激活”的重点在于需要对卷积层的输出结果进行非线性映射,使得输出的特征图具有非线性关系。

在这里我们首先需要重点强调一下非线性这一特征。在定性衡量的维度上,激活层的作用在于接收神经元加权求和的结果,根据预设规则决定该神经元是否激活、以何种强度传递信号,从而使多层网络具备拟合任意复杂函数的能力。

如果没有激活函数,或者说激活函数是一个完全线性的会如何呢?假设一个两层全连接网络,第一层输出 $\bold{h}=\bold{W_1}​x+\bold{b_1​}$,第二层输出$\bold{h}=\bold{W_2}​x+\bold{b_2}$​。若中间不加激活函数(或使用恒等函数 f(z)=z),则整体输出为

\[\mathbf{y}=W_{2}\left(W_{1}\mathbf{x}+\mathbf{b}_{1}\right)+\mathbf{b}_{2}=\left(W_{2} W_{1}\right)\mathbf{x}+\left(W_{2}\mathbf{b}_{1}+\mathbf{b}_{2}\right)\]

可以看到,这种变换完全等价于单层线性变换,无论堆叠多少层,模型最终还是线性的。

另外需要注意的是,我们在这里提到的线性是数学意义上精准的线性,即必须满足可加性和齐次性的线性。不能望文生义的觉得“一次函数“就是线性函数

常见的激活函数有如下几种

激活函数

2.3 池化层

池化是一种下采样压缩操作:在输入特征图上按固定窗口滑动,对每个窗口内的元素进行统计聚合(如取最大值或平均值),从而降低数据空间尺寸、减少计算量并增强局部特征的平移不变性。常见的池化类型有最大池化(取范围内最大值)和平均池化(取范围内平均值)。下面依旧是一个示例。从示例我们可以看到,处理之后得到的新矩阵大小明显更小,同时不同区域之间的数据大小关系被以一种更加“极端”的方式呈现。

池化

2.4 全连接层

全链接层:将提取的特征映射转化为网络的最终输出。这可以是一个分类标签、回归值或其他任务的结果

3 代码示例

下面是一份示例,展示在pytorch框架下使用CNN完成对MNIST手写数字数据集的训练。

以下使用 PyTorch 框架编写一个完整的卷积神经网络(CNN)用于 MNIST 手写数字识别。代码中包含详细注释,解释每个步骤的作用以及第三方库的用法。假设 MNIST 数据集已按默认方式下载至本地(若未下载,代码会自动下载至 ./data 目录)。


3.1 环境准备与导入库

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

# 设置设备(优先使用 GPU)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'使用设备: {device}')

库说明

模块/类 作用
torch PyTorch 核心库,提供张量运算
torch.nn 神经网络层、损失函数等构建模块
torch.optim 优化算法(SGD、Adam 等)
torch.nn.functional 常用函数(如激活函数、池化,但这里用层封装)
DataLoader 批量加载数据,支持打乱、多线程
torchvision.datasets 内置常用数据集(MNIST、CIFAR10 等)
torchvision.transforms 数据预处理与增强

3.2 数据加载与预处理

# 定义数据预处理流程:转为张量并归一化到 [0,1]
transform = transforms.Compose([
    transforms.ToTensor(),                     # 将 PIL Image (0-255) 转为 FloatTensor (0-1)
    transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值与标准差,使数据零中心化
])

# 加载训练集与测试集(若本地无数据则自动下载)
train_dataset = datasets.MNIST(
    root='./data',          # 数据存放路径
    train=True,             # 训练集
    download=True,          # 若 root 下无数据则下载
    transform=transform     # 应用预处理
)

test_dataset = datasets.MNIST(
    root='./data',
    train=False,            # 测试集
    download=True,
    transform=transform
)

# 创建数据加载器
batch_size = 64
train_loader = DataLoader(
    train_dataset,
    batch_size=batch_size,
    shuffle=True,           # 每个 epoch 打乱顺序
    num_workers=2           # 多线程加载(Windows 下建议设为 0)
)

test_loader = DataLoader(
    test_dataset,
    batch_size=batch_size,
    shuffle=False,
    num_workers=2
)

说明

  • transforms.Compose:将多个预处理步骤组合成一个流水线。
  • ToTensor():将 PIL 图像或 numpy 数组转为 PyTorch 张量,并自动将像素值从 0~255 缩放到 0.0~1.0。
  • Normalize(mean, std):执行标准化 (x - mean) / std,加速模型收敛。MNIST 的经验均值和标准差为 0.1307 和 0.3081。
  • DataLoader:生成可迭代的批量数据,支持多线程预读取。

3.3 定义 CNN 模型结构

class MNIST_CNN(nn.Module):
    """
    一个简单的卷积神经网络,包含两个卷积层和两个全连接层。
    输入尺寸: (1, 28, 28)  # 单通道灰度图
    输出: 10 个类别的 logits
    """
    def __init__(self):
        super(MNIST_CNN, self).__init__()
        # 卷积层 1: 输入 1 通道,输出 32 通道,卷积核 3x3,步长 1,填充 1(保持尺寸)
        self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, stride=1, padding=1)
        # 卷积层 2: 输入 32 通道,输出 64 通道
        self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, stride=1, padding=1)
        # 最大池化层: 2x2 窗口,步长 2(尺寸减半)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        # 全连接层 1: 将卷积特征图展平后输入,尺寸计算见下文
        self.fc1 = nn.Linear(64 * 7 * 7, 128)  # 28 -> 14 -> 7(两次池化后尺寸为 7x7)
        # 全连接层 2: 输出 10 个类别分数
        self.fc2 = nn.Linear(128, 10)
        # Dropout 层,用于防止过拟合
        self.dropout = nn.Dropout(0.5)

    def forward(self, x):
        # 输入 x 形状: (batch, 1, 28, 28)
        x = self.pool(F.relu(self.conv1(x)))   # 卷积1 + ReLU + 池化 -> (batch, 32, 14, 14)
        x = self.pool(F.relu(self.conv2(x)))   # 卷积2 + ReLU + 池化 -> (batch, 64, 7, 7)
        x = x.view(x.size(0), -1)              # 展平 -> (batch, 64*7*7)
        x = self.dropout(F.relu(self.fc1(x)))  # 全连接1 + ReLU + Dropout
        x = self.fc2(x)                        # 输出层 (不使用 softmax,因为 CrossEntropyLoss 自带)
        return x

关键组件详解

参数说明
nn.Conv2d in_channels: 输入通道数;out_channels: 输出通道数(即卷积核数量);kernel_size: 卷积核大小;stride: 步长;padding: 零填充圈数(padding=1 在 3×3 核下可保持输入输出尺寸一致)。
nn.MaxPool2d 下采样,kernel_size=2, stride=2 将特征图长宽减半。
nn.Linear 全连接层,输入输出均为特征维度。
F.relu ReLU 激活函数(也可使用 nn.ReLU() 作为层)。
x.view(x.size(0), -1) 将多维张量展平为二维,第一维为 batch 大小,第二维为所有特征拼接。
nn.Dropout 训练时随机丢弃部分神经元,防止过拟合。

模型参数计算

  • 输入图像:28×28
  • conv1(padding=1)后尺寸:28×28
  • pool 后尺寸:14×14
  • conv2(padding=1)后尺寸:14×14
  • pool 后尺寸:7×7
  • 通道数:64
  • 全连接输入维度:64 × 7 × 7 = 3136

3.4 实例化模型、损失函数与优化器

model = MNIST_CNN().to(device)          # 将模型移至 GPU/CPU
criterion = nn.CrossEntropyLoss()       # 交叉熵损失(内部自动做 softmax)
optimizer = optim.Adam(model.parameters(), lr=0.001)  # Adam 优化器

补充说明

  • model.to(device):将模型参数和缓冲区移动到指定设备。
  • nn.CrossEntropyLoss:结合了 LogSoftmaxNLLLoss,适用于多分类任务,要求输入为未经过 softmax 的 logits,目标为类别索引(0~9)。
  • optim.Adam:自适应学习率优化算法,收敛快且对超参数不敏感。

3.5 训练与测试函数

训练一个 epoch

def train(model, device, train_loader, optimizer, criterion, epoch):
    model.train()  # 设置为训练模式(启用 Dropout、BatchNorm 等)
    running_loss = 0.0
    correct = 0
    total = 0

    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)

        optimizer.zero_grad()          # 清零梯度
        output = model(data)           # 前向传播
        loss = criterion(output, target)  # 计算损失
        loss.backward()                # 反向传播
        optimizer.step()               # 更新参数

        running_loss += loss.item()
        _, predicted = output.max(1)   # 取 logits 最大值索引为预测类别
        total += target.size(0)
        correct += predicted.eq(target).sum().item()

        if batch_idx % 100 == 0:
            print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} '
                  f'({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')

    train_loss = running_loss / len(train_loader)
    train_acc = 100. * correct / total
    print(f'====> Epoch {epoch} 训练集平均损失: {train_loss:.4f}, 准确率: {train_acc:.2f}%')
    return train_loss, train_acc

测试函数

def test(model, device, test_loader, criterion):
    model.eval()  # 设置为评估模式(关闭 Dropout)
    test_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():  # 测试时不计算梯度,节省内存和计算
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            _, predicted = output.max(1)
            total += target.size(0)
            correct += predicted.eq(target).sum().item()

    test_loss /= len(test_loader)
    test_acc = 100. * correct / total
    print(f'测试集平均损失: {test_loss:.4f}, 准确率: {test_acc:.2f}%\n')
    return test_loss, test_acc

3.6 运行训练与测试

epochs = 5
train_losses, train_accs = [], []
test_losses, test_accs = [], []

for epoch in range(1, epochs + 1):
    train_loss, train_acc = train(model, device, train_loader, optimizer, criterion, epoch)
    test_loss, test_acc = test(model, device, test_loader, criterion)

    train_losses.append(train_loss)
    train_accs.append(train_acc)
    test_losses.append(test_loss)
    test_accs.append(test_acc)

训练日志示例

Train Epoch: 1 [0/60000 (0%)]    Loss: 2.306486
...
====> Epoch 1 训练集平均损失: 0.1924, 准确率: 94.27%
测试集平均损失: 0.0583, 准确率: 98.21%
...

3.7 可视化训练曲线(可选)

plt.figure(figsize=(12,4))
plt.subplot(1,2,1)
plt.plot(range(1, epochs+1), train_losses, label='Train Loss')
plt.plot(range(1, epochs+1), test_losses, label='Test Loss')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.title('Loss Curve')

plt.subplot(1,2,2)
plt.plot(range(1, epochs+1), train_accs, label='Train Acc')
plt.plot(range(1, epochs+1), test_accs, label='Test Acc')
plt.xlabel('Epoch')
plt.ylabel('Accuracy (%)')
plt.legend()
plt.title('Accuracy Curve')
plt.show()

3.8 模型保存与加载

# 保存模型参数(推荐方式)
torch.save(model.state_dict(), 'mnist_cnn.pth')

# 加载模型参数(用于预测)
# model = MNIST_CNN().to(device)
# model.load_state_dict(torch.load('mnist_cnn.pth'))
# model.eval()

说明

  • state_dict() 保存的是模型的可学习参数字典,不包含模型结构,因此加载时需先实例化相同的模型类。
  • 若需保存整个模型(含结构),可用 torch.save(model, 'model.pth'),但跨环境或代码变更时兼容性较差。

3.9 完整代码整合与运行注意事项

  1. 安装依赖

    pip install torch torchvision matplotlib
    
  2. 确保数据集存在:首次运行会自动下载 MNIST 数据集到 ./data 目录(约 100 MB)。

  3. GPU 支持:若有 NVIDIA GPU 且 CUDA 已配置,代码会自动使用 GPU 加速。

  4. 可能的问题

    • Windows 下 num_workers>0 导致错误:将 DataLoadernum_workers 改为 0。
    • 显存不足:减小 batch_size 或模型通道数。