1. 引言:什么是LeNet-5?
想象一下,你去银行办理业务,工作人员让你填写一张表格。表格上有许多需要手写的数字:日期、金额、身份证号码等。银行系统如何自动识别这些手写数字呢?这就是LeNet-5最初要解决的问题。
LeNet-5是由Yann LeCun等人在1998年提出的卷积神经网络(CNN),专门用于手写数字识别。它不仅是第一个成功应用于商业系统的卷积神经网络,更是现代深度学习计算机视觉的"开山鼻祖"。就像汽车的发明改变了交通方式一样,LeNet-5改变了计算机"看"世界的方式。
2. LeNet-5网络结构详解
2.1 整体架构:一个精密的"视觉处理流水线"
LeNet-5的网络结构可以比作一个工厂的质检流水线:
输入图像(32×32) → 卷积层C1 → 池化层S2 → 卷积层C3 → 池化层S4 → 全连接层C5 → 全连接层F6 → 输出层
每一层都有特定的功能,就像流水线上的不同工位:
- 卷积层:提取局部特征(如边缘、角点)
- 池化层:压缩信息,保留重要特征
- 全连接层:综合所有特征,做出最终判断
2.2 各层详细解析
第1层:卷积层C1(特征提取工位)
- 输入:32×32的灰度图像
- 卷积核:6个5×5的滤波器
- 输出:6个28×28的特征图
- 生活比喻:就像用6种不同的放大镜观察图片,每种放大镜关注不同的细节(横线、竖线、斜线等)
第2层:池化层S2(信息压缩工位)
- 操作:2×2的平均池化
- 输出:6个14×14的特征图
- 生活比喻:把一张高清照片缩小成缩略图,保留主要特征但减少数据量
第3层:卷积层C3(特征组合工位)
- 卷积核:16个5×5的滤波器
- 输出:16个10×10的特征图
- 特殊之处:不是简单的全连接卷积,而是有选择地连接前一层特征图
第4层:池化层S4(再次压缩)
- 操作:2×2的平均池化
- 输出:16个5×5的特征图
第5-7层:全连接层(决策工位)
- C5层:120个神经元,将二维特征图展平为一维向量
- F6层:84个神经元,进一步提取抽象特征
- 输出层:10个神经元(对应0-9十个数字),使用softmax输出概率
3. 生活中的LeNet-5:手写数字识别的实际应用
3.1 银行支票处理
当你填写支票时,LeNet-5的"后代"网络会自动识别:
- 金额区域:识别手写数字"500.00"
- 日期区域:识别"2025/06/11"
- 签名验证:虽然不是LeNet-5的直接应用,但原理相似
3.2 邮政编码自动分拣
邮局每天处理数百万封信件,邮政编码识别系统:
- 第一步:摄像头拍摄信封右下角
- 第二步:定位邮政编码区域
- 第三步:分割每个数字
- 第四步:用类似LeNet-5的网络识别每个数字
- 第五步:自动分拣到对应区域
3.3 考试答题卡识别
标准化考试中,学生填涂的学号、选择题答案:
python
# 伪代码示例
def 识别答题卡(图片):
定位学号区域 = 找到图片中的学号框()
分割数字 = 将学号区域切成单个数字()
识别结果 = []
for 每个数字图片 in 分割数字:
数字 = LeNet5类似模型.预测(数字图片)
识别结果.append(数字)
return 拼接(识别结果)
4. 代码拆解讲解:亲手搭建LeNet-5
4.1 环境准备
python
# requirements.txt
torch==2.0.0
torchvision==0.15.0
matplotlib==3.7.0
numpy==1.24.0
4.2 LeNet-5模型定义(PyTorch实现)
python
import torch
import torch.nn as nn
import torch.nn.functional as F
class LeNet5(nn.Module):
def __init__(self, num_classes=10):
super(LeNet5, self).__init__()
# 第一层:卷积层 C1
# 输入:1通道(灰度图),输出:6个特征图
# 卷积核:5×5,padding=2保持尺寸不变(原论文无padding,这里为教学调整)
self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
# 第二层:池化层 S2(平均池化)
# 原论文使用平均池化,现代常用最大池化
self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2)
# 第三层:卷积层 C3
# 输入:6个特征图,输出:16个特征图
self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
# 第四层:池化层 S4
self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2)
# 第五层:全连接层 C5
# 输入尺寸计算:经过两次池化(2×2),图像尺寸变化:
# 32×32 → 16×16 → 5×5(原论文)或 6×6(有padding时)
# 这里使用有padding的版本:32×32 → 16×16 → 6×6
self.fc1 = nn.Linear(16 * 6 * 6, 120)
# 第六层:全连接层 F6
self.fc2 = nn.Linear(120, 84)
# 输出层
self.fc3 = nn.Linear(84, num_classes)
def forward(self, x):
# C1层:卷积 + 激活函数
x = F.relu(self.conv1(x)) # 输出:6×32×32
# S2层:池化
x = self.pool1(x) # 输出:6×16×16
# C3层:卷积 + 激活
x = F.relu(self.conv2(x)) # 输出:16×12×12
# S4层:池化
x = self.pool2(x) # 输出:16×6×6
# 展平为一维向量
x = x.view(x.size(0), -1) # 输出:16×6×6 = 576
# C5层:全连接 + 激活
x = F.relu(self.fc1(x)) # 输出:120
# F6层:全连接 + 激活
x = F.relu(self.fc2(x)) # 输出:84
# 输出层:不使用激活,配合交叉熵损失
x = self.fc3(x) # 输出:10(0-9的概率)
return x
# 创建模型实例
model = LeNet5()
print(f"模型参数量:{sum(p.numel() for p in model.parameters())}") # 约6万参数
4.3 关键代码解析
4.3.1 卷积层的工作原理
python
# 卷积操作可视化理解
输入图像 = [[1, 2, 3],
[4, 5, 6],
[7, 8, 9]]
卷积核 = [[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]]
# 卷积计算(以左上角3×3区域为例)
输出 = (1*-1 + 2*0 + 3*1 +
4*-1 + 5*0 + 6*1 +
7*-1 + 8*0 + 9*1) = 6
4.3.2 池化层的作用
python
# 最大池化示例(2×2窗口,步长2)
输入特征图 = [[1, 3, 2, 4],
[5, 7, 6, 8],
[9, 2, 1, 3],
[4, 6, 5, 7]]
# 第一个2×2窗口:[[1,3],[5,7]] → 最大值7
# 第二个2×2窗口:[[2,4],[6,8]] → 最大值8
# 以此类推...
输出特征图 = [[7, 8],
[9, 7]]
# 尺寸从4×4压缩到2×2,保留了最显著的特征
4.3.3 激活函数:ReLU
python
def ReLU(x):
"""整流线性单元:让负值归零,正值保留"""
return max(0, x)
# 示例
输入 = [-2, -1, 0, 1, 2]
输出 = [0, 0, 0, 1, 2] # 非线性变换,增强模型表达能力
5. 训练与评估:让模型学会"看"数字
5.1 数据准备:MNIST数据集
python
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
# 数据预处理
transform = transforms.Compose([
transforms.Resize((32, 32)), # 调整到32×32(LeNet-5输入尺寸)
transforms.ToTensor(), # 转为Tensor
transforms.Normalize((0.5,), (0.5,)) # 归一化到[-1, 1]
])
# 加载MNIST数据集
train_dataset = torchvision.datasets.MNIST(
root='./data',
train=True,
download=True,
transform=transform
)
test_dataset = torchvision.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)}") # 60000张
print(f"测试集大小:{len(test_dataset)}") # 10000张
5.2 训练循环:模型的学习过程
python
import torch.optim as optim
from tqdm import tqdm
def train_model(model, train_loader, test_loader, epochs=10):
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 交叉熵损失,适合分类问题
optimizer = optim.Adam(model.parameters(), lr=0.001) # Adam优化器
# 训练循环
for epoch in range(epochs):
model.train() # 训练模式
running_loss = 0.0
correct = 0
total = 0
# 使用进度条
pbar = tqdm(train_loader, desc=f'Epoch {epoch+1}/{epochs}')
for images, labels in pbar:
# 前向传播
outputs = model(images)
loss = criterion(outputs, labels)
# 反向传播
optimizer.zero_grad() # 清空梯度
loss.backward() # 计算梯度
optimizer.step() # 更新参数
# 统计
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
# 更新进度条
pbar.set_postfix({
'loss': running_loss/(total/64),
'acc': 100*correct/total
})
# 每个epoch结束后在测试集上评估
test_acc = evaluate_model(model, test_loader)
print(f'Epoch {epoch+1}: 测试准确率 = {test_acc:.2f}%')
return model
def evaluate_model(model, test_loader):
model.eval() # 评估模式
correct = 0
total = 0
with torch.no_grad(): # 不计算梯度,节省内存
for images, labels in test_loader:
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
return 100 * correct / total
5.3 可视化训练过程
python
import matplotlib.pyplot as plt
def visualize_predictions(model, test_loader, num_images=10):
model.eval()
images, labels = next(iter(test_loader))
with torch.no_grad():
outputs = model(images[:num_images])
_, predictions = torch.max(outputs, 1)
# 创建子图
fig, axes = plt.subplots(2, 5, figsize=(12, 6))
axes = axes.ravel()
for i in range(num_images):
axes[i].imshow(images[i].squeeze(), cmap='gray')
axes[i].set_title(f'预测: {predictions[i].item()}\n真实: {labels[i].item()}')
axes[i].axis('off')
plt.tight_layout()
plt.show()
# 训练并可视化
trained_model = train_model(model, train_loader, test_loader, epochs=5)
visualize_predictions(trained_model, test_loader)
6. LeNet-5的现代意义与局限性
6.1 历史贡献
- 开创性架构:首次证明了CNN在图像识别上的有效性
- 端到端训练:无需手工设计特征,自动学习
- 商业成功:在美国银行支票识别系统中部署
6.2 局限性
- 网络较浅:只有7层,难以学习复杂特征
- 小数据集:仅针对28×28的MNIST数字
- 简单任务:只识别10个数字类别
- 计算限制:1998年的硬件限制了网络规模
6.3 现代改进
现代CNN如ResNet、EfficientNet等在LeNet-5基础上发展:
- 深度增加:从7层到1000+层
- 残差连接:解决梯度消失问题
- 注意力机制:让网络关注重要区域
- 自动化设计:神经架构搜索(NAS)
7. 附录:完整可运行代码
7.1 完整代码文件:lenet5_mnist.py
python
"""
LeNet-5完整实现:MNIST手写数字识别
在PyCharm中可直接运行
支持命令行参数:--epochs, --batch_size, --lr, --save_model
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
from tqdm import tqdm
import numpy as np
import argparse
import os
# ==================== 1. 定义LeNet-5模型 ====================
class LeNet5(nn.Module):
"""LeNet-5模型实现"""
def __init__(self, num_classes=10):
super(LeNet5, self).__init__()
# 卷积层 C1:1个输入通道,6个输出通道,5×5卷积核,padding=2保持尺寸
self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
# 池化层 S2:平均池化,2×2窗口,步长2
self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2)
# 卷积层 C3:6个输入通道,16个输出通道,5×5卷积核
self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
# 池化层 S4:平均池化
self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2)
# 全连接层 C5:16×5×5 → 120
self.fc1 = nn.Linear(16 * 5 * 5, 120)
# 全连接层 F6:120 → 84
self.fc2 = nn.Linear(120, 84)
# 输出层:84 → 10(0-9十个数字)
self.fc3 = nn.Linear(84, num_classes)
def forward(self, x):
# C1层:卷积 + ReLU激活
x = F.relu(self.conv1(x)) # 输出:6×32×32
# S2层:池化
x = self.pool1(x) # 输出:6×16×16
# C3层:卷积 + ReLU
x = F.relu(self.conv2(x)) # 输出:16×12×12
# S4层:池化
x = self.pool2(x) # 输出:16×6×6
# 展平操作:将三维特征图转换为一维向量
x = x.view(-1, 16 * 6 * 6) # 输出:16×6×6 = 576
# C5层:全连接 + ReLU
x = F.relu(self.fc1(x)) # 输出:120
# F6层:全连接 + ReLU
x = F.relu(self.fc2(x)) # 输出:84
# 输出层:全连接(无激活函数,后续用CrossEntropyLoss包含softmax)
x = self.fc3(x) # 输出:10
return x
# ==================== 2. 训练函数 ====================
def train(model, device, train_loader, optimizer, criterion, epoch):
"""训练一个epoch"""
model.train()
train_loss = 0
correct = 0
total = 0
pbar = tqdm(train_loader, desc=f'Epoch {epoch}')
for batch_idx, (data, target) in enumerate(pbar):
data, target = data.to(device), target.to(device)
# 前向传播
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
# 反向传播
loss.backward()
optimizer.step()
# 统计
train_loss += loss.item()
_, predicted = output.max(1)
total += target.size(0)
correct += predicted.eq(target).sum().item()
# 更新进度条
pbar.set_postfix({
'Loss': f'{loss.item():.4f}',
'Acc': f'{100.*correct/total:.2f}%'
})
avg_loss = train_loss / len(train_loader)
accuracy = 100. * correct / total
return avg_loss, accuracy
# ==================== 3. 测试函数 ====================
def test(model, device, test_loader, criterion):
"""测试模型性能"""
model.eval()
test_loss = 0
correct = 0
total = 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()
_, predicted = output.max(1)
total += target.size(0)
correct += predicted.eq(target).sum().item()
avg_loss = test_loss / len(test_loader)
accuracy = 100. * correct / total
return avg_loss, accuracy
# ==================== 4. 可视化训练过程 ====================
def plot_training_history(train_losses, train_accs, test_losses, test_accs):
"""绘制训练历史曲线"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
# 损失曲线
ax1.plot(train_losses, label='Train Loss', color='blue', linewidth=2)
ax1.plot(test_losses, label='Test Loss', color='red', linewidth=2)
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('Training and Test Loss')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 准确率曲线
ax2.plot(train_accs, label='Train Accuracy', color='blue', linewidth=2)
ax2.plot(test_accs, label='Test Accuracy', color='red', linewidth=2)
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy (%)')
ax2.set_title('Training and Test Accuracy')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('training_history.png', dpi=150, bbox_inches='tight')
plt.show()
# ==================== 5. 主函数 ====================
def main():
# 解析命令行参数
parser = argparse.ArgumentParser(description='LeNet-5 MNIST Training')
parser.add_argument('--epochs', type=int, default=10, help='训练轮数 (default: 10)')
parser.add_argument('--batch_size', type=int, default=64, help='批大小 (default: 64)')
parser.add_argument('--lr', type=float, default=0.001, help='学习率 (default: 0.001)')
parser.add_argument('--save_model', action='store_true', help='保存训练好的模型')
parser.add_argument('--no_cuda', action='store_true', help='禁用CUDA')
args = parser.parse_args()
# 设备设置
use_cuda = not args.no_cuda and torch.cuda.is_available()
device = torch.device("cuda" if use_cuda else "cpu")
print(f"使用设备: {device}")
# 数据预处理
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST数据集的均值和标准差
])
# 加载MNIST数据集
print("加载MNIST数据集...")
train_dataset = torchvision.datasets.MNIST(
root='./data',
train=True,
download=True,
transform=transform
)
test_dataset = torchvision.datasets.MNIST(
root='./data',
train=False,
download=True,
transform=transform
)
train_loader = DataLoader(
train_dataset,
batch_size=args.batch_size,
shuffle=True,
num_workers=2
)
test_loader = DataLoader(
test_dataset,
batch_size=args.batch_size,
shuffle=False,
num_workers=2
)
print(f"训练集大小: {len(train_dataset)}")
print(f"测试集大小: {len(test_dataset)}")
# 初始化模型、优化器、损失函数
model = LeNet5().to(device)
optimizer = optim.Adam(model.parameters(), lr=args.lr)
criterion = nn.CrossEntropyLoss()
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
print(f"开始训练,共 {args.epochs} 个epoch...")
# 训练历史记录
train_losses, train_accs = [], []
test_losses, test_accs = [], []
# 训练循环
for epoch in range(1, args.epochs + 1):
# 训练
train_loss, train_acc = train(model, device, train_loader, optimizer, criterion, epoch)
train_losses.append(train_loss)
train_accs.append(train_acc)
# 测试
test_loss, test_acc = test(model, device, test_loader, criterion)
test_losses.append(test_loss)
test_accs.append(test_acc)
print(f"Epoch {epoch:2d}: "
f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}% | "
f"Test Loss: {test_loss:.4f}, Test Acc: {test_acc:.2f}%")
# 最终评估
print("\n" + "="*60)
print(f"训练完成!最终测试准确率: {test_accs[-1]:.2f}%")
print("="*60)
# 可视化训练过程
plot_training_history(train_losses, train_accs, test_losses, test_accs)
# 保存模型
if args.save_model:
model_path = 'lenet5_mnist.pth'
torch.save({
'epoch': args.epochs,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_losses': train_losses,
'test_accs': test_accs,
}, model_path)
print(f"模型已保存到: {model_path}")
# 演示模型加载
print("\n模型加载示例:")
print("```python")
print("# 加载保存的模型")
print("checkpoint = torch.load('lenet5_mnist.pth')")
print("loaded_model = LeNet5().to(device)")
print("loaded_model.load_state_dict(checkpoint['model_state_dict'])")
print("loaded_model.eval()")
print("print(f'加载模型测试准确率: {checkpoint[\"test_accs\"][-1]:.2f}%')")
print("```")
# 示例推理
print("\n示例推理:")
model.eval()
with torch.no_grad():
# 获取一个测试样本
sample_data, sample_target = next(iter(test_loader))
sample_data, sample_target = sample_data[0:1].to(device), sample_target[0:1].to(device)
# 预测
output = model(sample_data)
_, predicted = output.max(1)
print(f"真实标签: {sample_target.item()}")
print(f"预测标签: {predicted.item()}")
print(f"预测概率: {F.softmax(output, dim=1).cpu().numpy()}")
# ==================== 6. 程序入口 ====================
if __name__ == '__main__':
main()