1. 从一张32x32的灰度图说起:为什么还要手写LeNet
很多人第一次接触卷积神经网络,都是从LeNet开始的。但大部分教程一上来就是model = LeNet(),然后model.fit(),跑完MNIST拿到99%的准确率就结束了。这样学完,你其实并不知道卷积核到底在干什么,池化层为什么能降维,全连接层的输入维度是怎么算出来的。
我当初也是这样过来的。直到有一次,我需要把一个训练好的LeNet模型部署到资源受限的设备上,才发现自己对每一层的参数量、特征图尺寸变化、内存占用完全没有概念。从那次之后,我重新把LeNet从头到尾手写了一遍,不用任何高层API,纯手写前向传播和反向传播。这个过程让我真正理解了卷积神经网络的工作原理。
这篇文章就是那次手写经历的完整记录。我会从LeNet的原始结构讲起,拆解每一层的计算逻辑,然后给出两种实现方式:一种是基于PyTorch的简洁实现,适合快速验证和部署;另一种是纯NumPy的手写实现,适合理解底层原理。最后会讲模型预测的完整流程,包括预处理、推理、后处理,以及我在实际部署中踩过的坑。
无论你是刚入门深度学习的新手,还是想重新夯实基础的从业者,这篇内容都能给你提供可直接参考的代码和实操经验。我尽量把每个设计决策背后的“为什么”讲清楚,而不是只给一堆代码让你抄。
2. LeNet的结构拆解:每一层到底在算什么
2.1 原始LeNet-5的层配置与参数计算
LeNet-5是Yann LeCun在1998年提出的,最初用于手写数字识别。它的结构非常精简,但包含了卷积神经网络的所有核心组件:卷积层、池化层、全连接层。
原始LeNet-5的输入是32x32的灰度图,结构如下:
| 层名称 | 类型 | 配置 | 输出尺寸 | 参数量 |
|---|---|---|---|---|
| C1 | 卷积 | 6个5x5卷积核,步长1,无填充 | 6x28x28 | (5x5x1+1)x6=156 |
| S2 | 平均池化 | 2x2窗口,步长2 | 6x14x14 | 0 |
| C3 | 卷积 | 16个5x5卷积核,步长1,无填充 | 16x10x10 | (5x5x6+1)x16=2416 |
| S4 | 平均池化 | 2x2窗口,步长2 | 16x5x5 | 0 |
| C5 | 卷积 | 120个5x5卷积核,步长1,无填充 | 120x1x1 | (5x5x16+1)x120=48120 |
| F6 | 全连接 | 84个神经元 | 84 | (120+1)x84=10164 |
| Output | 全连接 | 10个神经元(对应0-9) | 10 | (84+1)x10=850 |
总参数量约61,706个。这个规模在今天看来微不足道,但在当时是非常大的模型。
这里有几个细节值得注意。第一,C3层并不是把S2的所有6个通道都连接到每个输出通道,而是有选择地连接。原始论文中用一个连接表来定义哪些输入通道连接到哪些输出通道。这种非全连接的设计是为了打破对称性,让不同卷积核学到不同的特征。不过在现代实现中,我们通常简化为全连接,效果差别不大。
第二,C5层虽然叫卷积层,但因为输入尺寸正好是5x5,卷积核也是5x5,所以输出是1x1,本质上等价于全连接层。这种设计在当时是为了统一代码结构,现在看有点冗余,但理解这一点对读懂原始代码很重要。
第三,原始LeNet使用的是平均池化而不是最大池化,激活函数用的是tanh和sigmoid,而不是现在流行的ReLU。这些选择在当时是基于硬件和理论限制,现代实现中我们通常会替换成ReLU和最大池化,效果更好。
2.2 特征图尺寸变化的通用公式
很多人搞不清楚卷积之后特征图的尺寸怎么算。这里给一个通用公式:
对于输入尺寸为H_in x W_in,卷积核大小为K x K,步长为S,填充为P,则输出尺寸为:
H_out = (H_in - K + 2P) / S + 1 W_out = (W_in - K + 2P) / S + 1以C1层为例:输入32x32,卷积核5x5,步长1,无填充,则输出为(32-5+0)/1+1=28,所以是28x28。
池化层的计算类似,但通常没有填充,且步长等于窗口大小。S2层:输入28x28,窗口2x2,步长2,输出为(28-2)/2+1=14,所以是14x14。
这个公式看起来简单,但在实际写代码时,如果有多层卷积堆叠,很容易算错。我的经验是,在写模型定义之前,先拿纸笔把每一层的输入输出尺寸列出来,确认无误后再写代码。这样可以避免运行时才发现维度不匹配的问题。
2.3 卷积操作的直观理解
卷积核到底在做什么?你可以把它想象成一个“特征探测器”。每个卷积核是一个小的权重矩阵,它在输入图像上滑动,每到一个位置就做一次点积运算,得到一个数值。这个数值代表该位置与卷积核所代表的特征的匹配程度。
比如一个检测水平边缘的卷积核,它的权重可能是上面一行全是1,下面一行全是-1。当它滑到图像中一个水平边缘处时,点积结果会很大;滑到平滑区域时,结果接近0。这样输出的特征图上,边缘位置就被高亮了。
多个卷积核就是多个不同的探测器,有的检测边缘,有的检测角点,有的检测纹理。随着层数加深,后面的卷积核会组合前面的特征,检测更复杂的模式。
池化层的作用是降维和提供一定的平移不变性。最大池化取窗口内的最大值,相当于只保留最强烈的特征响应。这样即使输入图像有轻微平移,池化后的特征图变化也不大。
理解了这些,你再看LeNet的结构,就不再是一堆冰冷的数字,而是一个层层递进的特征提取流水线。
3. 基于PyTorch的LeNet实现:从定义到训练
3.1 模型定义的两种写法与选择理由
用PyTorch定义LeNet,有两种常见写法。第一种是用nn.Sequential顺序堆叠,代码简洁;第二种是继承nn.Module,在forward中显式定义前向传播,灵活性更高。
先看nn.Sequential的写法:
import torch import torch.nn as nn class LeNetSequential(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), nn.ReLU(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0), nn.ReLU(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(16, 120, kernel_size=5, stride=1, padding=0), nn.ReLU(), ) self.classifier = nn.Sequential( nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x这种写法适合结构规整的网络,每一层都是顺序连接,没有分支或跳跃连接。代码可读性好,调试也方便。
第二种写法是继承nn.Module,手动定义每一层:
class LeNetManual(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) self.conv3 = nn.Conv2d(16, 120, kernel_size=5) self.fc1 = nn.Linear(120, 84) self.fc2 = nn.Linear(84, num_classes) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) self.relu = nn.ReLU() def forward(self, x): x = self.relu(self.conv1(x)) x = self.pool(x) x = self.relu(self.conv2(x)) x = self.pool(x) x = self.relu(self.conv3(x)) x = torch.flatten(x, 1) x = self.relu(self.fc1(x)) x = self.fc2(x) return x两种写法在功能上完全等价。我个人的选择是:如果网络结构简单且不需要访问中间层输出,用nn.Sequential;如果需要提取中间特征、做可视化、或者有分支结构,用nn.Module手动定义。
这里有一个容易忽略的点:nn.Conv2d的默认padding=0,stride=1。如果你从其他框架转过来,可能会习惯性地认为有默认填充。实际上PyTorch的默认值就是无填充,所以输入32x32经过5x5卷积后变成28x28,这是正确的。
3.2 输入尺寸不匹配时的排查思路
在实际项目中,输入图像的尺寸往往不是32x32。比如你拿到的是28x28的MNIST原始图像,直接喂给LeNet会报错。因为28x28经过两次5x5卷积和两次2x2池化后,尺寸会变成:
第一次卷积:(28-5)/1+1=24,池化后12 第二次卷积:(12-5)/1+1=8,池化后4 第三次卷积:(4-5)/1+1=0,这就不对了。
所以LeNet要求输入至少是32x32。对于28x28的MNIST,标准做法是在四周填充2个像素,变成32x32。PyTorch中可以用torch.nn.functional.pad或者transforms.Pad来实现。
from torchvision import transforms transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])这里的Normalize参数是MNIST数据集的均值和标准差,用这两个值做标准化可以让输入数据分布更接近标准正态,有助于训练收敛。如果你用自己的数据集,需要先统计均值和标准差。
排查尺寸问题的通用方法是:在forward中打印每一层输出的shape,或者用torchsummary工具。我习惯在模型定义好后,先跑一个随机输入,确认输出维度正确再开始训练。
3.3 训练循环中的关键参数设置
训练LeNet并不复杂,但有几个参数需要仔细调。下面是一个完整的训练循环:
import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNetSequential(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) for epoch in range(10): model.train() running_loss = 0.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() running_loss += loss.item() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}')这里有几个经验点:
第一,优化器选择。原始LeNet用的是SGD,但Adam在大多数情况下收敛更快,对学习率不那么敏感。我一般先用Adam跑一个baseline,如果效果不理想再换SGD加动量。
第二,学习率。1e-3是Adam的常用起点。如果loss震荡厉害,降到1e-4;如果收敛太慢,升到3e-3试试。但不要超过1e-2,否则容易发散。
第三,batch size。64或128都可以。太小会导致梯度估计噪声大,太大则内存占用高且可能陷入局部最优。对于MNIST这种小数据集,64足够了。
第四,数据标准化。前面提到的Normalize((0.1307,), (0.3081,))是MNIST的全局均值和标准差。如果你不做标准化,模型也能训练,但收敛会慢一些,最终精度可能低0.5%左右。
第五,训练轮数。LeNet在MNIST上通常5-10个epoch就能达到99%以上的准确率。如果超过20个epoch还没收敛,大概率是学习率或数据预处理有问题。
3.4 训练过程中的常见异常与处理
训练LeNet时,我遇到过几种典型问题:
Loss不下降。最常见的原因是学习率太大或太小。可以先打印几个batch的loss值,如果loss在震荡且不下降,降低学习率;如果loss几乎不变,提高学习率。另一个可能是数据标签没对齐,比如图像和标签错位了,这种情况loss会卡在一个固定值附近。
Loss下降但准确率不升。这通常说明模型在记忆训练数据但没有学到泛化特征。检查一下是否过拟合,可以加Dropout或L2正则化。另外,如果训练集和验证集的预处理方式不一致,也会导致这个问题。
显存溢出。LeNet本身很小,一般不会溢出。但如果batch size设得太大,或者输入图像分辨率很高,就可能爆显存。解决办法是减小batch size,或者用梯度累积模拟大batch。
训练速度慢。如果用的是CPU,LeNet训练MNIST大概每个epoch几秒钟,可以接受。如果用了GPU但速度还是很慢,检查一下数据加载是否成了瓶颈。可以把DataLoader的num_workers设为4或8,开启多进程加载。
4. 纯NumPy手写LeNet:理解每一行计算
4.1 为什么还要手写一遍
用PyTorch实现LeNet,你只需要定义层,剩下的反向传播、参数更新都由框架自动完成。这很方便,但也掩盖了很多细节。如果你想真正理解卷积神经网络,我强烈建议手写一遍前向和反向传播。
手写的过程会让你被迫思考:卷积怎么用矩阵运算实现?池化的梯度怎么传?交叉熵损失的导数是什么?这些问题在调库时永远不会遇到,但它们是理解深度学习的基石。
下面我用NumPy实现一个简化版的LeNet,只包含一层卷积、一层池化和一层全连接,但涵盖了所有核心计算。完整代码可以在我的GitHub上找到,这里重点讲关键部分。
4.2 卷积层的前向与反向推导
卷积的前向传播可以用im2col技巧把卷积转换成矩阵乘法。具体做法是:把输入特征图中每个卷积核覆盖的区域展平成一列,所有位置展平后拼成一个大矩阵,然后与卷积核矩阵相乘。
import numpy as np def im2col(input_data, filter_h, filter_w, stride=1, pad=0): N, C, H, W = input_data.shape out_h = (H + 2*pad - filter_h)//stride + 1 out_w = (W + 2*pad - filter_w)//stride + 1 img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant') col = np.zeros((N, C, filter_h, filter_w, out_h, out_w)) for y in range(filter_h): y_max = y + stride*out_h for x in range(filter_w): x_max = x + stride*out_w col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride] col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1) return col这个函数把输入转换成列矩阵,然后卷积就变成了col @ filter_matrix。反向传播时,需要计算卷积核的梯度和输入的梯度。卷积核的梯度等于输入列矩阵的转置乘以输出梯度;输入的梯度需要把输出梯度通过col2im还原回输入形状。
def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0): N, C, H, W = input_shape out_h = (H + 2*pad - filter_h)//stride + 1 out_w = (W + 2*pad - filter_w)//stride + 1 col = col.reshape(N, out_h, out_w, C, filter_h, filter_w).transpose(0, 3, 4, 5, 1, 2) img = np.zeros((N, C, H + 2*pad + stride - 1, W + 2*pad + stride - 1)) for y in range(filter_h): y_max = y + stride*out_h for x in range(filter_w): x_max = x + stride*out_w img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :] return img[:, :, pad:H+pad, pad:W+pad]这两个函数是手写卷积的核心。理解它们的关键是:im2col把空间维度展开成矩阵的行,col2im是它的逆操作。有了这两个函数,卷积层的前向和反向都可以用矩阵乘法表示,代码简洁且效率不低。
4.3 池化层的梯度传递
池化层没有可学习的参数,但反向传播时需要把梯度传回输入。对于最大池化,梯度只传给窗口内最大值的位置,其他位置梯度为0。对于平均池化,梯度平均分配给窗口内所有位置。
class MaxPool: def __init__(self, pool_h, pool_w, stride=1, pad=0): self.pool_h = pool_h self.pool_w = pool_w self.stride = stride self.pad = pad self.x = None self.arg_max = None def forward(self, x): N, C, H, W = x.shape out_h = (H - self.pool_h)//self.stride + 1 out_w = (W - self.pool_w)//self.stride + 1 col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad) col = col.reshape(-1, self.pool_h*self.pool_w) arg_max = np.argmax(col, axis=1) out = np.max(col, axis=1) out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2) self.x = x self.arg_max = arg_max return out def backward(self, dout): dout = dout.transpose(0, 2, 3, 1) pool_size = self.pool_h * self.pool_w dmax = np.zeros((dout.size, pool_size)) dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten() dmax = dmax.reshape(dout.shape + (pool_size,)) dcol = dmax.reshape(dmax.shape[0]*dmax.shape[1]*dmax.shape[2], -1) dx = col2im(dcol, self.x.shape, self.pool_h, self.pool_w, self.stride, self.pad) return dx这里用arg_max记录了最大值的位置,反向传播时只把梯度传给这些位置。这种实现方式比循环遍历窗口快很多,也更容易理解。
4.4 全连接层与Softmax交叉熵的联动
全连接层就是矩阵乘法加偏置。前向传播是out = x @ W + b,反向传播时,权重的梯度是x.T @ dout,偏置的梯度是dout按列求和,输入的梯度是dout @ W.T。
Softmax交叉熵损失的反向传播有一个很优雅的性质:如果输出层是Softmax,损失是交叉熵,那么输出梯度就是softmax_output - one_hot_label。这个性质大大简化了反向传播的实现。
class SoftmaxWithLoss: def __init__(self): self.loss = None self.y = None self.t = None def forward(self, x, t): self.t = t self.y = softmax(x) self.loss = cross_entropy_error(self.y, self.t) return self.loss def backward(self, dout=1): batch_size = self.t.shape[0] if self.t.size == self.y.size: dx = (self.y - self.t) / batch_size else: dx = self.y.copy() dx[np.arange(batch_size), self.t] -= 1 dx = dx / batch_size return dx这个实现中,t可以是one-hot编码,也可以是类别索引。如果是类别索引,需要把对应位置的预测值减1。这种灵活性在实际使用中很方便。
手写一遍之后,你会发现PyTorch的nn.CrossEntropyLoss内部做的就是这些事。理解了原理,再用框架时就不会觉得它是个黑盒了。
5. 模型预测的完整流程:从一张图到最终结果
5.1 推理前的预处理必须与训练一致
模型训练好之后,预测时的预处理必须和训练时完全一致。这是一个非常容易踩的坑。我见过有人训练时用了标准化,预测时忘了做,结果模型输出完全乱套。
预测流程通常包括以下步骤:
- 读取图像,转为灰度图(如果模型输入是单通道)
- 缩放到模型要求的尺寸(LeNet是32x32)
- 转为Tensor,并调整维度顺序为
(C, H, W) - 标准化,使用训练时统计的均值和标准差
- 增加batch维度,变成
(1, C, H, W) - 送入模型,得到输出
- 对输出做Softmax,取最大概率对应的类别
from PIL import Image import torchvision.transforms as transforms def predict(image_path, model, device): model.eval() transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) image = Image.open(image_path) image = transform(image).unsqueeze(0).to(device) with torch.no_grad(): output = model(image) probabilities = torch.softmax(output, dim=1) predicted_class = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_class].item() return predicted_class, confidence这里有几个细节。transforms.Grayscale确保输入是单通道,即使原图是RGB也会被转换。unsqueeze(0)增加batch维度,因为模型期望的输入是4维的。torch.no_grad()关闭梯度计算,节省内存和计算时间。
5.2 批量预测与单张预测的性能差异
如果需要预测大量图片,逐张推理效率很低。更好的做法是把多张图片组成一个batch,一次性送入模型。这样可以利用GPU的并行计算能力,吞吐量能提升几倍到几十倍。
def batch_predict(image_paths, model, device, batch_size=32): model.eval() transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) all_predictions = [] for i in range(0, len(image_paths), batch_size): batch_paths = image_paths[i:i+batch_size] batch_images = [] for path in batch_paths: image = Image.open(path) image = transform(image) batch_images.append(image) batch_tensor = torch.stack(batch_images).to(device) with torch.no_grad(): outputs = model(batch_tensor) probabilities = torch.softmax(outputs, dim=1) predictions = torch.argmax(probabilities, dim=1) all_predictions.extend(predictions.cpu().numpy().tolist()) return all_predictions实测下来,batch_size=32时,GPU利用率明显高于单张推理,整体耗时能减少60%以上。但batch_size也不是越大越好,太大会导致显存不足。一般根据显存大小调整,从32开始试,逐步增加到128或256。
5.3 预测结果的置信度分析与阈值处理
模型输出的Softmax概率可以看作置信度。如果置信度很低,比如低于0.6,说明模型对这个预测不太确定。在实际应用中,可以设置一个阈值,低于阈值的样本交给人工处理,或者拒绝预测。
def predict_with_threshold(image_path, model, device, threshold=0.6): predicted_class, confidence = predict(image_path, model, device) if confidence < threshold: return None, confidence return predicted_class, confidence这个策略在工业质检、医疗影像等对准确率要求高的场景中很常用。宁可拒绝预测,也不要给出错误结果。
另外,可以分析置信度的分布。如果大部分样本的置信度都很低,说明模型没有训练好,或者测试数据分布和训练数据差异太大。这时候需要重新检查训练过程或数据质量。
5.4 模型保存与加载的注意事项
训练好的模型需要保存下来,以便后续部署。PyTorch提供了两种保存方式:保存整个模型和只保存参数。
# 保存整个模型 torch.save(model, 'lenet_full.pth') # 只保存参数(推荐) torch.save(model.state_dict(), 'lenet_params.pth') # 加载参数 model = LeNetSequential(num_classes=10) model.load_state_dict(torch.load('lenet_params.pth')) model.to(device) model.eval()推荐只保存参数,因为保存整个模型会把模型类的定义也序列化进去,如果代码结构变了,加载时可能报错。只保存参数的话,加载时只需要保证模型类定义一致即可。
加载模型后,一定要调用model.eval()。这会关闭Dropout和BatchNorm的训练模式,确保推理结果正确。我见过有人忘了这一步,导致预测结果每次都不一样,排查了半天才发现是Dropout在作怪。
6. 部署与优化中积累的几条实战经验
6.1 模型量化对推理速度的实际影响
LeNet本身很小,但在一些嵌入式设备上,推理速度仍然是个问题。模型量化可以把浮点参数转换成低精度整数,减少内存占用和计算量。
PyTorch支持动态量化和静态量化。动态量化最简单,一行代码就能搞定:
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8 )实测下来,动态量化后模型大小减少约4倍,CPU推理速度提升1.5到2倍。精度损失通常在0.1%到0.5%之间,对于MNIST这种简单任务几乎可以忽略。
但量化不是万能的。如果模型本身已经很小,量化带来的收益有限。另外,量化后的模型在GPU上不一定比浮点模型快,因为GPU对低精度整数的支持不如CPU成熟。所以量化更适合CPU部署场景。
6.2 数据增强在小数据集上的收益
MNIST有6万张训练图,对于LeNet来说足够了。但如果你用自己的数据集,可能只有几千张图,这时候数据增强就很重要。
常用的增强方式包括随机旋转、平移、缩放、加噪声等。对于手写数字识别,轻微的旋转和平移是合理的,因为不同人写数字的角度和位置确实有差异。
train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])注意增强的幅度不要太大。旋转超过15度可能会把6和9混淆,平移太多可能把数字移出边界。我一般从很小的幅度开始试,观察验证集准确率的变化。
数据增强只在训练时使用,验证和测试时要用原始数据。这一点在代码中要严格区分,否则评估结果会不准确。
6.3 学习率调度器的选择与效果对比
固定学习率在训练初期效果不错,但后期容易在最优解附近震荡。使用学习率调度器可以让学习率逐渐降低,帮助模型更精细地收敛。
常用的调度器有StepLR、CosineAnnealingLR、ReduceLROnPlateau等。我在LeNet上对比过几种:
| 调度器 | 配置 | 最终验证准确率 | 训练稳定性 |
|---|---|---|---|
| 固定学习率 | lr=1e-3 | 99.1% | 后期震荡 |
| StepLR | step=5, gamma=0.5 | 99.2% | 稳定 |
| CosineAnnealingLR | T_max=10 | 99.3% | 稳定 |
| ReduceLROnPlateau | patience=2 | 99.2% | 稳定 |
差别不大,但CosineAnnealingLR稍微好一点。对于更复杂的任务,调度器的影响会更明显。我的建议是:如果训练轮数少(小于10),用固定学习率就行;如果轮数多,加一个CosineAnnealingLR,几乎不需要调参。
6.4 从LeNet迁移到其他任务的思路
LeNet虽然简单,但它的设计思想可以迁移到很多任务上。比如:
- 图像分类:把输入通道改成3,输出类别改成你的类别数,其他结构不变,就是一个基础的CNN分类器。
- 特征提取:去掉最后的全连接层,用卷积部分的输出作为特征,接一个SVM或KNN,适合小样本场景。
- 目标检测的骨干网络:LeNet的卷积部分可以作为轻量级骨干,配合检测头使用,适合移动端部署。
- 时序信号处理:把一维时序信号转换成二维图像(如频谱图),然后用LeNet处理,在语音识别和传感器数据分析中很常见。
迁移时需要注意输入尺寸的匹配。如果新任务的输入尺寸不同,需要重新计算全连接层的输入维度。一个简单的办法是先用一个随机输入跑一遍卷积部分,打印输出shape,然后据此定义全连接层。
我在实际项目中用LeNet做过一个简单的工业零件分类任务,输入是64x64的灰度图,把第一层卷积改成7x7,其他不变,训练500张图就达到了95%的准确率。对于快速原型验证来说,LeNet的性价比非常高。
6.5 常见报错与排查清单
最后整理一份LeNet实现和预测中常见的报错及排查方法:
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
| RuntimeError: size mismatch | 全连接层输入维度不对 | 打印卷积输出shape,重新计算 |
| RuntimeError: expected scalar type | 输入数据类型与模型不一致 | 检查Tensor的dtype,统一为float32 |
| CUDA out of memory | batch size太大 | 减小batch size或使用梯度累积 |
| Loss is nan | 学习率太大或数据有NaN | 降低学习率,检查数据预处理 |
| 预测结果全一样 | 模型未加载参数或未eval | 检查load_state_dict和model.eval() |
| 准确率远低于预期 | 预处理不一致或标签错位 | 对比训练和预测的预处理流程 |
这份清单覆盖了我遇到过的90%以上的问题。每次报错时,先对照表格排查,通常能快速定位原因。
提示:在模型开发过程中,养成打印中间变量shape和数值范围的习惯。很多问题在早期就能发现,不用等到训练完才排查。
7. 写在最后的一点个人体会
LeNet是我深度学习入门时第一个真正手写过的模型。当时觉得它太简单,没什么好学的。但后来每次遇到新的问题,回头再看LeNet,总能发现之前忽略的细节。
比如最近我在做一个边缘设备上的图像分类项目,模型大小和推理速度是硬约束。试了好几个复杂的网络都不满足要求,最后用LeNet的变体,调整了卷积核数量和通道数,在精度只损失1%的情况下,模型大小减少了80%,推理速度提升了3倍。这个经历让我意识到,简单模型在特定场景下的价值往往被低估了。
如果你正在学深度学习,我的建议是:不要急着追最新的模型。先把LeNet这样的经典网络吃透,手写一遍前向和反向,搞清楚每一层的计算和梯度传递。这个过程花不了多少时间,但会让你之后学习任何复杂网络都事半功倍。
代码方面,我习惯把模型定义、训练脚本、预测脚本分成三个文件,每个文件职责单一,方便调试和复用。数据预处理单独写一个模块,训练和预测都调用同一个预处理函数,避免不一致的问题。这些工程习惯看起来不起眼,但在实际项目中能省很多时间。
最后分享一个小技巧:在训练LeNet时,可以在每个epoch结束后,随机挑几张预测错误的图看看。很多时候,错误样本的标签本身就有问题,或者图像质量太差,人眼都难以辨认。分析这些错误样本,比单纯看准确率数字更有价值。