卷积神经网络这几年几乎成了深度学习的“代言人”,从人脸识别到工业质检,从医学影像到自动驾驶,到处都是它的身影。但很多初学者第一次接触 CNN 时的真实感受是:看了不少结构图,知道有卷积层、池化层、全连接层,也能照着别人的代码把 MNIST 跑出 99% 的准确率,可一旦被问到“卷积核到底在学什么”“特征图长什么样”“池化层拿掉会怎样”,就会发现自己其实并不理解。
这就是本文想解决的问题:我们不讲复杂数学公式,而是用图像思维、代码和一个完整可运行的手写数字识别示例,把卷积神经网络从输入到输出的全过程拆开来看。重点不是让你背模型结构,而是让你明白每一层在做什么、每一步数据变成了什么形状、哪些设计是必不可少的。
文章会从 CNN 解决的核心问题讲起,再逐步展开卷积、汇聚、全连接三个关键算子,之后用一个 PyTorch 实现的 LeNet-5 风格模型完成 MNIST 手写数字识别,最后通过可视化手段把卷积核、特征图和预测结果直接“画”出来。读完你不仅能跑通代码,还能输出一组属于自己的 CNN 内部结构图,再遇到“CNN 为什么有效”这类问题,至少知道从哪几个角度回答。
1. 这篇文章真正要解决的问题
很多教程上来就给出卷积核的计算公式,或者画一张非常复杂的网络结构图,然后告诉你“这就是卷积神经网络”。这种做法对已经有基础的人没太大问题,但对刚入门的人来说,反而是负担。真正让人困惑的不是公式本身,而是下列几个问题一直没人讲透:
- 一张 28x28 像素的图片,经过卷积、池化、全连接之后,数据到底经历了什么变化?
- 卷积核里的权重是人为设计的,还是网络自己学出来的?
- 网络上说的“特征图”和“特征提取”到底长什么样,能不能直接看到?
- 为什么不用全连接网络直接做图像分类,非要引入卷积结构?
- PyTorch 里的
nn.Conv2d参数应该怎么设置,模型输出维度如何计算?
本文把这些问题的答案都落到实际代码和可视化结果上。对于想快速上手的读者,可以直接复制第二部分的完整代码训练一个模型;对于想深入理解的读者,可视化部分会展示网络内部“看到”的内容。
我的核心判断是:卷积神经网络之所以适合图像任务,不是因为它在算法排行榜上表现好,而是因为它把“局部相关性”和“平移不变性”这两种图像信号的天然属性直接编码进了网络结构里。不理解这一点,就只能停留在调库层面。
这篇文章适合四类读者:
- 刚学完 Python,开始接触深度学习的初学者;
- 已经跑通过 PyTorch 基础教程,但对 CNN 内部机制模糊的同学;
- 需要在课程设计或项目演示中展示 CNN 原理的在校学生;
- 想用可视化手段给团队或客户解释模型原理的开发者。
2. CNN到底在学什么:从“像素”到“语义”
先回答一个核心问题:卷积神经网络到底在学什么?
假设你有一张 28x28 的手写数字“7”的图片。从计算机的角度看,它只是一个 784 维的向量,每个维度取值 0 到 255,代表灰度值。直接把这个向量丢给一个几层的全连接网络,理论上也能做分类,但效果通常不够好。原因很直观:全连接网络的每一层都是全局操作,一个神经元要同时关注 784 个输入,它很难判断“局部的弯折是否组成一个完整的数字轮廓”。
而卷积神经网络的做法完全不同。它从图像的小局部开始,每次观察一个小窗口(比如 5x5 像素),在这个窗口内做加权求和,得到一个输出值。窗口在整张图上滑动一遍,就生成了一张“特征图”。这张特征图的每个位置,表示原图对应区域是否具备某种局部特征,比如“是否有一条斜线”“是否有一个弧线”“是否有一个亮斑”。
再往深处走,第一层特征图组合成第二层的输入,第二层的卷积核开始学习更复杂的模式,比如“两条斜线组成一个角”“一个弧线和一条竖线组合成半圆”。到了更深的层,特征图已经具备很强的语义信息,比如“一个类似 7 的完整笔画结构”。最终,全连接层把这些高层次的局部特征整合起来,映射到 10 个类别上。
这个过程经常被描述成“特征提取 + 分类”,但真正的关键在于三个设计原则:
- 局部连接:每个神经元只看输入的一小片区域,而不是全局。这符合图像的天然结构,因为图像的语义由局部边缘和纹理逐步抽象而来。
- 权值共享:同一个卷积核在图像的所有位置滑动时,权重保持不变。这意味着同一个特征检测器可以在图像任意位置生效,也就是平移不变性。数字“7”不管出现在图片左上角还是右下角,都能被同一个卷积核检测出来。
- 层次化抽象:浅层学局部、细节,深层学全局、语义。这和人类视觉通路的处理顺序有很强的对应关系。如果有人问“CNN 是不是模拟人的视觉”,更准确的说法是:它借鉴了局部感受野和层次抽象的思想,但数学本质还是特征变换和分类。
理解到这里,CNN 的“黑盒”已经打开了一个口子。它不是魔术,而是一种用局部模板扫描全图,再用多层次模板组合出语义信息的方法。剩下要弄清楚的,就是卷积、池化、全连接这些具体算子是怎么配合完成这件事的。
3. 核心算子:卷积、汇聚、全连接
CNN 结构看起来复杂,拆开其实只有三种关键操作:卷积、汇聚(池化)和全连接。下面用一个最简单的流程来对比它们的定位。
3.1 卷积:全图扫描的“局部特征检测器”
卷积层的输入是若干张特征图,输出也是若干张特征图,中间靠一组可学习的卷积核完成变换。以 PyTorch 里的nn.Conv2d(1, 6, kernel_size=5, padding=2)为例:
- 输入通道数为 1,说明当前图像是单通道灰度图;
- 输出通道数为 6,说明这一层使用 6 个卷积核,会产生 6 张特征图;
- 卷积核尺寸为 5x5;
- padding=2 表示在图像四边各补 2 圈 0,让卷积前后空间尺寸保持一致。
为什么这一步有效?因为 5x5 窗口内 25 个像素的加权求和,其实就是在判断“这个小区域内是否存在某种特定的像素排列模式”。6 个卷积核就是 6 个不同的判断标准,分别响应竖线、横线、角点、弧边等基本结构。
刚开始训练时,卷积核权重是随机初始化的,网络输出一团糟。随着梯度下降不断迭代,损失函数会引导卷积核向“有利于分类”的方向调整。最终学出来的那些权重模式,往往就是各种边缘、纹理和部件的模板。
3.2 汇聚:降低分辨率,保留主要信息
池化层最常用的形式是最大池化。它把特征图划分成一个个不重叠的小窗口,每个窗口只保留最大值。以nn.MaxPool2d(2)为例,输入 28x28 的特征图经过池化后会变成 14x14 大小。
池化层有两个核心作用:
- 降低计算量:空间尺寸减半,后续层的计算负担直接缩小;
- 增强鲁棒性:保留窗口内的最大响应,相当于对微小平移和轻微形变不敏感。手写数字的笔画粗细、位置都有细微差别,只要最大响应还在,网络就能认出这个模式。
有人会觉得池化层“丢信息”,确实如此,但它丢的是对分类不重要、对位置很敏感的信息,换来的是更紧凑、更稳定的特征表达。这也是 CNN 里“信息压缩”的关键环节。
3.3 全连接:把特征映射成类别分数
经过多轮卷积和池化后,特征图张量被摊平成一维向量,进入全连接层。全连接层的每个神经元都和上一层的全部输出相连,相当于做一次全局的特征融合。
在全连接层之前,网络已经通过卷积和池化把图像变成了“高级特征向量”,理论上这个向量已经包含了足够判别类别所需的信息。全连接层要做的不是再去提取边缘,而是学习如何把这些特征组合成最终的类别决策。
3.4 三种算子的分工对比
| 算子 | 核心思路 | 典型参数 | 输出变化 | 作用 |
|---|---|---|---|---|
| 卷积 | 局部窗口加权求和 | kernel_size、stride、padding、out_channels | 通道数变化,空间尺寸由 padding 控制 | 提取局部特征,权值共享带来平移不变性 |
| 汇聚 | 窗口内取最大值或平均值 | kernel_size、stride | 空间尺寸缩小,通道数不变 | 降低分辨率,增强鲁棒性 |
| 全连接 | 全局线性变换 + 非线性激活 | in_features、out_features | 特征向量映射为类别分数 | 全局特征融合与分类决策 |
通过这个对比可以清楚地看到,卷积负责“看见”,池化负责“压缩”,全连接负责“决策”。三者协作,才构成了一个完整的图像分类系统。
4. 为什么手写数字识别是入门首选
MNIST 数据集几乎是所有 CNN 入门教程的第一站,不是因为它简单,而是因为它恰到好处。
MNIST 包含 60000 张训练图片和 10000 张测试图片,每张都是 28x28 的灰度图,内容为 0 到 9 的手写数字。数据规模适中,单张图片分辨率低,训练速度快,非常适合在普通笔记本电脑上用 CPU 完成一轮完整的训练和可视化实验。
更重要的是,MNIST 足够直观。每个样本就是一个人能瞬间判断的数字,所以你可以随时“人肉检查”模型错误。比如某个测试样本被预测成了 3,真实标签是 8,你可以直接从图上看出原因:书写太潦草、笔画断连、倾斜角度过大等。这种“一眼看懂”的特性,是 CIFAR-10 或 ImageNet 很难替代的。
从模型角度看,LeNet-5 是 1998 年 Yann LeCun 等人提出的经典 CNN 结构,专门用于手写数字识别。它的设计思路影响深远,现代 CNN 中常用的卷积-池化交替堆叠、最后接全连接分类器的结构,就是从 LeNet-5 定型下来的。
本文示例采用 LeNet-5 的简化版本:
- Conv2d(1, 6, 5, padding=2) + ReLU + MaxPool2d(2)
- Conv2d(6, 16, 5) + ReLU + MaxPool2d(2)
- Flatten 后接三个全连接层,输出维度分别为 120、84、10
对 MNIST 数据集来说,这个模型参数量只有六万左右,训练速度快,准确率也能轻松达到 99% 附近。更重要的是,它的层数不多,非常适合在可视化时逐层分析。
5. 环境准备与数据集加载
在实际动手前,先把环境准备好。以下环境基于常见配置,如果你本地的 Python 版本略有不同,问题也不大,重点演示的是通用流程。
建议使用 conda 或 venv 创建独立的 Python 环境,避免依赖冲突。
conda create -n cnn-demo python=3.10 conda activate cnn-demo pip install torch torchvision matplotlib numpy版本方面不做固定要求,PyTorch 2.x 和 1.x 在本文代码中都可以正常运行。如果你的机器有 NVIDIA 显卡并希望训练更快,可以安装对应 CUDA 版本的 PyTorch;没有 GPU 也完全不影响,MNIST 用 CPU 训练几个 epoch 就足够。
数据加载部分使用 torchvision.datasets 提供的 MNIST 接口。需要注意的一点是,MNIST 原始图片是 PIL 格式,需要先转为 Tensor 再送入网络。此外,标准化处理可以让数据分布更稳定,CNN 训练也会更顺利。
# 文件路径:data_loader.py import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) print(f"训练集样本数: {len(train_dataset)}") print(f"测试集样本数: {len(test_dataset)}") # 查看一个 batch 的数据形状 images, labels = next(iter(train_loader)) print(f"一个 batch 的图像形状: {images.shape}") # 期望输出 torch.Size([64, 1, 28, 28]) print(f"一个 batch 的标签形状: {labels.shape}") # 期望输出 torch.Size([64])这里有几个初学者容易迷糊的点:
images.shape是[batch_size, channels, height, width],顺序是通道在前,不是行在前。PyTorch 的默认布局就是 NCHW。Normalize((0.1307,), (0.3081,))中的两个值分别是 MNIST 数据集的全局均值和标准差,这是历史约定俗成的取值,也可以自己统计,但直接用这两个值更省事。- 第一次运行
download=True会从网上下载数据到./data目录,如果网络不稳定,可以手动下载后放到目录里。
数据加载是整个流程中最早可能出问题的一步,常见异常包括下载超时、目录权限不足或 torchvision 版本过旧。如果遇到下载失败,可以检查网络,或者从官方镜像手动下载四个 gzip 文件后放入./data/MNIST/raw目录。
6. 用PyTorch实现CNN手写数字识别
6.1 定义网络模型
前面已经确定使用简化的 LeNet-5 结构。在 PyTorch 中定义这个模型非常直接,继承nn.Module并在forward中描述张量流动路径即可。
# 文件路径:model.py import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv_block = nn.Sequential( # 输入: [B, 1, 28, 28] -> 输出: [B, 6, 28, 28] nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(), # 输出: [B, 6, 14, 14] nn.MaxPool2d(2), # 输入: [B, 6, 14, 14] -> 输出: [B, 16, 10, 10] nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(), # 输出: [B, 16, 5, 5] nn.MaxPool2d(2), ) self.fc_block = nn.Sequential( nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.conv_block(x) x = self.fc_block(x) return x关键注释里已经标出了每一层的输入输出形状,这里再重点解释两个容易算错的地方。
第一个是第二次卷积为什么不用 padding。第一次卷积后特征图依然是 28x28,经池化变成 14x14。第二次卷积使用 5x5 卷积核且不做 padding,14 - 4 = 10,所以输出是 10x10。再经过一次池化,变成 5x5。全连接层的输入维度就是 16 个通道乘以 5x5 空间尺寸,即 400。
第二个是nn.Flatten()的作用。它把[B, 16, 5, 5]的张量压平为[B, 400],从而能够输入后续的全连接层。如果你不用Flatten,就必须手动调用x.view(x.size(0), -1),效果一样。
6.2 训练与评估代码
接下来是训练循环。这里做一个简单的封装,方便后续调用。
# 文件路径:train.py import torch import torch.nn as nn import torch.optim as optim def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 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() total_loss += loss.item() _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / len(train_loader) accuracy = correct / total return avg_loss, accuracy def evaluate(model, test_loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / len(test_loader) accuracy = correct / total return avg_loss, accuracy训练主程序:
# 文件路径:main.py import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms from model import LeNet5 from train import train_one_epoch, evaluate # 1. 数据准备 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) # 2. 初始化 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet5().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) # 3. 训练 epochs = 5 for epoch in range(1, epochs + 1): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) test_loss, test_acc = evaluate(model, test_loader, criterion, device) print(f"Epoch {epoch:02d} | Train Loss {train_loss:.4f} | Train Acc {train_acc:.4f} | Test Loss {test_loss:.4f} | Test Acc {test_acc:.4f}") # 4. 保存模型 torch.save(model.state_dict(), 'mnist_lenet5.pth')以上代码没有太多复杂技巧,几个 epoch 后模型在测试集上准确率一般能到 98% 以上。如果你的环境里只训练了一个 epoch,准确率可能只有 95% 左右,这很正常,多跑几个 epoch 就会明显提升。
需要提醒的是,optimizer.zero_grad()必须在每次前向传播之前调用,否则梯度会累加,导致模型无法收敛。这是 PyTorch 初学者最常犯的错误之一,在修改代码时尤其要注意。
7. 可视化:把网络内部真正“画”出来
模型训练成功后,我们进入整篇文章最有价值的部分:可视化。可视化的目的不是“好看”,而是回答几个关键问题:
- 卷积核长什么样?网络到底学到了什么特征?
- 输入图片经过每一层卷积后,特征图发生了什么变化?
- 模型预测某个数字时,它的置信度分布是什么样?
7.1 卷积核可视化
第一个可视化对象是第一个卷积层的 6 个卷积核。它们的权重形状是[6, 1, 5, 5],可以理解为 6 张 5x5 的小灰度图。将权重归一化到 0 到 1 之间并显示出来,就能直观看到每个卷积核关注什么模式。
# 文件路径:visualize_kernels.py import matplotlib.pyplot as plt def show_first_layer_kernels(model): conv1_weight = model.conv_block[0].weight.data.cpu() # shape: [6, 1, 5, 5] num_kernels = conv1_weight.shape[0] fig, axes = plt.subplots(1, num_kernels, figsize=(12, 2)) for i in range(num_kernels): kernel = conv1_weight[i, 0] # 归一化到 [0, 1] 便于显示 normalized = (kernel - kernel.min()) / (kernel.max() - kernel.min() + 1e-8) axes[i].imshow(normalized, cmap='gray') axes[i].set_title(f'Kernel {i}') axes[i].axis('off') plt.tight_layout() plt.savefig('conv1_kernels.png', dpi=150) plt.show()运行后你会看到,第一层卷积核呈现类似“边缘检测器”的模式:有的偏亮区域在左侧,说明它对竖边缘更敏感;有的在斜向产生明暗变化,说明它关注斜线。这说明网络确实在学习图像的基础结构,而不是在随机响应。
如果你把第二层卷积核也画出来,会看到 16 个 5x5 的小图,但每个小图对应输入侧的 6 个通道。因为第二层输入有 6 个通道,每个卷积核实际是[6, 5, 5]的立体模板,画成平面图时可能需要一个一个通道查看,不像第一层那么直观。
7.2 特征图可视化
特征图可视化是最直观的“内部状态展示”。思路很简单:随机取一张测试图片,送入模型,分别记录第一层卷积后、第一次池化后、第二层卷积后的输出,然后以子图的方式展示。
PyTorch 中可以通过前向传播钩子(forward hook)获取中间层输出,也可以直接手动拆分模型的计算过程。这里用钩子方式实现,因为它不需要改变模型结构,更加通用。
# 文件路径:visualize_features.py import matplotlib.pyplot as plt import torch def get_feature_maps(model, image_tensor, layers): """提取指定层的输出特征图。layers 是模型层对象列表。""" activations = {} def hook_fn(name): def fn(module, input, output): activations[name] = output.detach() return fn hooks = [] for name, layer in layers: hook = layer.register_forward_hook(hook_fn(name)) hooks.append(hook) model.eval() with torch.no_grad(): _ = model(image_tensor.unsqueeze(0)) for hook in hooks: hook.remove() return activations def visualize_feature_maps(activations, max_channels=8): for name, output in activations.items(): feature_map = output[0] # 去掉 batch 维度 channels = min(feature_map.shape[0], max_channels) fig, axes = plt.subplots(1, channels, figsize=(channels * 1.5, 2.5)) for i in range(channels): axes[i].imshow(feature_map[i], cmap='viridis') axes[i].set_title(f'{name} ch{i}') axes[i].axis('off') plt.tight_layout() plt.savefig(f'feature_map_{name}.png', dpi=150) plt.show()使用时:
model.eval() sample_image, sample_label = test_dataset[0] # 取第一张测试图片 layers_to_extract = [ ('conv1_relu', model.conv_block[1]), # 第一次卷积 + ReLU 后 ('pool1', model.conv_block[2]), # 第一次池化后 ('conv2_relu', model.conv_block[4]), # 第二次卷积 + ReLU 后 ] acts = get_feature_maps(model, sample_image, layers_to_extract) visualize_feature_maps(acts)观察特征图时,有几个重点:
- 第一层卷积后的特征图通常保留了明显的空间结构,某些通道会在数字的轮廓位置出现高亮,说明该通道正在响应对应的边缘模式。
- 池化后的特征图分辨率降低一半,但主要激活区域仍然可见,说明池化虽然“缩小”了图像,却没有破坏核心语义。
- 第二层卷积后的特征图往往更抽象,人眼不容易直接看出和原数字的对应关系,因为此时网络已经在组合第一层的基础特征。这是“层次化抽象”最直观的证据。
如果你觉得钩子方式复杂,还可以直接把模型拆开手动计算,但这种方式侵入性弱、可复用到其他模型上,值得掌握。
7.3 预测结果与置信度可视化
最后一个可视化是把模型预测结果和置信度分布画出来,这在实际项目汇报中非常常用。它能让非技术背景的人瞬间理解模型在做什么。
# 文件路径:visualize_prediction.py import matplotlib.pyplot as plt import torch import torch.nn.functional as F def show_prediction_with_probability(model, image_tensor, true_label, index=0): model.eval() with torch.no_grad(): logits = model(image_tensor.unsqueeze(0)) probs = F.softmax(logits, dim=1)[0] predicted = torch.argmax(probs).item() fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.5)) # 左侧显示原始图像 ax1.imshow(image_tensor.squeeze(), cmap='gray') ax1.set_title(f'True: {true_label} | Pred: {predicted}') ax1.axis('off') # 右侧显示置信度条形图 colors = ['#e74c3c' if i == predicted else '#95a5a6' for i in range(10)] ax2.bar(range(10), probs.numpy(), color=colors) ax2.set_xticks(range(10)) ax2.set_ylabel('Probability') ax2.set_title('Softmax Output') plt.tight_layout() plt.savefig(f'prediction_{index}.png', dpi=150) plt.show()这段代码的作用很直接:左边是原始手写图片,右边是模型对 10 个类别的预测概率柱状图。如果预测正确,正确类别的柱子会以红色突出显示;如果预测错误,错误类别的柱子变红,真实类别反而变成灰色,一眼就能看出模型“错在哪”。
实际使用中可以遍历测试集前几十张图片,批量保存预测结果图。训练好的模型通常只在个别书写潦草的图片上出错,这些错误样本往往非常有研究价值:有些数字人眼都难以辨认,模型猜错也算情有可原;有些数字人眼很清楚,模型却认错了,此时就需要分析是否存在训练数据不足、数据增强不够或模型容量不够的问题。
8. 运行结果与效果验证
本节给出一个完整的执行路径和预期效果,方便你判断自己的实验是否成功。
先确认环境依赖已装好,然后运行:
python main.py预期输出类似:
Epoch 01 | Train Loss 0.1783 | Train Acc 0.9458 | Test Loss 0.0921 | Test Acc 0.9705 Epoch 02 | Train Loss 0.0643 | Train Acc 0.9802 | Test Loss 0.0487 | Test Acc 0.9853 Epoch 03 | Train Loss 0.0431 | Train Acc 0.9870 | Test Loss 0.0358 | Test Acc 0.9887 Epoch 04 | Train Loss 0.0328 | Train Acc 0.9902 | Test Loss 0.0310 | Test Acc 0.9902 Epoch 05 | Train Loss 0.0264 | Train Acc 0.9921 | Test Loss 0.0281 | Test Acc 0.9912各项指标是否正常,可以从三个维度判断:
- 损失是否持续下降:训练损失每个 epoch 都在下降,说明模型在收敛。如果损失波动很大或持续不降,优先检查学习率设置和数据预处理。
- 训练准确率和测试准确率的关系:二者接近,说明没有严重过拟合。如果训练准确率接近 100% 而测试准确率明显掉队,说明模型记住了训练数据,需要增加正则化或数据增强。
- 最终测试准确率:本文的简化 LeNet-5 结构在 MNIST 上跑出 99% 左右是正常水平。如果只有 90%,可以检查是否少跑了几轮、是否忘了标准化、卷积核数量是否过小。
模型训练成功后,继续运行可视化脚本:
python visualize_kernels.py python visualize_features.py python visualize_prediction.py如果你的安装环境有图形界面,会弹出对应窗口;如果是在服务器上运行,plt.savefig已经把图片保存到了本地文件,直接查看图片即可。第一层卷积核图、特征图、预测图全部生成成功,就说明整条可视化链路已经打通。
如果运行可视化脚本时报错“matplotlib is required”,安装一下即可:
pip install matplotlib9. 常见问题与排查方法
以下是初学者在跑 CNN 手写数字识别时最容易遇到的几类问题,按出现频率排序。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 训练准确率一直很低(低于 90%) | 数据没有标准化;学习率过大;模型结构写错 | 打印images.min()和images.max()检查输入范围;打印模型每层输出形状 | 检查ToTensor()和Normalize是否生效;降低学习率或改用 Adam |
| 训练时损失变为 NaN | 学习率过大导致梯度爆炸;数据中存在异常值 | 减小学习率到 1e-4 再试;检查 loss 是否出现负数 | 使用梯度裁剪或降低学习率 |
运行报错size mismatch | 全连接层输入维度计算错误 | 逐层打印卷积和池化后的输出形状 | 重新计算16 * 5 * 5,或使用Flatten后用shape验证 |
| 可视化时特征图为空白 | 未调用model.eval(),模型处于训练模式;归一化导致像素值太低 | 确认模型状态;输出特征图的最大值和最小值 | 添加model.eval(),可视化时对特征图做归一化 |
| 下载 MNIST 数据集超时 | 网络无法访问国外资源 | 查看./data/MNIST/raw目录是否生成了临时文件 | 手动下载数据文件放入 raw 目录后重跑 |
| 训练时 CPU 占用很高、速度慢 | 模型参数多但数据量不大,或未使用 GPU | 查看device是否设置为 cuda | 在 main.py 中检查 device 输出;或减少 epoch 数先看效果 |
这里单独展开一个常见的认知误区:很多人以为“准确率没有达到 99%,是模型结构不行”,其实对 MNIST 来说,大部分结构都能达到 98% 以上。准确率低通常是训练不充分或输入预处理有问题,而不是网络设计有大问题。所以在调结构之前,先确认数据流是否正确、训练是否收敛。
10. 最佳实践与工程建议
跑通一个 MNIST demo 很容易,但在实际项目里要稳定复现和扩展,还有几个重要建议值得记录。
10.1 数据预处理不能只停留在“能跑通”
ToTensor()会把像素从 0 到 255 缩放到 0 到 1,这一步骤很多初学者会忽略。如果不做标准化,网络输入分布可能不稳定,导致训练慢甚至难以收敛。在实际工程里,数据预处理的细节往往比模型结构更影响最终效果,例如是否去均值、是否做数据增强、是否做类别平衡,都需要根据任务决定。
10.2 模型保存和加载要连带结构信息
torch.save(model.state_dict(), 'mnist_lenet5.pth')只保存了权重,不保存模型结构。加载时必须先实例化一个结构一致的模型,再调用load_state_dict。更稳妥的做法是同时保存模型结构和超参数,或者用torch.save(model, 'model.pth')保存整个对象,但这种方式在后期的兼容性上不如保存state_dict。建议把模型结构和训练配置写成一个类,便于复现。
# 文件和代码示例:保存参数配置 config = { 'model': 'LeNet5', 'num_classes': 10, 'epochs': 5, 'batch_size': 64, 'lr': 1e-3 } torch.save({'state_dict': model.state_dict(), 'config': config}, 'mnist_lenet5_full.pth')10.3 可视化和训练代码分离
把 Visualize 代码和训练代码分开,是工程上比较合理的设计。训练脚本负责训练并保存模型,可视化脚本负责加载模型、读取测试样本、输出图片。这样不会每次可视化都要重新训练模型,也便于在演示时快速加载已有权重。
10.4 随机种子和可复现性
深度学习实验的可复现性经常被忽略。在论文或课程设计中,如果你希望结果可以稳定复现,必须固定随机种子:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)注意,PyTorch 中某些算子即使在固定随机种子下仍然可能受 GPU 并行计算影响产生微小差异,所以真正的完全复现还需要设置torch.backends.cudnn.deterministic = True,但这会牺牲一部分训练速度。对于 MNIST 这类小实验,仅固定随机种子已经足够。
10.5 从小模型开始,先跑通再扩展
很多开发者在第一次接触 CNN 时,总想直接使用 ResNet 或 VGG 等复杂结构。但从工程实践的角度看,面对 MNIST 这种低分辨率、单通道数据集,从最简单的 LeNet-5 开始反而更有利于排查问题和理解原理。复杂模型带来的性能提升有限,但调试成本会显著上升。等到基础流程全部跑通、可视化也做出来了,再逐步增加网络深度,才能清晰地判断每一步改动带来的效果。
11. 总结与后续学习方向
现在回看整条链路:我们从“CNN 为什么适合图像任务”出发,介绍了卷积、汇聚、全连接三种核心算子的分工,随后用 PyTorch 实现了一个简化的 LeNet-5,在 MNIST 手写数字数据集上完成训练,最后通过卷积核可视化、特征图可视化和预测概率可视化,把网络内部结构直接展示出来。
最重要的收获不只是“学会了训练 MNIST”,而是建立了三个关键认知:
- CNN 的有效性来自局部连接、权值共享和层次化抽象,这三个设计刚好契合图像的局部相关性和平移不变性。
- 特征图是理解 CNN 的关键中间产物,每一层都在做不同抽象程度的特征变换,直接可视化特征图能验证模型到底在学什么。
- 可视化不是锦上添花,而是调试和解释模型的实用工具。当你面对一个陌生网络时,最好的入门方式就是把它每一层的输入输出和特征图画出来。
从这里继续深入,可以考虑几个方向。第一,把固定的 5x5 卷积替换为不同尺寸的卷积核,观察感受野变化如何影响特征图的表达。第二,尝试添加 Dropout 或 BatchNorm 层,重新训练并对比准确率和训练曲线,理解正则化对深度学习模型的作用。第三,在可视化代码中增加 Grad-CAM 类方法,通过梯度生成热力图,定位模型分类时关注的图像区域,这会让可视化能力再上一个台阶。
写这篇文的初衷,是想帮那些“照着代码敲完了但还是不敢说自己懂 CNN”的读者真正跨过理解这道坎。建议收藏这份代码和思路,在自己电脑上完整跑一遍,然后挑几张错误样本分析一下。图片和数据远比公式更有说服力,这是初学者最值得建立的习惯。