- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
LeNet(LeNet-5)是历史上最早发布的卷积神经网络之一,由 Yann LeCun 于 1989 年提出,用于识别手写数字,并在 ATM 支票数字识别场景中沿用至今。本文以《动手学深度学习》(d2l-zh)仓库中 lenet.md 一章为核心,系统讲解 LeNet-5 的架构设计、四框架(MXNet / PyTorch / TensorFlow / PaddlePaddle)下的完整实现代码、逐层张量形状推导、GPU 训练函数与评估函数的实现细节,并结合仓库 d2l 工具包源码(如 d2l/torch.py)印证try_gpu、train_ch6、evaluate_accuracy_gpu等关键函数。读完本文,你将能独立用任意主流深度学习框架搭建、检查、训练并评估 LeNet-5,并理解其背后的设计动机与优化空间。
为什么从 LeNet 开始:卷积网络取代全连接
在前几章中,无论 softmax 回归还是多层感知机(MLP),处理 Fashion-MNIST 中的 $28\times28$ 图像时,第一步都是把图像展平成一个 784 维的固定长度一维向量,再交给全连接层。这种做法完全丢弃了像素之间的空间结构信息,而且全连接层参数量庞大。
掌握卷积层之后,就可以直接在二维网格上保留空间结构。用卷积层替代全连接层还有一个额外好处:模型更简洁、所需参数更少。LeNet 正是体现这一思想的最早、最成功的实践——1989 年 LeCun 在 AT&T 贝尔实验室发表了第一篇通过反向传播成功训练卷积神经网络的研究,成果可与当时监督学习主流方法支持向量机(SVM)相媲美,并被广泛用于自动取款机(ATM)识别支票上的数字。
LeNet 架构:卷积编码器 + 全连接稠密块
总体来看,LeNet-5 由两个部分组成:
- 卷积编码器:由两个卷积层组成;
- 全连接层稠密块:由三个全连接层组成。
每个卷积块中的基本单元是"卷积层 + sigmoid 激活函数 + 平均汇聚层"。需要注意的是,虽然 ReLU 和最大汇聚层通常更有效,但它们在 20 世纪 90 年代尚未出现,因此 LeNet 选择了当时的标配。每个卷积层使用 $5\times5$ 卷积核和 sigmoid 激活函数,把空间排列的输入映射到多张二维特征图,并通常同时增加通道数:第一个卷积层有 6 个输出通道,第二个有 16 个输出通道。每个 $2\times2$ 汇聚操作(步幅 2)通过空间下采样将宽高减半、维数整体减少 4 倍。卷积块的输出形状由批量大小、通道数、高度、宽度四个维度决定。
为了把卷积块的输出传给稠密块,必须在小批量中展平每个样本:把这个四维输入转换成全连接层期望的二维输入——第一维索引小批量中的样本,第二维给出每个样本的平面向量表示。LeNet 的稠密块有三个全连接层,输出分别为 120、84 和 10。因为仍是在做分类任务,输出层的 10 维正好对应 10 个类别。
四框架下的 LeNet 实现
用现代深度学习框架实现该模型非常简单,只需实例化一个Sequential块并串联各层。下面是仓库文档中给出的 MXNet、PyTorch、TensorFlow 与 PaddlePaddle 四种实现。
MXNet(Gluon)实现:
from d2l import mxnet as d2l from mxnet import autograd, gluon, init, np, npx from mxnet.gluon import nn npx.set_np() net = nn.Sequential() net.add(nn.Conv2D(channels=6, kernel_size=5, padding=2, activation='sigmoid'), nn.AvgPool2D(pool_size=2, strides=2), nn.Conv2D(channels=16, kernel_size=5, activation='sigmoid'), nn.AvgPool2D(pool_size=2, strides=2), # 默认情况下,"Dense"会自动将形状为(批量大小,通道数,高度,宽度)的输入, # 转换为形状为(批量大小,通道数*高度*宽度)的输入 nn.Dense(120, activation='sigmoid'), nn.Dense(84, activation='sigmoid'), nn.Dense(10))PyTorch 实现:
from d2l import torch as d2l import torch from torch import nn net = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(), nn.Linear(120, 84), nn.Sigmoid(), nn.Linear(84, 10))TensorFlow(Keras)实现:
from d2l import tensorflow as d2l import tensorflow as tf def net(): return tf.keras.models.Sequential([ tf.keras.layers.Conv2D(filters=6, kernel_size=5, activation='sigmoid', padding='same'), tf.keras.layers.AvgPool2D(pool_size=2, strides=2), tf.keras.layers.Conv2D(filters=16, kernel_size=5, activation='sigmoid'), tf.keras.layers.AvgPool2D(pool_size=2, strides=2), tf.keras.layers.Flatten(), tf.keras.layers.Dense(120, activation='sigmoid'), tf.keras.layers.Dense(84, activation='sigmoid'), tf.keras.layers.Dense(10)])PaddlePaddle 实现:
from d2l import paddle as d2l import warnings warnings.filterwarnings("ignore") import paddle from paddle import nn, optimizer net = nn.Sequential( nn.Conv2D(1, 6, kernel_size=5, padding=2), nn.Sigmoid(), nn.AvgPool2D(kernel_size=2, stride=2), nn.Conv2D(6, 16, kernel_size=5), nn.Sigmoid(), nn.AvgPool2D(kernel_size=2, stride=2), nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(), nn.Linear(120, 84), nn.Sigmoid(), nn.Linear(84, 10))与原版 LeNet-5 的唯一差异
我们对原始模型做了一点小改动:去掉了最后一层的高斯激活函数。除此之外,这个网络与最初的 LeNet-5 架构完全一致。也就是说,最后输出层nn.Linear(84, 10)直接输出 10 个类别的 logits,与交叉熵损失配合使用,这一设计也更契合现代框架的惯例。
各层结构与超参数速查
| 层 | 类型 | 核 / 池大小 | 输出通道 / 神经元 | 填充 | 激活 | 备注 |
|---|---|---|---|---|---|---|
| 输入 | 图像 | — | 1 通道 $28\times28$ | — | — | 单通道黑白图像 |
| Conv1 | 卷积 | $5\times5$ | 6 | 2 | sigmoid | padding 补偿核导致的尺寸缩小 |
| Pool1 | 平均汇聚 | $2\times2$,步幅 2 | 6 | — | — | 宽高减半 |
| Conv2 | 卷积 | $5\times5$ | 16 | 0 | sigmoid | 无填充,宽高各减 4 |
| Pool2 | 平均汇聚 | $2\times2$,步幅 2 | 16 | — | — | 宽高减半 |
| Dense1 | 全连接 | — | 120 | — | sigmoid | 输入为展平后的 $16\times5\times5$ |
| Dense2 | 全连接 | — | 84 | — | sigmoid | — |
| Dense3 | 全连接 | — | 10 | — | 无 | 对应 10 个分类 |
逐层检查模型:张量形状推导
将一张 $28\times28$ 的单通道黑白图像送入 LeNet,并在每一层打印输出形状,可以验证模型操作是否符合预期。四个框架的检查代码分别如下:
# MXNet X = np.random.uniform(size=(1, 1, 28, 28)) net.initialize() for layer in net: X = layer(X) print(layer.name, 'output shape:\t', X.shape)# PyTorch X = torch.rand(size=(1, 1, 28, 28), dtype=torch.float32) for layer in net: X = layer(X) print(layer.__class__.__name__,'output shape: \t',X.shape)# TensorFlow X = tf.random.uniform((1, 28, 28, 1)) for layer in net().layers: X = layer(X) print(layer.__class__.__name__, 'output shape: \t', X.shape)# PaddlePaddle X = paddle.rand((1, 1, 28, 28), 'float32') for layer in net: X = layer(X) print(layer.__class__.__name__, 'output shape: \t', X.shape)执行后可以看到逐层形状变化:(1, 1, 28, 28) → (1, 6, 28, 28) → (1, 6, 14, 14) → (1, 16, 10, 10) → (1, 16, 5, 5) → (1, 400) → (1, 120) → (1, 84) → (1, 10)(PyTorch 中Flatten后为(1, 400))。
从中可以归纳出几个关键规律:
- 整个卷积块中,每一层特征的高度和宽度相对上一层都在减小;
- 第一个卷积层使用 2 像素填充,补偿 $5\times5$ 卷积核导致的特征减少;第二个卷积层没有填充,因此高度和宽度都减少 4 像素;
- 随着层叠上升,通道数从输入时的 1 增加到第一个卷积层后的 6,再到第二个卷积层后的 16;
- 每个汇聚层使高度和宽度减半;
- 最后每个全连接层逐步降低维数,最终输出与类别数匹配的 10 维向量。
值得注意的是,若把输入尺寸从 $28\times28$ 换为 Fashion-MNIST 之外的更大图像(如 $32\times32$),第二个卷积层输出的宽高会变为 $6\times6$,此时 Dense1 的输入应相应改为16 * 6 * 6——这是迁移 LeNet 到其他分辨率时必须同步修改的关联参数。
在 Fashion-MNIST 上训练 LeNet
加载数据集
直接复用 d2l 工具包的load_data_fashion_mnist(定义见 d2l/torch.py 等文件),它会下载 Fashion-MNIST 数据集、转换为张量并封装为 DataLoader:
#@tab all batch_size = 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)虽然卷积神经网络的参数比同深度 MLP 少,但由于每个参数参与更多次乘法,计算成本依然较高,因此值得用 GPU 加速训练。
GPU 评估函数:evaluate_accuracy_gpu
由于完整数据集位于内存中,模型使用 GPU 计算之前需要先把数据复制到显存。MXNet/PyTorch 版evaluate_accuracy_gpu的实现(同样保存在 d2l/torch.py):
# MXNet def evaluate_accuracy_gpu(net, data_iter, device=None): #@save """使用GPU计算模型在数据集上的精度""" if not device: # 查询第一个参数所在的第一个设备 device = list(net.collect_params().values())[0].list_ctx()[0] metric = d2l.Accumulator(2) # 正确预测的数量,总预测的数量 for X, y in data_iter: X, y = X.as_in_ctx(device), y.as_in_ctx(device) metric.add(d2l.accuracy(net(X), y), d2l.size(y)) return metric[0] / metric[1]# PyTorch def evaluate_accuracy_gpu(net, data_iter, device=None): #@save """使用GPU计算模型在数据集上的精度""" if isinstance(net, nn.Module): net.eval() # 设置为评估模式 if not device: device = next(iter(net.parameters())).device # 正确预测的数量,总预测的数量 metric = d2l.Accumulator(2) with torch.no_grad(): for X, y in data_iter: if isinstance(X, list): # BERT微调所需的(之后将介绍) X = [x.to(device) for x in X] else: X = X.to(device) y = y.to(device) metric.add(d2l.accuracy(net(X), y), d2l.size(y)) return metric[0] / metric[1]# PaddlePaddle def evaluate_accuracy_gpu(net, data_iter, device=None): #@save """使用GPU计算模型在数据集上的精度""" if isinstance(net, nn.Layer): net.eval() # 设置为评估模式 if not device: device = next(iter(net.parameters())).place paddle.set_device("gpu:{}".format(str(device)[-2])) # 正确预测的数量,总预测的数量 metric = d2l.Accumulator(2) with paddle.no_grad(): for X, y in data_iter: if isinstance(X, list): # BERT微调所需的 X = [paddle.to_tensor(x, place=device) for x in X] else: X = paddle.to_tensor(X, place=device) y = paddle.to_tensor(y, place=device) metric.add(d2l.accuracy(net(X), y), d2l.size(y)) return metric[0] / metric[1]与第 3 章定义的evaluate_accuracy相比,核心改动在于:进入前向计算前,先用X.as_in_ctx(device)(MXNet)或X.to(device)(PyTorch)把每个小批量复制到 GPU;PyTorch 版还会额外处理X为列表的情况(后续 BERT 微调需要),并在torch.no_grad()下评估以节省显存。
GPU 训练函数:train_ch6
与第 3 章的train_epoch_ch3不同,train_ch6在正向和反向传播之前把每个小批量移动到指定设备(如 GPU)。由于之后要实现多层网络,该函数主要使用高级 API,并用 [subsec_xavier] 介绍的 Xavier 随机初始化模型参数;损失函数用交叉熵,优化器用小批量随机梯度下降(SGD)。四种框架的实现要点如下。
MXNet 版(net.initialize(force_reinit=True, ctx=device, init=init.Xavier())在目标设备上用 Xavier 初始化,gluon.loss.SoftmaxCrossEntropyLoss()作为损失,gluon.Trainer(..., 'sgd', {'learning_rate': lr})构造 SGD 优化器):
#@save def train_ch6(net, train_iter, test_iter, num_epochs, lr, device): """用GPU训练模型(在第六章定义)""" net.initialize(force_reinit=True, ctx=device, init=init.Xavier()) loss = gluon.loss.SoftmaxCrossEntropyLoss() trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], legend=['train loss', 'train acc', 'test acc']) timer, num_batches = d2l.Timer(), len(train_iter) for epoch in range(num_epochs): metric = d2l.Accumulator(3) # 训练损失之和,训练准确率之和,样本数 for i, (X, y) in enumerate(train_iter): timer.start() # 下面是与"d2l.train_epoch_ch3"的主要不同 X, y = X.as_in_ctx(device), y.as_in_ctx(device) with autograd.record(): y_hat = net(X) l = loss(y_hat, y) l.backward() trainer.step(X.shape[0]) metric.add(l.sum(), d2l.accuracy(y_hat, y), X.shape[0]) timer.stop() train_l = metric[0] / metric[2] train_acc = metric[1] / metric[2] if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1: animator.add(epoch + (i + 1) / num_batches, (train_l, train_acc, None)) test_acc = evaluate_accuracy_gpu(net, test_iter) animator.add(epoch + 1, (None, None, test_acc)) print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, ' f'test acc {test_acc:.3f}') print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec ' f'on {str(device)}')PyTorch 版(与 d2l/torch.py 中保存的版本一致,d2l/torch.py):
#@save def train_ch6(net, train_iter, test_iter, num_epochs, lr, device): """用GPU训练模型(在第六章定义)""" def init_weights(m): if type(m) == nn.Linear or type(m) == nn.Conv2d: nn.init.xavier_uniform_(m.weight) net.apply(init_weights) print('training on', device) net.to(device) optimizer = torch.optim.SGD(net.parameters(), lr=lr) loss = nn.CrossEntropyLoss() animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], legend=['train loss', 'train acc', 'test acc']) timer, num_batches = d2l.Timer(), len(train_iter) for epoch in range(num_epochs): # 训练损失之和,训练准确率之和,样本数 metric = d2l.Accumulator(3) net.train() for i, (X, y) in enumerate(train_iter): timer.start() optimizer.zero_grad() X, y = X.to(device), y.to(device) y_hat = net(X) l = loss(y_hat, y) l.backward() optimizer.step() with torch.no_grad(): metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0]) timer.stop() train_l = metric[0] / metric[2] train_acc = metric[1] / metric[2] if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1: animator.add(epoch + (i + 1) / num_batches, (train_l, train_acc, None)) test_acc = evaluate_accuracy_gpu(net, test_iter) animator.add(epoch + 1, (None, None, test_acc)) print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, ' f'test acc {test_acc:.3f}') print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec ' f'on {str(device)}')TensorFlow 版(通过TrainCallback回调可视化训练过程,用tf.distribute.OneDeviceStrategy把模型绑定到指定设备):
class TrainCallback(tf.keras.callbacks.Callback): #@save """一个以可视化的训练进展的回调""" def __init__(self, net, train_iter, test_iter, num_epochs, device_name): self.timer = d2l.Timer() self.animator = d2l.Animator( xlabel='epoch', xlim=[1, num_epochs], legend=[ 'train loss', 'train acc', 'test acc']) self.net = net self.train_iter = train_iter self.test_iter = test_iter self.num_epochs = num_epochs self.device_name = device_name def on_epoch_begin(self, epoch, logs=None): self.timer.start() def on_epoch_end(self, epoch, logs): self.timer.stop() test_acc = self.net.evaluate( self.test_iter, verbose=0, return_dict=True)['accuracy'] metrics = (logs['loss'], logs['accuracy'], test_acc) self.animator.add(epoch + 1, metrics) if epoch == self.num_epochs - 1: batch_size = next(iter(self.train_iter))[0].shape[0] num_examples = batch_size * tf.data.experimental.cardinality( self.train_iter).numpy() print(f'loss {metrics[0]:.3f}, train acc {metrics[1]:.3f}, ' f'test acc {metrics[2]:.3f}') print(f'{num_examples / self.timer.avg():.1f} examples/sec on ' f'{str(self.device_name)}') #@save def train_ch6(net_fn, train_iter, test_iter, num_epochs, lr, device): """用GPU训练模型(在第六章定义)""" device_name = device._device_name strategy = tf.distribute.OneDeviceStrategy(device_name) with strategy.scope(): optimizer = tf.keras.optimizers.SGD(learning_rate=lr) loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) net = net_fn() net.compile(optimizer=optimizer, loss=loss, metrics=['accuracy']) callback = TrainCallback(net, train_iter, test_iter, num_epochs, device_name) net.fit(train_iter, epochs=num_epochs, verbose=0, callbacks=[callback]) return netPaddlePaddle 版(用nn.initializer.XavierUniform初始化,paddle.optimizer.SGD优化,并在每个小批量上用paddle.to_tensor(..., place=device)迁移数据):
#@save def train_ch6(net, train_iter, test_iter, num_epochs, lr, device): """用GPU训练模型(在第六章定义)""" def init_weights(m): if type(m) == nn.Linear or type(m) == nn.Conv2D: nn.initializer.XavierUniform(m.weight) net.apply(init_weights) print('training on', device) net.to(device) optimizer = paddle.optimizer.SGD(learning_rate=lr, parameters=net.parameters()) loss = nn.CrossEntropyLoss() animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], legend=['train loss', 'train acc', 'test acc']) timer, num_batches = d2l.Timer(), len(train_iter) for epoch in range(num_epochs): # 训练损失之和,训练准确率之和,样本数 metric = d2l.Accumulator(3) net.train() for i, (X, y) in enumerate(train_iter): timer.start() optimizer.clear_grad() X, y = paddle.to_tensor(X, place=device), paddle.to_tensor(y, place=device) y_hat = net(X) l = loss(y_hat, y) l.backward() optimizer.step() with paddle.no_grad(): metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0]) timer.stop() train_l = metric[0] / metric[2] train_acc = metric[1] / metric[2] if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1: animator.add(epoch + (i + 1) / num_batches, (train_l, train_acc, None)) test_acc = evaluate_accuracy_gpu(net, test_iter) animator.add(epoch + 1, (None, None, test_acc)) print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, ' f'test acc {test_acc:.3f}') print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec ' f'on {str(device)}')启动训练
一切就绪后,用学习率 0.9、训练 10 个 epoch 启动训练与评估:
#@tab all lr, num_epochs = 0.9, 10 train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())其中d2l.try_gpu()(见 d2l/torch.py)在存在 GPU 时返回cuda:i,否则回退到cpu(),保证无 GPU 环境也能运行。训练结束时输出最终 loss、训练准确率、测试准确率以及吞吐量(examples/sec)。
d2l 工具包的配套实现
上述train_ch6、evaluate_accuracy_gpu、try_gpu等函数已作为公共工具保存在 d2l 包中,四个框架各自维护一份,便于后续章节直接复用:
- d2l/mxnet.py:MXNet 版
load_data_fashion_mnist、accuracy、try_gpu、evaluate_accuracy_gpu、train_ch6; - d2l/torch.py:PyTorch 版,定义于
sec_lenet章节; - d2l/tensorflow.py:TensorFlow 版;
- d2l/paddle.py:PaddlePaddle 版。
此外,Accumulator(多变量累加)、Animator(训练曲线可视化)、Timer(计时)等基础工具类也定义在同一批工具文件中,组成了全书统一的实验基础设施。
小结
- 卷积神经网络(CNN)是一类使用卷积层的网络;
- 在 CNN 中,我们组合使用卷积层、非线性激活函数和汇聚层;
- 为了构造高性能 CNN,通常对卷积层进行排列,逐渐降低其表示的空间分辨率,同时增加通道数;
- 在传统 CNN 中,卷积块编码得到的表征在输出之前需由一个或多个全连接层处理;
- LeNet 是最早发布的卷积神经网络之一,也是这类"卷积下采样 + 全连接分类"范式的经典范本。
练习与拓展方向
- 将平均汇聚层替换为最大汇聚层,观察会发生什么(准确率与收敛速度的变化);
- 尝试构建一个基于 LeNet 的更复杂网络以提高准确性,可调整的方向包括:
- 调整卷积窗口大小;
- 调整输出通道的数量;
- 调整激活函数(如 ReLU);
- 调整卷积层的数量;
- 调整全连接层的数量;
- 调整学习率和其他训练细节(例如初始化方式和轮数);
- 在原始 MNIST 数据集上尝试以上改进的网络;
- 显示不同输入(例如毛衣和外套)时 LeNet 第一层和第二层的激活值,直观感受卷积核提取到的边缘、纹理等特征。
延伸阅读
- 本节的配套工具函数与数据加载实现,可参见 d2l/torch.py(以及 d2l/mxnet.py、d2l/tensorflow.py、d2l/paddle.py 中对应的版本);
- 卷积层、填充与步幅、多输入输出通道、汇聚层等前置概念,可回溯 chapter_convolutional-neural-networks 章节目录下的 conv-layer.md、padding-and-strides.md、channels.md、pooling.md 等章节;
- 数据加载与 Fashion-MNIST 数据集的详细说明位于 chapter_linear-networks/image-classification-dataset.md;
- 本文源码分析所引用的工具函数均来自 d2l 目录下的框架封装模块,可直接在本地安装后
from d2l import torch as d2l使用。
- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
相关推荐
PyTorch深度学习教程:神经网络训练原理与实践
PyTorch深度学习教程:神经网络训练原理与实践 你是否曾经困惑于神经网络如何从随机猜测逐渐学会识别手写数字?本文将深入解析PyTorch中神经网络训练的核心
示例工程深入解析LeNet-5卷积神经网络架构及其PyTorch实现
深入解析LeNet 5卷积神经网络架构及其PyTorch实现 LeNet 5是由Yann LeCun等人在1998年提出的经典卷积神经网络架构,主要用于手写数字
示例工程机器学习深度学习教程日语重排序模型家族全览:从xsmall到large的japanese-reranker-cross-encoder系列终极指南
日语重排序模型家族全览:从xsmall到large的japanese reranker cross encoder系列终极指南 日语重排序模型在AI搜索和信息检
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考