目 录CONTENT

文章目录
AI

卷积神经网络(CNN)——从全连接到卷积,让网络“看见”局部特征

TalentQ
2026-06-03 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

1. 整体流程概览

在逐行写代码之前,我们先从宏观上理解一个 CNN 实验的完整生命周期。整个实验可以划分为 6 个逻辑阶段

阶段

核心任务

为什么必须有这个阶段?

1. 数据准备

下载 CIFAR-10 数据集(32×32 彩色图),做数据增强(随机翻转、裁剪)

CIFAR-10 比 MNIST 更接近真实图像(彩色、复杂背景),数据增强能防止模型“死记硬背”,提高泛化能力

2. 模型定义

搭建卷积神经网络(卷积层 + 池化层 + 全连接层)

卷积层提取局部特征(边缘、纹理),池化层降低分辨率,全连接层做最终分类

3. 损失与优化器

交叉熵损失 + Adam 优化器 + 学习率调度器

与 MLP 相同,因为仍然是多分类任务。学习率调度器让训练后期更精细地收敛

4. 训练循环

前向传播 → 计算损失 → 反向传播 → 更新参数

与 MLP 完全相同,只是模型内部结构变了

5. 测试评估

在测试集上计算准确率

评估模型在未见过的图像上的表现

6. 可视化

将卷积核和中间层的特征图画出来

理解 CNN 内部到底学到了什么 —— 这是 CNN 独有的可解释性优势

2. CIFAR-10 数据集:从手写数字到真实物体

2.1 数据集基本信息

属性

内容

全称

Canadian Institute For Advanced Research - 10 classes

发布年份

2009 年(由 Alex Krizhevsky、Vinod Nair、Geoffrey Hinton 整理)

样本总数

60,000 张彩色图像

训练集

50,000 张

测试集

10,000 张

图像尺寸

32 × 32 像素

颜色通道

3 通道(RGB 红绿蓝),每通道值范围 0~255

类别数量

10

类别列表

飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车

2.2 为什么选择 CIFAR-10 而不是继续用 MNIST?

对比维度

MNIST

CIFAR-10

图像类型

灰度图

彩色图(RGB)

背景

纯黑,数字居中

复杂背景,物体位置变化

类内差异

较小(不同人写的“2”差异有限)

很大(不同角度的猫、不同品种的狗)

MLP 最佳准确率

~98%

~55%(几乎不可用)

CNN 最佳准确率

~99.5%

~85% ~ 90%

结论:MNIST 在 CNN 上太简单,无法体现 CNN 相对于 MLP 的真正优势。CIFAR-10 的难度适中,能让你直观感受到 CNN 的威力。

2.3 数据增强的必要性

CIFAR-10 训练集只有 5 万张图,而模型参数量可能超过 10 万,容易过拟合。数据增强通过随机变换生成更多样的训练样本:

transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),   # 随机裁剪,相当于平移
    transforms.RandomHorizontalFlip(),      # 随机水平翻转
    transforms.ToTensor(),
    transforms.Normalize(mean, std)
])
  • 随机裁剪:从 32×32 中随机裁出 32×32(带 4 像素填充),相当于让模型看到物体的不同位置。

  • 随机翻转:猫左右翻转后仍然是猫,增加了样本多样性。

2.4 归一化:为什么需要 mean 和 std?

CIFAR-10 的三个颜色通道(RGB)有各自的统计分布。通过预先计算的全局均值和标准差,我们将每个通道的像素值标准化到均值为 0、标准差为 1 的范围。这样做的好处是:

  • 不同通道的数值范围保持一致,优化器不会在“峡谷”里来回震荡

  • 训练更稳定,收敛更快

mean = [0.4914, 0.4822, 0.4465]   # 三个通道的均值
std  = [0.2470, 0.2435, 0.2616]   # 三个通道的标准差

3. CNN 核心概念精讲

在写代码之前,必须理解 CNN 的三个核心思想。

3.1 局部连接(Local Connectivity)

对比

全连接网络(MLP)

卷积神经网络(CNN)

连接方式

每个神经元连接上一层的所有神经元

每个神经元只连接上一层的局部区域(如 3×3)

参数量(以 32×32×3 输入为例)

下一层 100 个神经元 → 32×32×3×100 ≈ 307,200

卷积核 3×3×3×100 ≈ 2,700

直观理解

每个输出像素考虑整张图的全局信息

每个输出像素只考虑周围一小块区域

为什么局部连接有效? 图像中的局部像素相关性很强(相邻像素颜色相近),而距离远的像素关系较弱。先提取局部特征(边缘、角点),再逐步组合成全局特征(眼睛、鼻子 → 脸),这是生物视觉系统的启发。

3.2 权重共享(Weight Sharing)

在全连接网络中,每个输入位置都有独立的权重。在卷积网络中,同一个卷积核滑动到图像的不同位置时,权重完全相同

  • 例子:一个 3×3 的边缘检测卷积核(如 Sobel 算子),无论它出现在图像的左上角还是右下角,它检测的都是“垂直边缘”。如果每个位置学不同的权重,模型需要重复学习相同的模式,浪费参数。

3.3 池化(Pooling)的空间不变性

池化层对每个局部区域取最大值(MaxPooling)或平均值(AvgPooling),然后缩小特征图的尺寸。

为什么需要池化?

  • 降维:将 32×32 的特征图缩小到 16×16,减少后续层的计算量。

  • 平移不变性:猫的耳朵移动了 2 个像素,最大值池化仍然能检测到(因为局部最大值不变)。

  • 防止过拟合:减少参数数量,降低模型对精确位置的依赖。

4. 完整代码实现(带超详细注释)

以下代码是一个完整的 CNN 训练脚本,可以直接在你的 Ubuntu 环境中运行。每行都有详细的注释,帮助你理解每一步在做什么、为什么这样做。

"""
卷积神经网络(CNN)- CIFAR-10 图像分类
=========================================
【任务】训练一个 CNN 模型,识别 32×32 的小图片属于 10 个类别中的哪一类。

【环境】
  - 系统:Ubuntu (WSL2)
  - 框架:PyTorch 2.5.1(Meta 开源的深度学习框架)
  - 语言:Python 3.11
  - 硬件:NVIDIA GPU + CUDA 12.x
    - CUDA 是 NVIDIA 的并行计算平台,让 GPU 不仅能画图,还能做通用计算
    - PyTorch 通过 CUDA 把矩阵运算卸载到 GPU 上,比 CPU 快 10-100 倍

【CIFAR-10 数据集】
  - 60000 张 32×32 的彩色小图片(RGB 3 通道)
  - 10 个类别:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车
  - 50000 张训练集 + 10000 张测试集
  - 每张图只有 32×32 像素 —— 非常小,所以即使是简单模型也能跑

【CNN 的核心思想】
  传统全连接网络处理图片时,会把图片拉成一维向量,完全丢失了空间结构。
  CNN 用"卷积核"在图片上滑动,专门捕捉局部特征(边缘、纹理、形状),
  这更符合人类视觉的工作原理 —— 你先看到局部特征,再组合成整体。

作者:TalentQ
日期:2026-06-02
"""

# ============================================================================
# 0. 导入库 —— 每个库的作用
# ============================================================================

import torch                         # PyTorch 核心库,提供张量运算和自动求导
import torch.nn as nn                # 神经网络模块,包含各种层的定义(Conv2d, Linear 等)
import torch.nn.functional as F      # 函数式接口,包含激活函数(relu)、池化等无参数操作
import torch.optim as optim          # 优化器模块,包含 Adam、SGD 等参数更新算法
from torchvision import datasets, transforms  # torchvision:PyTorch 的视觉工具包
                                             # - datasets:内置数据集下载(CIFAR-10、MNIST 等)
                                             # - transforms:图像预处理/数据增强
from torch.utils.data import DataLoader       # DataLoader:批量加载、打乱、多线程加速
import time                                   # 计时

import matplotlib.pyplot as plt     # 画图库,用于可视化卷积核和特征图
import numpy as np                  # 数值计算库,这里主要用于把张量转成 matplotlib 能画的格式


# ============================================================================
# 1. 检查设备 —— 有 GPU 就用 GPU,没有就降级用 CPU
# ============================================================================

# torch.cuda.is_available() 检查是否有可用的 NVIDIA GPU
# 三元表达式:如果 GPU 可用 → "cuda",否则 → "cpu"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")

if device.type == "cuda":
    print(f"GPU 名称: {torch.cuda.get_device_name(0)}")

    # 【关键配置】开启 cuDNN 自动调优
    # cuDNN 是 NVIDIA 的深度神经网络加速库,PyTorch 底层调用它来算卷积。
    # 对于同一个卷积操作,cuDNN 有多种实现算法(不同内存布局、不同数学技巧)。
    # 默认情况下 cuDNN 会试探性地选一个;开启 benchmark=True 后,
    # 它会在第一次遇到某个输入尺寸时,把所有算法跑一遍,记下最快的,以后复用。
    # 代价:首次运行稍慢一点点。收益:后续所有卷积都跑在最优算法上,显著加速。
    torch.backends.cudnn.benchmark = True


# ============================================================================
# 2. 数据准备 —— 把原始图片变成模型能吃的"张量"
# ============================================================================

# --- 2.1 均值和标准差(用于归一化) ---
# 这 6 个数字是 CIFAR-10 整个训练集的统计值,不是随便写的。
# 每个数字对应 RGB 三个通道:mean=[R均值, G均值, B均值],std=[R标准差, G标准差, B标准差]
# 归一化的公式:(像素值 - mean) / std
# 目的:让每个通道的数据变成均值=0、标准差=1 的分布。这能让模型训练更稳定、收敛更快。
# 如果不归一化,不同通道的数值范围可能差异很大,优化器会在"峡谷"里来回震荡。
mean = [0.4914, 0.4822, 0.4465]   # 三个通道的均值
std  = [0.2470, 0.2435, 0.2616]   # 三个通道的标准差

# --- 2.2 训练集的数据预处理(含数据增强) ---
# transforms.Compose 就像一个"流水线",把多个变换串起来,依次执行。
# 【为什么要数据增强?】训练集只有 50000 张图,模型很容易"死记硬背"(过拟合)。
# 数据增强在每次取图片时随机做微调,让模型每轮看到的都是"略不同"的图片,强制它学习真正的特征。
transform_train = transforms.Compose([
    # 数据增强 1:随机裁剪 —— 先把图片四周各补 4 个像素(变成 40×40),再随机切回 32×32
    # 作用:模拟"镜头稍微偏了一点"的效果,让模型对位置不敏感
    transforms.RandomCrop(32, padding=4),

    # 数据增强 2:随机水平翻转 —— 50% 概率左右翻转
    # 作用:飞机头朝左还是朝右都应该是"飞机",让模型学到镜像不变性
    transforms.RandomHorizontalFlip(),

    # 步骤 1:转成张量 —— 把 PIL 图片(0-255 的整数)变成 torch.Tensor(0.0-1.0 的浮点数)
    # 这一步还会把图片的 shape 从 H×W×C(高×宽×通道)变成 C×H×W,这是 PyTorch 的标准格式
    transforms.ToTensor(),

    # 步骤 2:归一化 —— 利用上面的 mean 和 std,让数据变成标准正态分布
    transforms.Normalize(mean, std)
])

# --- 2.3 测试集的预处理(不做数据增强) ---
# 测试集必须保持原样,不然测评结果不客观。
# 想象一下:考试时你不能改题目,但平时练习可以变着花样练。
transform_test = transforms.Compose([
    transforms.ToTensor(),            # 同上:PIL 图片 → 张量
    transforms.Normalize(mean, std)   # 必须和训练集用同一组 mean/std
])

# --- 2.4 下载并加载数据集 ---
# datasets.CIFAR10 是 torchvision 内置的 CIFAR-10 下载器
# 参数:
#   './data'      - 数据存放目录(不存在会自动创建)
#   train=True    - True=训练集(50000张), False=测试集(10000张)
#   download=True - 本地没有就自动从官网下载(约 170MB)
#   transform=    - 对每张图片执行的预处理流水线
train_dataset = datasets.CIFAR10('./data', train=True,  download=True, transform=transform_train)
test_dataset  = datasets.CIFAR10('./data', train=False, download=True, transform=transform_test)

# --- 2.5 用 DataLoader 把数据集包装成"批量投喂器" ---
# 模型不会一次只吃一张图(效率太低),而是每次吃一个"batch"(例如 512 张)。
# DataLoader 负责:批量组装 + 随机打乱 + 多线程并行加载。
batch_size = 512  # 每次训练喂 512 张图给模型
                   # batch_size 越大:GPU 利用率越高(一次算的多),但显存占用也越大
                   # batch_size 越小:训练更"吵"(每次梯度估计不准确),但能跑在显存小的卡上
                   # 512 对于 CIFAR-10 是个偏大的值,目的就是喂饱 GPU

train_loader = DataLoader(
    train_dataset,
    batch_size=batch_size,
    shuffle=True,       # 每轮(epoch)训练前打乱顺序 —— 避免模型记住"输入顺序"
    num_workers=4,      # 开 4 个子进程来读数据 —— 让 CPU 读数据时 GPU 不闲着
    pin_memory=True      # 把数据"钉"在内存中,加速 CPU→GPU 的传输(锁页内存)
)

test_loader = DataLoader(
    test_dataset,
    batch_size=batch_size,
    shuffle=False,       # 测试时不需要打乱,评估结果和顺序无关
    num_workers=4,
    pin_memory=True
)

# CIFAR-10 的 10 个类别名称,从 0 到 9 依次对应
classes = ['airplane', 'automobile', 'bird', 'cat', 'deer',
           'dog', 'frog', 'horse', 'ship', 'truck']


# ============================================================================
# 3. 定义 CNN 模型 —— 这是整个程序的核心
# ============================================================================

# nn.Module 是所有神经网络模块的基类(父类),CNN 继承它
# 继承后你只需要:
#   1. __init__ 里定义"有哪些层"(建筑图纸)
#   2. forward  里定义"数据怎么流经这些层"(施工顺序)
# PyTorch 会自动帮你处理反向传播 —— 不需要手动算梯度!
class CNN(nn.Module):
    def __init__(self):
        # super().__init__() 调用父类的初始化,这一步必须写,否则会报错
        super(CNN, self).__init__()

        # --- 卷积层 1:从原始图片提取低级特征 ---
        # nn.Conv2d(输入通道, 输出通道, kernel_size=卷积核大小)
        #   - 输入通道=3:RGB 彩色图,红绿蓝 3 个通道
        #   - 输出通道=32:用 32 个不同的卷积核,每个学一种"低级特征"
        #     (例如:一个学"水平边缘",一个学"竖直边缘",一个学"绿色色块"……)
        #   - kernel_size=3:3×3 的卷积核在图上滑动,每次看 3×3 的像素块
        # 可学习参数量 = 3×32×3×3 + 32(偏置) = 896 个
        self.conv1 = nn.Conv2d(3, 32, kernel_size=3)

        # --- 卷积层 2:从低级特征组合出高级特征 ---
        #   - 输入通道=32:上一层产出了 32 张"特征图"
        #   - 输出通道=64:用 64 个卷积核,学习更复杂的组合特征
        #     (例如:第 1 层看到边缘,第 2 层把边缘组合成"轮子"或"窗户"的形状)
        # 可学习参数量 = 32×64×3×3 + 64(偏置) ≈ 18,500 个
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3)

        # --- 池化层:缩小特征图的尺寸 ---
        # MaxPool2d(kernel_size=2, stride=2):在 2×2 的窗口里取最大值
        # 效果:长宽各缩小一半,面积变成 1/4。
        # 为什么要池化?
        #   1. 减少计算量 —— 后面的层要处理的数据少了
        #   2. 提供平移不变性 —— 特征稍微挪了点位置,最大值还是那个最大值
        #   3. 增大感受野 —— 同样的卷积核,在缩小的图上能看到更大的范围
        self.pool = nn.MaxPool2d(2, 2)   # 输入 2×2 窗口,步长 2,不重叠

        # --- Dropout:随机"关掉"神经元,防止过拟合 ---
        # p=0.25 表示每次训练迭代,随机把 25% 的神经元输出置为 0
        # 直觉:强迫每个神经元不能依赖"某个特定的队友",必须能独立做贡献
        # 这让模型更像一个"委员会"而非"独裁者"。
        # 注意:测试时会自动关闭 Dropout(model.eval() 生效),所有神经元都参与。
        self.dropout = nn.Dropout(0.25)

        # --- 全连接层 1:把卷积提取的特征汇总起来做分类 ---
        # 64 * 6 * 6 = 2304 是经过两次卷积+两次池化后的"展平"尺寸
        # 尺寸推导(CIFAR-10 输入为 32×32):
        #   输入:3 × 32 × 32
        #   conv1(3×3, 没有 padding 所以边缘收缩) → 32 × 30 × 30
        #   pool(2×2)                              → 32 × 15 × 15
        #   conv2(3×3)                             → 64 × 13 × 13
        #   pool(2×2)                              → 64 × 6 × 6   ← 注意这里 13/2=6.5 向下取整=6
        # 所以最后是 64 个通道,每张特征图 6×6
        self.fc1 = nn.Linear(64 * 6 * 6, 512)  # 2304 个输入 → 512 个隐藏神经元

        # --- 全连接层 2:输出层 ---
        # 输出 10 个数字,代表 10 个类别的"原始得分"(也叫 logits)。
        # 得分最高的那个就是模型预测的类别。
        # 注意:这里没有 softmax,因为 CrossEntropyLoss 内部会自动做 softmax。
        self.fc2 = nn.Linear(512, 10)

    def forward(self, x):
        """
        前向传播:定义数据从输入到输出的完整路径。

        参数 x 的形状:[batch_size, 3, 32, 32]
           - batch_size = 当前批次的图片数量(比如 512)
           - 3  = RGB 三通道
           - 32, 32 = 图片的高和宽

        返回形状:[batch_size, 10] —— 每张图对应 10 个类别的得分
        """

        # 第 1 个卷积块:conv1 → ReLU 激活 → 池化
        # 写法解读:从里往外读
        #   self.conv1(x)    - 对输入 x 做卷积
        #   F.relu(...)      - 对卷积结果逐元素做 ReLU:max(0, 值)。负值变 0,正值不变。
        #                      为什么需要激活函数?因为卷积是线性运算,多层线性叠起来还是线性的,
        #                      等于一层。插入非线性的 ReLU 后,网络才能学复杂的非线性规律。
        #   self.pool(...)   - 最大池化,缩小尺寸
        x = self.pool(F.relu(self.conv1(x)))   # 输出:[batch, 32, 15, 15]

        # 第 2 个卷积块:conv2 → ReLU → 池化
        x = self.pool(F.relu(self.conv2(x)))   # 输出:[batch, 64, 6, 6]

        # Dropout —— 训练时随机"关掉"部分神经元,测试时自动失效
        x = self.dropout(x)                    # 输出:[batch, 64, 6, 6]

        # 展平(Flatten):把 4D 张量变成 2D,好喂给全连接层
        # x.size(0) = batch_size(保留), -1 = 自动算剩下的维度(64*6*6 = 2304)
        # 这一步把 [batch, 64, 6, 6] 变成 [batch, 2304]
        x = x.view(x.size(0), -1)

        # 全连接层 1 → ReLU 激活 → Dropout
        x = F.relu(self.fc1(x))               # 输出:[batch, 512]
        x = self.dropout(x)                   # 再防一次过拟合

        # 全连接层 2(输出层)—— 不接激活函数,输出原始的 10 个得分
        x = self.fc2(x)                       # 输出:[batch, 10]

        return x


# 实例化模型并搬到 GPU(或 CPU)
model = CNN().to(device)
# model.to(device) 把模型的所有参数(权重、偏置)复制到 GPU 显存
# 之后所有计算都在 GPU 上执行
print(model)  # 打印模型结构——可以看到每一层的参数数量


# ============================================================================
# 4. 损失函数、优化器、学习率调度器 —— 训练的"三件套"
# ============================================================================

# --- 损失函数(Loss Function):衡量模型"错得有多离谱" ---
# CrossEntropyLoss = LogSoftmax + NLLLoss 的组合
# 输入:模型的原始得分(logits)和真实标签(0~9 的整数)
# 输出:一个标量,越大表示预测越离谱,越小越好
# 直觉:如果猫的图片被预测为"猫"的得分最高 → loss 小;
#       如果被预测为"狗"的得分最高 → loss 大,模型会被"惩罚"。
criterion = nn.CrossEntropyLoss()

# --- 优化器(Optimizer):根据 loss 调整模型参数,让 loss 越来越小 ---
# Adam 是目前最常用的优化器,结合了两种经典方法的优点:
#   - Momentum(动量):类似小球滚下山坡,累积速度,越过小坑
#   - RMSprop:对不同参数使用不同的学习率,平稳的维度走大步,陡峭的维度走小步
# lr=0.001(学习率)是最关键的参数:
#   - 太大:模型"跳"过最优解,loss 震荡不收敛
#   - 太小:训练太慢,可能卡在局部最优
# model.parameters() 告诉优化器"你需要调整哪些参数"
optimizer = optim.Adam(model.parameters(), lr=0.001)

# --- 学习率调度器(Scheduler):让学习率随着训练进程逐步减小 ---
# 直觉:训练初期模型"方向感差",需要大步快走(大步探索);
#       训练后期逼近最优,需要小步微调(小步精调),否则会"跨过"最优解。
# StepLR:每 step_size=10 个 epoch,学习率乘以 gamma=0.5(即减半)
#   epoch 1-10:  lr = 0.001
#   epoch 11-20: lr = 0.0005
#   epoch 21-30: lr = 0.00025……(如果继续训练)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)


# ============================================================================
# 5. 训练函数 —— 核心训练循环,每次调用跑一个 epoch
# ============================================================================

def train(epoch):
    """
    一个 epoch = 把整个训练集完整过一遍。
    每次 epoch,模型会遍历所有 50000 张训练图片一次。

    训练和测试的根本区别:
      训练:requires_grad=True,计算梯度 + 更新参数
      测试:torch.no_grad(),只算准确率,不更新参数
    """

    model.train()  # 【关键】切换到训练模式
    # model.train() 会开启两件事:
    #   1. Dropout 开始随机丢弃神经元
    #   2. BatchNorm(如果有)开始累积均值和方差
    # 忘记调用 model.train() 是一个常见 bug,会导致 Dropout 不工作。

    total_loss = 0  # 累积这个 epoch 所有 batch 的 loss,最后算平均值

    # enumerate(train_loader) 每次返回一批数据:
    #   batch_idx: 当前是第几批(0, 1, 2, ...)
    #   data:      [batch_size, 3, 32, 32] 图片张量
    #   target:    [batch_size] 每张图对应的标签(0~9 的整数)
    for batch_idx, (data, target) in enumerate(train_loader):

        # --- 把数据从 CPU 内存搬到 GPU 显存 ---
        # non_blocking=True:异步传输,CPU 不等待 GPU 完成就开始准备下一批数据
        # 这会显著减少 GPU 等待数据的时间,提升利用率
        data, target = data.to(device, non_blocking=True), target.to(device, non_blocking=True)

        # --- 清零梯度 ---
        # PyTorch 默认会累加梯度(为了支持 RNN 等需要梯度累积的场景),
        # 但标准训练中每个 batch 的梯度是独立的,所以必须先清零。
        # set_to_none=True:把梯度设为 None 而不是填 0,省一次显存写入操作。
        optimizer.zero_grad(set_to_none=True)

        # --- 前向传播:把图片喂给模型,得到预测得分 ---
        # 这一步走过整个 forward() 函数,PyTorch 会记录所有运算,
        # 构建一个"计算图",为后续的反向传播做准备。
        output = model(data)          # output shape: [batch_size, 10]

        # --- 计算损失:比较预测得分和真实标签 ---
        loss = criterion(output, target)   # loss 是一个标量张量,比如 tensor(1.524)

        # --- 反向传播:计算每个参数的梯度 ---
        # loss.backward() 沿着计算图反向走一遍,用链式法则自动算出
        # loss 对每个参数的偏导数(梯度)。
        # 这就是 PyTorch 的核心魔法 —— 自动求导(autograd)。
        # 你不需要手写任何导数公式!
        loss.backward()

        # --- 更新参数:沿着梯度的反方向走一小步 ---
        # optimizer.step() 根据刚才算出的梯度,按照 Adam 算法更新所有参数。
        # 参数的更新方向 = 让 loss 变小的方向。
        # 更新量 = 学习率 × 梯度(Adam 实际上更复杂,但直觉如此)
        optimizer.step()

        # loss.item():把 GPU 上的标量张量转成 Python 浮点数(用于统计)
        # 注意:这会触发一次 CPU-GPU 同步,所以只在统计时调用,不在训练循环中频繁调用
        total_loss += loss.item()

    # 这个 epoch 的平均 loss —— 越低越好
    avg_loss = total_loss / len(train_loader)
    print(f'Epoch {epoch} Average loss: {avg_loss:.4f}')


# ============================================================================
# 6. 测试函数 —— 评估模型在"没见过"的图片上的表现
# ============================================================================

def test():
    """
    在测试集(10000 张图)上评估模型。
    训练集准确率高但测试集准确率低 = 过拟合(模型在死记硬背,不会泛化)。
    训练集准确率高 + 测试集准确率也高 = 模型真正学到了规律。
    """

    model.eval()  # 【关键】切换到评估模式
    # model.eval() 会:
    #   1. 关闭 Dropout —— 所有神经元都参与,不再随机丢弃
    #   2. 冻结 BatchNorm 的统计量 —— 用训练时累积的均值/方差,而不是当前 batch 的
    # 忘记调用 model.eval() 会导致测试结果不稳定且偏低。

    test_loss = 0   # 累积测试损失
    correct = 0     # 累积预测正确的图片数

    # torch.no_grad():【关键】关闭梯度计算
    # 测试时不需要反向传播,关闭自动求导可以:
    #   1. 省显存 —— 不用保存计算图的中间结果
    #   2. 加速 —— 跳过梯度计算的开销
    # 如果不加这个,大模型测试时可能会爆显存。
    with torch.no_grad():
        for data, target in test_loader:
            # 数据搬到 GPU(同样异步传输)
            data, target = data.to(device, non_blocking=True), target.to(device, non_blocking=True)

            # 前向传播(不需要梯度)
            output = model(data)

            # 累加损失
            test_loss += criterion(output, target).item()

            # 找出每张图得分最高的那个类别
            # output.argmax(dim=1):沿着第 1 维(类别维度)取最大值的索引(即预测类别)
            # keepdim=True:保持维度,[batch_size] 变成 [batch_size, 1],方便后面比较
            pred = output.argmax(dim=1, keepdim=True)

            # target.view_as(pred):把 target 的形状变成和 pred 一样 [batch_size, 1]
            # pred.eq(...):逐元素比较,相等为 1,不等为 0
            # sum().item():统计这批里预测正确的总数
            correct += pred.eq(target.view_as(pred)).sum().item()

    # 统计整个测试集的结果
    test_loss /= len(test_loader)
    accuracy = 100. * correct / len(test_loader.dataset)
    print(f'Test set: Average loss: {test_loss:.4f}, '
          f'Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n')
    return accuracy


# ============================================================================
# 7. 可视化函数 —— 看看模型到底"学到"了什么
# ============================================================================

def visualize_filters(model, epoch=None):
    """
    可视化第一层卷积的 32 个卷积核。
    每个卷积核是一个 3×3 的"小窗口"(3 通道 × 3×3)。
    把它画出来,可以直观看到模型在"找"什么特征。

    如果卷积核看起来是:
      - 边缘检测器(明暗分明的条纹)→ 模型学到了有用的特征
      - 杂乱噪点                      → 模型还没学到东西(可能是训练刚开始)
      - 全是灰色                      → 梯度消失,模型没在学
    """

    # .weight.data:取出卷积层的权重(卷积核),不包含梯度信息
    # .cpu():把数据从 GPU 搬回 CPU(matplotlib 只能画 CPU 上的数据)
    # .numpy():PyTorch 张量 → NumPy 数组(matplotlib 接受的格式)
    filters = model.conv1.weight.data.cpu().numpy()  # shape: [32, 3, 3, 3]
    # 32=输出通道(卷积核数量),3=输入通道(RGB),3×3=卷积核大小

    # 把所有权重缩放到 [0, 1] 区间,方便画图
    # 这叫 Min-Max 归一化:(值 - 最小值) / (最大值 - 最小值)
    filters_min, filters_max = filters.min(), filters.max()
    filters = (filters - filters_min) / (filters_max - filters_min)

    # 只画前 16 个卷积核(4×4 的网格)
    fig, axes = plt.subplots(4, 4, figsize=(8, 8))
    for i, ax in enumerate(axes.flat):
        if i < 16:
            # np.transpose 把 [3, 3, 3](通道, 高, 宽)变成 [3, 3, 3]
            # 但这里 conv1 输入是 3 通道(RGB),所以 filter_img 还是 [3, 3, 3]
            # matplotlib 的 imshow 需要的彩色图格式是 (H, W, C),所以转置成 (3, 3, 3)
            # 但由于是 3×3 像素太小,会在图中被放大显示
            filter_img = np.transpose(filters[i], (1, 2, 0))
            ax.imshow(filter_img)
            ax.set_title(f'Filter {i+1}')
        ax.axis('off')  # 不显示坐标轴
    plt.tight_layout()

    # 保存图片
    filename = f'conv1_filters_epoch{epoch}.png' if epoch else 'conv1_filters.png'
    plt.savefig(filename, dpi=150)  # dpi=150 保证图片清晰度
    print(f"卷积核可视化已保存到 {filename}")
    plt.close()  # 关闭画布,释放内存(不关会吃内存)


def visualize_feature_maps(model, test_loader, num_images=4):
    """
    可视化卷积层输出的"特征图"。

    特征图 = 卷积核对图片扫描后的结果。
    - 亮的地方:模型觉得这里有"重要的特征"
    - 暗的地方:这块区域和这个卷积核"不相关"

    通过观察特征图,你可以直观理解模型在关注图片的哪些部分。
    """

    model.eval()

    # 从测试集取一批图片
    data_iter = iter(test_loader)    # iter() 把 DataLoader 变成迭代器
    images, labels = next(data_iter) # next() 取下一批
    images = images[:num_images].to(device)  # 只取前 num_images 张

    # --- 使用 Hook 截获中间层的输出 ---
    # PyTorch 的 Hook 机制可以在前向传播时"拦截"某一层的输出。
    # 默认情况下 forward 只返回最后一层的结果,中间层的输出被丢弃了。
    # Hook 就像一个"钩子",挂在某层上,该层计算完后自动触发回调函数保存输出。
    activation = {}   # 字典,用来存储截获的特征图

    def get_activation(name):
        # 闭包:返回一个 hook 函数,该函数会把输出存到 activation[name] 里
        def hook(model, input, output):
            activation[name] = output.detach()  # detach() 切断梯度连接,省显存
        return hook

    # 在 conv1 上注册一个"前向 hook"
    # 之后每次 conv1 算完,hook 函数都会被自动调用
    handle = model.conv1.register_forward_hook(get_activation('conv1'))

    with torch.no_grad():
        _ = model(images)   # 前向传播(触发 hook,特征图被存入 activation 字典)

    handle.remove()  # 用完就摘掉 hook,避免干扰后续使用

    # 取出 conv1 的特征图 [num_images, 32, H, W]
    feat_maps = activation['conv1'].cpu().numpy()

    # 为每张图片画前 8 个特征图(2×4 的网格)
    for img_idx in range(num_images):
        fig, axes = plt.subplots(2, 4, figsize=(12, 6))
        fig.suptitle(f'Image {img_idx+1}: True label = {classes[labels[img_idx]]}')
        for i in range(8):
            ax = axes[i // 4, i % 4]  # 计算子图位置:i=0→(0,0), i=4→(1,0)
            ax.imshow(feat_maps[img_idx, i, :, :], cmap='viridis')  # viridis 配色:黄=高激活,紫=低激活
            ax.set_title(f'Feature Map {i+1}')
            ax.axis('off')
        plt.tight_layout()
        plt.savefig(f'feature_maps_img{img_idx+1}.png', dpi=150)
        print(f"特征图已保存到 feature_maps_img{img_idx+1}.png")
        plt.close()


# ============================================================================
# 8. 训练循环 —— 把一切组合起来
# ============================================================================

epochs = 20        # 训练 20 轮 —— 整个训练集反复用 20 遍
best_acc = 0       # 记录到目前最好的准确率,用于保存最佳模型
start_time = time.time()

for epoch in range(1, epochs + 1):
    # 第 1 步:训练(遍历所有训练图片,更新参数)
    train(epoch)

    # 第 2 步:测试(在测试集上评估效果)
    acc = test()

    # 第 3 步:更新学习率(scheduler 在每个 epoch 后自动判断该不该降 lr)
    scheduler.step()

    # 第 4 步:如果准确率创新高,保存模型
    if acc > best_acc:
        best_acc = acc
        # torch.save(model.state_dict(), ...):只保存模型参数(权重和偏置)
        # state_dict 是一个 Python 字典:{'conv1.weight': tensor(...), 'conv1.bias': tensor(...), ...}
        # 这是推荐的保存方式,比保存整个模型更灵活(加载时不需要原始类定义完全一致)
        torch.save(model.state_dict(), 'best_cifar10_cnn.pth')
        print(f"保存新最佳模型,准确率: {acc:.2f}%")
        visualize_filters(model, epoch)  # 每当模型进步,存档一张此时的卷积核可视化

end_time = time.time()
print(f"训练完成,总耗时: {end_time - start_time:.2f} 秒,最佳准确率: {best_acc:.2f}%")


# ============================================================================
# 9. 最终可视化 —— 训练结束后再看一遍
# ============================================================================

visualize_filters(model)
visualize_feature_maps(model, test_loader)

5. 可视化:CNN 内部学到了什么

运行上面的代码后,你会在当前目录下看到两类图片:

5.1 卷积核可视化(conv1_filters.png

这张图展示了第一层卷积的 32 个 3×3 卷积核(每个核是 3 通道 RGB)。你会看到:

  • 边缘检测器:一些核呈现出明显的明暗条纹(水平、垂直、对角线)。

  • 颜色检测器:一些核整体偏红、偏蓝或偏绿,专门响应某种颜色。

  • 纹理检测器:一些核呈现斑点状,响应特定纹理。

如果训练时间不足,这些核看起来会是随机噪点;训练充分后,它们会变得有结构。

5.2 特征图可视化(feature_maps_imgX.png

这些图展示了某张输入图片经过第一层卷积后,每个卷积核产生的“响应图”。例如,输入一张猫的图片:

  • 某些特征图会在猫的边缘处“亮起来”(高激活值)。

  • 某些特征图会在猫的毛色区域亮起来。

  • 不同特征图关注图片的不同方面。

通过观察这些特征图,你可以直观理解模型在“看”什么。

6. 实验结果与分析

6.1 预期结果

在 GTX 950M 上训练 20 个 epoch 约需 5 分钟,预期准确率:

模型

参数量

CIFAR-10 准确率

三层 MLP(784→512→256→10)

~40 万

~50%(几乎无用)

本博客的 CNN(Conv2d×2 + FC×2)

~12 万

75% ~ 80%

更深的 CNN(如 ResNet-18)

~1100 万

~95%(但你的 GPU 可能跑不动)

6.2 对比全连接网络

维度

MLP

CNN

参数量

~40 万

~12 万

CIFAR-10 准确率

~50%

~75%

可解释性

几乎没有(权重无法直观理解)

强(卷积核和特征图可可视化)

对空间结构敏感度

无(展平后丢失位置信息)

强(卷积操作保留空间关系)

7. 常见问题与排查

问题

可能原因

解决方法

CIFAR-10 下载慢或失败

国外网站访问慢

手动下载数据集放到 ./data 目录,或使用 torchvision 的镜像参数

准确率低于 70%

训练不够久(<10 epoch)或学习率不合适

增加 epoch 到 20~30,尝试调整 lr

显存不足(OOM)

batch_size 过大

减小到 256 或 128

torch.cuda.is_available() 返回 False

未安装 CUDA 驱动或 PyTorch 是 CPU 版

运行 nvidia-smi 检查;重新安装 GPU 版 PyTorch

特征图可视化报错

hook 未正确注册

确保前向传播时模型处于 eval() 模式

卷积核可视化全是噪声

模型未训练或训练不足

至少训练 5 个 epoch 后再可视化

8. 总结与下一步

通过本实验,你已经:

  • ✅ 理解了卷积神经网络的核心思想(局部连接、权重共享、池化)

  • ✅ 在 CIFAR-10 上实现了 CNN 并达到 ~75% 准确率

  • ✅ 可视化了卷积核和特征图,直观理解了 CNN 内部学到了什么

  • ✅ 对比了 CNN 与 MLP 在图像分类任务上的巨大差异

思考题:为什么 CIFAR-10 的准确率(75%)远低于 MNIST 的 98%?你能尝试修改网络结构(如增加卷积层、使用 BatchNorm、调整 Dropout)来提升准确率吗?欢迎在评论区分享你的实验结果!

0

评论区