AI项目(一):手写字识别

手写字识别(Handwritten Digit Recognition)是计算机视觉领域的"Hello World"。本项目将基于经典的数据集 MNIST ,使用 Python 和深度学习框架 PyTorch,从零开始构建一个完整的手写字识别系统。

传统机器学习流程

传统机器学习处理图像任务,标准流程通常分为以下四个步骤:

bash 复制代码
[原始图像] ──> [图像预处理] ──> [人工特征提取] ──> [分类器预测]

图像预处理 (Preprocessing)

由于传统算法对噪点和位置非常敏感,必须先对图像进行清洗:

  • 二值化 (Binarization):将灰度图转化为纯黑白两色(0 和 255),去除背景杂色。
  • 尺寸归一化与居中 (Centering) :将长宽不一的手写字裁剪并缩放到固定大小(如 28×2828 \times 2828×28),并将字体的重心移到图片正中央,减少位置偏差带来的干扰。
  • 降噪 (Noise Reduction):使用中值滤波等手段去掉纸张纹理或扫描产生的杂点。

人工特征提取 (Feature Extraction)

传统算法无法直接"看懂"像素矩阵,直接把 28×28=78428 \times 28 = 78428×28=784 个像素点拉直喂给模型效果往往很差。因此,科学家们发明了各种数学方法来提取几何特征:

  • HOG (方向梯度直方图):统计图像局部区域的梯度方向。它能很好地捕捉到数字的"边缘"和"线条走向"。
  • SIFT/SURF (尺度不变特征变换):寻找数字的关键点(如笔画的交叉点、转折点、端点)。
  • 像素密度与投影特征
    • 水平/垂直投影:统计每一行、每一列黑色像素点的数量,形成两个投影波形。例如,数字"1"的垂直投影会有一个极高的峰值,而数字"8"会有两个明显的波谷。
    • 网格粗细度 :将图像划分为 4×44 \times 44×4 的网格,计算每个小方格内黑色像素的占比。

分类器训练与预测 (Classification)

特征提取出来后,会变成一个相对紧凑的特征向量,然后送入经典的数学分类模型中进行训练:

  • SVM (支持向量机):传统手写识别的王牌。它试图在多维特征空间中找到一个最佳的"超平面",把不同的数字最大程度地隔开。
  • KNN (K-最近邻算法):最直观的方法。要识别一个新字,就去训练集里找和它特征最像的 K 个字,看这 K 个字多数投给谁(比如有 4 个邻居是"7",1 个是"1",那它就是"7")。
  • 随机森林 (Random Forest):构建多棵决策树,通过组合判断数字的笔画特征(如:上方是否有圈?下方是否有转折?)来投票决定最终结果。

传统机器学习 vs 深度学习

维度 传统机器学习 深度学习 (如 CNN)
特征提取方式 人工设计(依赖专家经验,如 HOG/SIFT) 端到端自动学习(模型自己寻找最合适的特征)
数据表现形式 图像空间结构被破坏(通常需要拉平成一维向量) 保留图像的 2D 空间邻近关系和局部结构
数据量需求 小样本表现较好(几百到几千张图就能训练) 需要海量数据(成万上亿张图才能喂饱参数)
计算资源 CPU 即可快速完成训练 极度依赖 GPU 算力
准确率上限 遇到复杂的连笔、倾斜时,准确率遭遇瓶颈 可以无限逼近甚至超越人类肉眼的识别率

项目流程与核心机制

在编写代码之前,我们需要理清一个完整的 AI 项目是如何运转的。手写字识别的核心流程可以抽象为以下五个阶段:

bash 复制代码
[数据准备] ──> [模型设计] ──> [损失与优化] ──> [训练与验证] ──> [推理预测]

数据准备 (Data Preparation)

MNIST 数据集包含 60,000 张训练图片和 10,000 张测试图片。每张图片都是 28×2828 \times 2828×28 像素的灰度图,代表 0 到 9 的手写数字。

  • 归一化 (Normalization) :将像素值从 0,2550, 2550,255 压缩到 0,10, 10,1−1,1-1, 1−1,1,这有助于梯度下降算法更快地收敛。
  • 批处理 (Batching):我们不会一次性把大几万张图片喂给计算机,而是分成一个个小批次(如每次 64 张),在内存占用和计算效率之间取得平衡。

模型设计 (Model Architecture)

我们将搭建一个卷积神经网络 (CNN)。相比于传统的多层感知机 (MLP),CNN 能更好地捕捉图像的空间特征(如线条、边缘)。

  • 卷积层 (Conv2d):利用滤波器(Filter)提取图像的局部特征。
  • 激活函数 (ReLU):引入非线性能力,让网络能拟合复杂的函数。
  • 池化层 (MaxPool2d):下采样,减少计算量,同时提取主要特征。
  • 全连接层 (Linear):将提取出的高维特征展平,最终映射到 10 个分类节点上(对应数字 0-9)。

损失函数与优化器 (Loss & Optimizer)

  • 损失函数 (CrossEntropyLoss):多分类任务的标准配置。它衡量的是模型预测的概率分布与真实标签(One-hot 编码)之间的差距。
  • 优化器 (Adam):结合了 AdaGrad 和 RMSProp 的优点,能自适应调整学习率,是目前最常用的主流优化器之一。

完整 Python 代码实现

以下是项目的完整代码。

python 复制代码
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
import os

# ==========================================
# 1. 超参数设置 (Hyperparameters)
# ==========================================
BATCH_SIZE = 64          # 每个批次的样本数
EPOCHS = 5               # 总共训练的轮次
LEARNING_RATE = 0.001    # 初始学习率
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") 
print(f"当前使用的计算设备: {DEVICE}")

# ==========================================
# 2. 数据准备与预处理 (Data Pipeline)
# ==========================================
# 定义数据预处理流:先转换为张量(Tensor),再进行归一化(均值0.1307,标准差0.3081是MNIST的官方统计值)
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

# 下载并加载训练集与测试集
train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform, download=True)

train_loader = DataLoader(dataset=train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(dataset=test_dataset, batch_size=BATCH_SIZE, shuffle=False)

# ==========================================
# 3. 构建卷积神经网络 (CNN Model)
# ==========================================
class ConvNet(nn.Module):
    def __init__(self):
        super(ConvNet, self).__init__()
        # 第一层卷积:输入1个通道(灰度图),输出16个通道,卷积核大小5x5
        self.conv1 = nn.Sequential(
            nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2),
            nn.BatchNorm2d(16),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2)  # 特征图尺寸减半:28x28 -> 14x14
        )
        # 第二层卷积:输入16个通道,输出32个通道,卷积核大小5x5
        self.conv2 = nn.Sequential(
            nn.Conv2d(16, 32, 5, 1, 2),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2)  # 特征图尺寸再次减半:14x14 -> 7x7
        )
        # 全连接层:输入特征数为 32通道 * 7 * 7,输出10个分类
        self.fc = nn.Linear(32 * 7 * 7, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = x.view(x.size(0), -1)  # 将多维特征展平为一维向量 (batch_size, 32*7*7)
        output = self.fc(x)
        return output

# 实例化模型并转移到指定设备
model = ConvNet().to(DEVICE)

# ==========================================
# 4. 定义损失函数与优化器
# ==========================================
criterion = nn.CrossEntropyLoss()  # 交叉熵损失
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)

# ==========================================
# 5. 编写训练与测试函数
# ==========================================
def train(model, device, train_loader, optimizer, criterion, epoch):
    model.train()  # 切换为训练模式
    running_loss = 0.0
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        
        optimizer.zero_grad()        # 1. 梯度清零
        output = model(data)         # 2. 前向传播
        loss = criterion(output, target) # 3. 计算损失
        loss.backward()              # 4. 反向传播
        optimizer.step()             # 5. 权重更新
        
        running_loss += loss.item()
        if batch_idx % 200 == 199:   # 每200个batch打印一次日志
            print(f"Epoch [{epoch+1}/{EPOCHS}], Step [{batch_idx+1}/{len(train_loader)}], Loss: {running_loss / 200:.4f}")
            running_loss = 0.0

def test(model, device, test_loader, criterion):
    model.eval()  # 切换为评估模式(关闭Dropout和BatchNorm的训练行为)
    test_loss = 0
    correct = 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()
            pred = output.argmax(dim=1, keepdim=True)  # 找到概率最大的索引作为预测值
            correct += pred.eq(target.view_as(pred)).sum().item()

    test_loss /= len(test_loader.dataset)
    accuracy = 100. * correct / len(test_loader.dataset)
    print(f"\n--- 测试集评估 ---")
    print(f"平均损失: {test_loss:.4f}, 准确率: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n")
    return accuracy

# ==========================================
# 6. 执行核心训练循环
# ==========================================
best_acc = 0.0
for epoch in range(EPOCHS):
    train(model, DEVICE, train_loader, optimizer, criterion, epoch)
    epoch_acc = test(model, DEVICE, test_loader, criterion)
    
    # 保存准确率最高的模型权重
    if epoch_acc > best_acc:
        best_acc = epoch_acc
        torch.save(model.state_dict(), 'best_mnist_cnn.pth')
        print("====== 已保存当前最佳模型权重 ======")

print(f"训练完成!最佳准确率: {best_acc:.2f}%")

# ==========================================
# 7. 效果可视化(验证预测结果)
# ==========================================
def plot_predictions(model, device, test_loader):
    model.eval()
    data, target = next(iter(test_loader)) # 获取一个批次的数据
    data, target = data.to(device), target.to(device)
    output = model(data)
    pred = output.argmax(dim=1, keepdim=True)
    
    # 转移回CPU用于绘图
    images = data.cpu()
    preds = pred.cpu()
    targets = target.cpu()
    
    # 画出前6张图及其预测结果
    plt.figure(figsize=(10, 6))
    for i in range(6):
        plt.subplot(2, 3, i+1)
        # 逆归一化还原图像显示
        img = images[i][0] * 0.3081 + 0.1307
        plt.imshow(img, cmap='gray')
        color = "green" if preds[i].item() == targets[i].item() else "red"
        plt.title(f"Pred: {preds[i].item()} (True: {targets[i].item()})", color=color)
        plt.axis('off')
    plt.tight_layout()
    plt.show()

# 运行可视化
plot_predictions(model, DEVICE, test_loader)

总结

整个项目可以浓缩为三大核心支柱

bash 复制代码
[数据驱动] ──> 基于 MNIST 10,000+ 标准灰度图,通过归一化(Mean 0.1307, Std 0.3081)消除光照与尺度干扰。
     │
[特征重塑] ──> 放弃传统人工设计特征(如 HOG/SIFT),改用 CNN 卷积核自动捕捉边缘、弧度等高维空间特征。
     │
[数学纠错] ──> 借由 CrossEntropyLoss 衡量预测差距,通过 Adam 优化器反向传播梯度,实现数百万参数的自适应调整。