【人工智能】使用CNN进行笔迹识别训练任务

背景知识:CNN的由来与演进

说到卷积神经网络(Convolutional Neural Network, CNN),我们得把时间拨回到上世纪90年代。1998年,深度学习三巨头之一的 Yann LeCun 发表了具有里程碑意义的 LeNet-5 网络。有趣的是,LeNet-5 的设计初衷正是为了识别手写数字——它被美国邮政系统用来读取信封上的手写邮政编码,而它训练和测试用的数据集,就是我们今天依然在用的 MNIST

也就是说,你现在正在做的事情和LeCun在1998年做的事情几乎一模一样——用卷积神经网络识别 MNIST 手写数字。这也是为什么 MNIST 被称为机器学习界的"Hello World":它既简单到可以让初学者快速上手,又完整地承载了 CNN 的核心思想。

LeNet-5 的结构非常经典,可以概括为:输入 → 卷积 → 池化 → 卷积 → 池化 → 全连接 → 输出。你会发现,二十多年后的今天,我们写的 CNN 代码依然遵循着几乎相同的范式,只是卷积层数更多、激活函数从 Sigmoid 换成了 ReLU、优化器更先进了。

从 LeNet-5 之后,CNN 经历了一段漫长的沉寂期——受限于当时的算力和数据规模,深度学习并不被主流学术界看好。真正的转折点出现在 2012 年:Alex Krizhevsky 等人提出的 AlexNet 在 ImageNet 图像识别大赛中以压倒性优势夺冠,错误率比第二名低了近 11 个百分点。AlexNet 在 LeNet-5 的基础上做了几个关键改进:使用 ReLU 激活函数加速训练、引入 Dropout 防止过拟合(你会发现我们的代码里也用了 Dropout)、以及利用 GPU 并行计算。此后,VGG、GoogLeNet(Inception)、ResNet 等架构层出不穷,CNN 彻底奠定了在计算机视觉领域的统治地位。

那么,“卷积"这个名字到底是什么意思?从数学直觉上理解,卷积就是用一个小的权重矩阵(卷积核)在输入图像上滑动,每次做一次局部的加权求和。你可以想象手里拿着一个 3×3 的放大镜,从图片左上角开始,一行一行地滑过每一个像素。放大镜每停在一个位置,你就把放大镜里 9 个像素的值和 9 个权重对应相乘再相加,得到一个输出值。最终,所有位置的输出值拼在一起,就形成了一张新的"特征图”。

用公式来表达就是:输出位置 (i, j) 的值 = ΣΣ 输入像素 × 卷积核权重 + 偏置。这个"滑动窗口加权求和"的过程就是卷积操作的核心,也是"卷积神经网络"名字的由来。

为什么 CNN 比 MLP 更适合图像?——两大归纳偏置

你可能还记得,如果直接用多层感知机(MLP)来识别 MNIST,需要先把 28×28 的图片"拉平"成一个 784 维的向量,然后送入全连接层。这种做法有一个根本性的问题:图片的空间结构被彻底破坏了

CNN 之所以在图像任务上天然优于 MLP,根源在于它内置了两个关键的"归纳偏置"(Inductive Bias)——翻译成人话就是:CNN 天生就知道图像的一些基本规律,不需要从零学起。

归纳偏置一:平移不变性

平移不变性的意思是:无论一个物体出现在图片的哪个位置,CNN 都能识别它。

想象一张猫的照片:猫蹲在画面左边是猫,跳到画面右边还是猫。MLP 把图片展平成一维向量后,像素 (0, 0) 和像素 (0, 1) 的连接权重是独立的。如果训练时猫大多出现在图片左边,MLP 就学会了"左边有猫的特征就输出猫"。当测试时猫跑到了右边,MLP 可能就懵了——因为右边的像素对应的完全是另一组权重。

CNN 的处理方式则完全不同:同一个卷积核在整张图片上滑动,权重是共享的。无论猫在左边还是右边,卷积核都会用同一组参数去扫描,所以只要某个局部特征(比如猫耳朵的形状)出现了,不管位置在哪,CNN 都能检测到。这就是"平移不变性"的直觉——不是说你平移了图片输出完全不变(严格来说是"平移等变性"),而是说 CNN 能够在不同位置识别出相同的模式。

归纳偏置二:局部感受野

局部感受野的意思是:卷积核每次只看邻近的一小片像素,而不是一次性看整张图。

一个 3×3 的卷积核,每次只能"看到"9 个像素。这听起来像是个限制,实际上却是 CNN 最大的优势之一——它迫使网络从局部到全局、从简单到复杂地学习视觉特征:

  • 浅层卷积:学到的是边缘、颜色、纹理这些低级特征。比如第一层可能出现一个"检测竖线"的卷积核和一个"检测横线"的卷积核。
  • 中层卷积:在浅层特征的基础上,开始组合出更复杂的形状——比如"拐角"、“圆弧”。
  • 深层卷积:进一步组合出语义级别的特征——比如"数字 8 的两个圆圈"、“数字 7 的一横一竖”。

这种层级式的特征学习,和人类视觉皮层的工作方式高度相似(事实上,LeCun 设计 CNN 时就从神经科学中获得了灵感)。

作为对比,MLP 的全连接层会看到"所有像素的混乱连接":像素 (0, 0) 和像素 (27, 27) 之间有一条直接的连接,但这两个像素在图片上相距甚远,在绝大多数情况下毫无关系。MLP 需要从海量数据中自己"领悟"出这种空间关系——这既浪费参数,又容易过拟合。

这两个归纳偏置——平移不变性和局部感受野——正是 CNN 在图像任务上能将 MLP 远远甩在身后的根本原因。

共享权重:CNN 参数效率的秘密

理解了上述两个归纳偏置之后,我们再来看看它们在实际工程中带来的直接收益——参数量的数量级差异

让我们用 MNIST 的数据来做一个具体的对比计算。

CNN 的参数量(以本文代码为例):

  • 第一层卷积 Conv2d(1, 16, 3):一个 3×3 的卷积核有 9 个权重参数 + 1 个偏置 = 10 个参数。但我们有 16 个输出通道,也就是 16 个卷积核,所以是 16 × 10 = 160 个参数
  • 更重要的是,每个卷积核会扫过整张 28×28 的图片。同一个 3×3 的卷积核在 28×28 的所有位置上共享同一组 9 个权重。一个卷积核只学一种模式(比如"检测竖线"),16 个卷积核学 16 种模式——干净、高效、可解释。

MLP 全连接层的参数量(等效对比):

  • 如果第一层就要处理 28×28 = 784 个输入像素,并输出 128 个隐藏单元,那么仅仅这一层的参数量就是 784 × 128 + 128 = 100,480 个参数
  • MLP 为输入向量中的每一个位置和隐藏层中的每一个神经元之间都分配了一个独立的权重。

一张表格来直观感受:同样是处理 MNIST 的一张 28×28 图片,CNN 用 9 个共享权重就能检测一种特征,而 MLP 需要 100,480 个独立权重才能完成第一层变换——参数数量差了 一万倍以上

这种参数效率的差异带来了几个重要的实际好处:

  1. 不容易过拟合:参数越少,模型越不容易"记住"训练数据的噪声,泛化能力就越强。这也是为什么 CNN 在训练数据相对较少的情况下依然能表现良好。
  2. 训练速度更快:参数少了,梯度计算和更新的开销自然就小了。
  3. 可解释性更强:你可以把每个卷积核学到的东西可视化出来(比如画成热力图),直观地看到网络"在看什么"。

特征图与通道

说到共享权重,就不得不提一个重要的概念——特征图(Feature Map)和通道(Channel)

一个卷积核在输入图片上滑动后,会产生一张二维的特征图。这张特征图上的每个值,代表"这个卷积核所关注的特征在这个位置有多强"。比如,一个专门检测竖线的卷积核,它产生的特征图上,凡是原始图片中有竖线的地方,数值就会很高。

那么,如果有多个卷积核呢?每个卷积核都会产生一张独立的特征图。16 个卷积核会产生 16 张特征图,32 个卷积核会产生 32 张。这 16 张或 32 张特征图叠在一起,就形成了所谓的多通道输出。在代码中,Conv2d(1, 16, 3) 的意思就是:输入有 1 个通道(灰度图),输出有 16 个通道(16 种不同的特征检测器)。

总结一下:通道数 = 卷积核的数量 = 网络能学到的不同特征模式的种类。通道越多,网络的表达能力越强(当然,参数量也会相应增加,过拟合风险也会上升——所以需要在实验中找到一个平衡点)。


好了,有了这些背景知识,现在来看具体的代码实现,你就会发现每一行都有它的道理。

import torch
import matplotlib.pyplot as plt
from torch import nn
import torch.optim as optim
import torch.nn.functional as F
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
 
from main import optimizer
 
# 设置超参数
batch_size = 64
 
# 定义预处理步骤
transform = transforms.Compose([
    transforms.ToTensor(),  # 转换为张量,范围 [0,1]
    transforms.Normalize((0.1307,), (0.3081,))  # 标准化:均值、方差是 MNIST 的经验值
])
 
# 加载训练集
train_dataset = datasets.MNIST(
    root='./data',       # 数据存放路径
    train=True,          # 训练集
    download=True,       # 如果没有就下载
    transform=transform  # 应用预处理
)
 
# 加载测试集
test_dataset = datasets.MNIST(
    root='./data',
    train=False,         # 测试集
    download=True,
    transform=transform
)
 
# 构建 DataLoader
train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    shuffle=True         # 打乱数据,适合训练
)
 
test_loader = DataLoader(
    dataset=test_dataset,
    batch_size=batch_size,
    shuffle=False        # 测试集不需要打乱
)
 
# 简单测试一下
print(f"训练集大小: {len(train_dataset)}")
print(f"测试集大小: {len(test_dataset)}")
 
# 取一个 batch 看看形状
images, labels = next(iter(train_loader))
print(f"图片批次维度: {images.shape}")   # [batch_size, 1, 28, 28]
print(f"标签批次维度: {labels.shape}")   # [batch_size]
 
class CNN(nn.Module):
    def __init__(self):
        super(CNN, self).__init__()
        self.c1 = nn.Conv2d(1, 16, 3, padding=1)
        self.c2 = nn.Conv2d(16, 32, 3, padding=1)
        self.c3 = nn.Conv2d(32, 64, 3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        self.linear0 = nn.Linear(64 * 7 * 7, 128)  # 注意这里是14*14,如果只池化一次,池化一次减半
        self.linear1 = nn.Linear(128, 64)
        self.linear2 = nn.Linear(64, 32)
        self.linear3 = nn.Linear(32, 10)
        self.drop = nn.Dropout(p=0.31)  # 丢弃概率
 
    def forward(self, x):
        x = F.relu(self.c1(x))
        x = self.pool(F.relu(self.c2(x)))  # [batch,32,14,14] → pool → [batch,32,7,7]
        x = self.pool(F.relu(self.c3(x)))
        x = x.view(x.size(0), -1)  # flatten
        x = F.relu(self.linear0(x))
        x = F.relu(self.linear1(x))
        x = self.drop(x)
        x = F.relu(self.linear2(x))
        x = self.linear3(x)
        return x
 
ez = CNN()
 
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(ez.parameters(), lr=0.001)
 
for i in range(10):
    ez.train()
    for images, labels in train_loader:
        out = ez(images)
        loss = criterion(out, labels)
        #反向传播
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        print(f"<UNK>: {loss}")
 
#训练结束
ez.eval()  # 关闭 dropout/batchnorm 等训练特性
 
correct = 0
total = 0
 
with torch.no_grad():  # 测试不需要计算梯度,节省显存
    for images, labels in test_loader:
        outputs = ez(images)                       # [batch_size, 10]
 
        # 取每行最大值对应的索引作为预测类别
        _, predicted = torch.max(outputs, 1)
 
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
 
print(f"测试集准确率: {correct}/{total} = {correct/total*100:.2f}%")

主体是CNN类和后面的训练过程。 [修改说明:将原本一句话拆分为更清晰的短句,并补充过渡] 一开始只使用了 MLP,测试效果准确率只有 96%。使用 CNN 之后进行了多次训练(for i in range(10),即训练 10 个 epoch),效果显著,准确率达到了 99.24%

e8413a3b47a4db524a276a6ea8c1c2df.png

af2829391dc0419ff1d620b468e97435.png


总结:从 96% 到 99.24% 的飞跃

回到本文的实验结果——从 MLP 的 96% 到 CNN 的 99.24%,这 3.24 个百分点的提升看似不大,但在 MNIST 数据集上,90% 以上每提升一个点都越来越困难。更重要的是,这 3.24% 的准确率提升,正是前面介绍的两个归纳偏置在起作用的直接证据

  • 平移不变性让 CNN 即使面对书写风格各异、数字位置略有偏移的手写体,依然能保持一致的特征检测能力。MLP 对位置的轻微变化很敏感,而 CNN 天然地克服了这一点。
  • 局部感受野 + 层级特征学习让 CNN 先学会识别笔画和边缘,再组合成数字的整体形状。这种"从局部到整体"的学习路径,和手写数字的视觉结构完全匹配。
  • 共享权重带来的参数效率,让 CNN 在只有 60000 张训练图片的情况下也不会严重过拟合,能够真正学到"什么是数字"的泛化规律,而不是死记硬背训练样本。

上面的两张图片分别是:第一张为训练过程中的 loss 变化截图,展示了模型在每个 batch 上的损失值逐步下降的完整过程;第二张为训练完成后在测试集上的准确率输出截图,可以看到最终的准确率输出。如果你自己运行这段代码,随着 epoch 的增加,准确率通常还会进一步小幅提升。

从 LeNet-5(1998)到这个简单的 PyTorch 教程(2025),27 年过去了,CNN 的核心思想几乎没变——好的设计经得起时间的考验。而你现在已经理解了这些思想背后的原理,这比会调用 torch.nn.Conv2d 更重要。