1. 整体流程概览
在逐行写代码之前,我们先从宏观上理解一个 CNN 实验的完整生命周期。整个实验可以划分为 6 个逻辑阶段:
2. CIFAR-10 数据集:从手写数字到真实物体
2.1 数据集基本信息
2.2 为什么选择 CIFAR-10 而不是继续用 MNIST?
结论: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)
为什么局部连接有效? 图像中的局部像素相关性很强(相邻像素颜色相近),而距离远的像素关系较弱。先提取局部特征(边缘、角点),再逐步组合成全局特征(眼睛、鼻子 → 脸),这是生物视觉系统的启发。
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 分钟,预期准确率:
6.2 对比全连接网络
7. 常见问题与排查
8. 总结与下一步
通过本实验,你已经:
✅ 理解了卷积神经网络的核心思想(局部连接、权重共享、池化)
✅ 在 CIFAR-10 上实现了 CNN 并达到 ~75% 准确率
✅ 可视化了卷积核和特征图,直观理解了 CNN 内部学到了什么
✅ 对比了 CNN 与 MLP 在图像分类任务上的巨大差异
思考题:为什么 CIFAR-10 的准确率(75%)远低于 MNIST 的 98%?你能尝试修改网络结构(如增加卷积层、使用 BatchNorm、调整 Dropout)来提升准确率吗?欢迎在评论区分享你的实验结果!
评论区