7.6. 卷积神经网络(LeNet)¶ 在 SageMaker Studio Lab 中打开 Notebook
现在我们已经准备好实现一个功能齐全的卷积神经网络了。在之前介绍图像数据时,我们对Fashion-MNIST数据集中的服装图片应用了softmax回归线性模型(4.4节)和多层感知机(5.2节)。为了使这些数据能够被模型处理,我们首先将每个图像从\(28\times28\)的矩阵展平为一个固定长度的\(784\)维向量,然后在全连接层中处理它们。现在我们掌握了卷积层,我们可以在图像中保留空间结构。用卷积层代替全连接层的另一个好处是:模型更简洁、参数更少。
在本节中,我们将介绍*LeNet*,它是最早发布的卷积神经网络之一,因其在计算机视觉任务中的高效性能而受到广泛关注。这个模型是由AT&T贝尔实验室的研究员Yann LeCun在1989年提出的(并以其命名),目的是识别图像中的手写数字(LeCun et al., 1998)。这项工作代表了十年研究开发的成果,LeCun的团队发表了第一篇通过反向传播成功训练卷积神经网络的研究(LeCun et al., 1989)。
在当时,LeNet取得了与支持向量机(当时监督学习领域的主流方法)性能相媲美的出色成果,每个数字的错误率不到1%。LeNet最终被应用于自动取款机(ATM)中,用于处理存款时的数字识别。时至今日,一些自动取款机仍然运行着Yann LeCun和他的同事Leon Bottou在20世纪90年代编写的代码!
import torch
from torch import nn
from d2l import torch as d2l
from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
from types import FunctionType
import jax
from flax import linen as nn
from jax import numpy as jnp
from d2l import jax as d2l
import tensorflow as tf
from d2l import tensorflow as d2l
7.6.1. LeNet¶
总体来看,LeNet(LeNet-5)由两个部分组成:(i)一个由两个卷积层组成的卷积编码器;(ii)一个由三个全连接层组成的密集块。其架构如图7.6.1所示。
图 7.6.1 LeNet中的数据流。输入是手写数字,输出是10个可能结果的概率。¶
每个卷积块中的基本单元是一个卷积层、一个sigmoid激活函数和随后的平均汇聚层。请注意,虽然ReLU和最大汇聚层效果更好,但它们在当时还未被发现。每个卷积层使用一个\(5\times 5\)的卷积核和一个sigmoid激活函数。这些层将具有空间排列的输入映射到二维特征图的数量,通常会增加通道的数量。第一个卷积层有6个输出通道,而第二个卷积层有16个。每个\(2\times2\)的汇聚操作(步幅为2)通过空间下采样将维度减少\(4\)倍。卷积块的输出形状为(批量大小,通道数,高度,宽度)。
为了将卷积块的输出传递给稠密块,我们必须展平小批量中的每个样本。换言之,我们将这个四维输入转换成全连接层所期望的二维输入:提醒一下,我们想要的二维表示使用第一个维度索引小批量中的样本,第二个维度给出每个样本的展平向量表示。LeNet的稠密块有三个全连接层,分别有120、84和10个输出。因为我们仍在执行分类任务,所以10维输出层对应于可能的输出类别的数量。
虽然要真正理解LeNet的内部工作原理可能需要一些努力,但我们希望下面的代码片段能让您相信,使用现代深度学习框架实现此类模型非常简单。我们只需要实例化一个Sequential
块,并将适当的层连接起来,使用5.4.2.2节中介绍的Xavier初始化。
def init_cnn(module): #@save
"""Initialize weights for CNNs."""
if type(module) == nn.Linear or type(module) == nn.Conv2d:
nn.init.xavier_uniform_(module.weight)
class LeNet(d2l.Classifier): #@save
"""The LeNet-5 model."""
def __init__(self, lr=0.1, num_classes=10):
super().__init__()
self.save_hyperparameters()
self.net = nn.Sequential(
nn.LazyConv2d(6, kernel_size=5, padding=2), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.LazyConv2d(16, kernel_size=5), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.LazyLinear(120), nn.Sigmoid(),
nn.LazyLinear(84), nn.Sigmoid(),
nn.LazyLinear(num_classes))
class LeNet(d2l.Classifier): #@save
"""The LeNet-5 model."""
def __init__(self, lr=0.1, num_classes=10):
super().__init__()
self.save_hyperparameters()
self.net = nn.Sequential()
self.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),
nn.Dense(120, activation='sigmoid'),
nn.Dense(84, activation='sigmoid'),
nn.Dense(num_classes))
self.net.initialize(init.Xavier())
class LeNet(d2l.Classifier): #@save
"""The LeNet-5 model."""
lr: float = 0.1
num_classes: int = 10
kernel_init: FunctionType = nn.initializers.xavier_uniform
def setup(self):
self.net = nn.Sequential([
nn.Conv(features=6, kernel_size=(5, 5), padding='SAME',
kernel_init=self.kernel_init()),
nn.sigmoid,
lambda x: nn.avg_pool(x, window_shape=(2, 2), strides=(2, 2)),
nn.Conv(features=16, kernel_size=(5, 5), padding='VALID',
kernel_init=self.kernel_init()),
nn.sigmoid,
lambda x: nn.avg_pool(x, window_shape=(2, 2), strides=(2, 2)),
lambda x: x.reshape((x.shape[0], -1)), # flatten
nn.Dense(features=120, kernel_init=self.kernel_init()),
nn.sigmoid,
nn.Dense(features=84, kernel_init=self.kernel_init()),
nn.sigmoid,
nn.Dense(features=self.num_classes, kernel_init=self.kernel_init())
])
class LeNet(d2l.Classifier): #@save
"""The LeNet-5 model."""
def __init__(self, lr=0.1, num_classes=10):
super().__init__()
self.save_hyperparameters()
self.net = 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(num_classes)])
我们在LeNet的复现中做了一些改动,我们将高斯激活层替换为了softmax层。这大大简化了实现,特别是因为高斯解码器现在已经很少使用了。除此之外,这个网络与最初的LeNet-5架构相匹配。
让我们看看网络内部发生了什么。通过将一个单通道(黑白)\(28 \times 28\)的图像通过网络,并打印出每一层的输出形状,我们可以检查模型,以确保其操作与我们从图7.6.2中预期的一致。
让我们看看网络内部发生了什么。通过将一个单通道(黑白)\(28 \times 28\)的图像通过网络,并打印出每一层的输出形状,我们可以检查模型,以确保其操作与我们从图7.6.2中预期的一致。
让我们看看网络内部发生了什么。通过将一个单通道(黑白)\(28 \times 28\)的图像通过网络,并打印出每一层的输出形状,我们可以检查模型,以确保其操作与我们从图7.6.2中预期的一致。Flax提供了nn.tabulate
,这是一个简洁的方法来总结我们网络中的层和参数。在这里,我们使用bind
方法来创建一个有界模型。变量现在被绑定到d2l.Module
类,也就是说,这个有界模型变成了一个有状态的对象,然后可以用来访问Sequential
对象属性net
和其中的layers
。请注意,bind
方法只应用于交互式实验,而不是apply
方法的直接替代。
让我们看看网络内部发生了什么。通过将一个单通道(黑白)\(28 \times 28\)的图像通过网络,并打印出每一层的输出形状,我们可以检查模型,以确保其操作与我们从图7.6.2中预期的一致。
图 7.6.2 LeNet-5的压缩表示法。¶
@d2l.add_to_class(d2l.Classifier) #@save
def layer_summary(self, X_shape):
X = torch.randn(*X_shape)
for layer in self.net:
X = layer(X)
print(layer.__class__.__name__, 'output shape:\t', X.shape)
model = LeNet()
model.layer_summary((1, 1, 28, 28))
Conv2d output shape: torch.Size([1, 6, 28, 28])
Sigmoid output shape: torch.Size([1, 6, 28, 28])
AvgPool2d output shape: torch.Size([1, 6, 14, 14])
Conv2d output shape: torch.Size([1, 16, 10, 10])
Sigmoid output shape: torch.Size([1, 16, 10, 10])
AvgPool2d output shape: torch.Size([1, 16, 5, 5])
Flatten output shape: torch.Size([1, 400])
Linear output shape: torch.Size([1, 120])
Sigmoid output shape: torch.Size([1, 120])
Linear output shape: torch.Size([1, 84])
Sigmoid output shape: torch.Size([1, 84])
Linear output shape: torch.Size([1, 10])
@d2l.add_to_class(d2l.Classifier) #@save
def layer_summary(self, X_shape):
X = np.random.randn(*X_shape)
for layer in self.net:
X = layer(X)
print(layer.__class__.__name__, 'output shape:\t', X.shape)
model = LeNet()
model.layer_summary((1, 1, 28, 28))
Conv2D output shape: (1, 6, 28, 28)
AvgPool2D output shape: (1, 6, 14, 14)
Conv2D output shape: (1, 16, 10, 10)
AvgPool2D output shape: (1, 16, 5, 5)
Dense output shape: (1, 120)
Dense output shape: (1, 84)
Dense output shape: (1, 10)
[22:57:59] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
@d2l.add_to_class(d2l.Classifier) #@save
def layer_summary(self, X_shape, key=d2l.get_key()):
X = jnp.zeros(X_shape)
params = self.init(key, X)
bound_model = self.clone().bind(params, mutable=['batch_stats'])
_ = bound_model(X)
for layer in bound_model.net.layers:
X = layer(X)
print(layer.__class__.__name__, 'output shape:\t', X.shape)
model = LeNet()
model.layer_summary((1, 28, 28, 1))
Conv output shape: (1, 28, 28, 6)
PjitFunction output shape: (1, 28, 28, 6)
function output shape: (1, 14, 14, 6)
Conv output shape: (1, 10, 10, 16)
PjitFunction output shape: (1, 10, 10, 16)
function output shape: (1, 5, 5, 16)
function output shape: (1, 400)
Dense output shape: (1, 120)
PjitFunction output shape: (1, 120)
Dense output shape: (1, 84)
PjitFunction output shape: (1, 84)
Dense output shape: (1, 10)
@d2l.add_to_class(d2l.Classifier) #@save
def layer_summary(self, X_shape):
X = tf.random.normal(X_shape)
for layer in self.net.layers:
X = layer(X)
print(layer.__class__.__name__, 'output shape:\t', X.shape)
model = LeNet()
model.layer_summary((1, 28, 28, 1))
Conv2D output shape: (1, 28, 28, 6)
AveragePooling2D output shape: (1, 14, 14, 6)
Conv2D output shape: (1, 10, 10, 16)
AveragePooling2D output shape: (1, 5, 5, 16)
Flatten output shape: (1, 400)
Dense output shape: (1, 120)
Dense output shape: (1, 84)
Dense output shape: (1, 10)
请注意,在整个卷积块中,每一层表示的高度和宽度都会减小(与前一层相比)。第一个卷积层使用2像素的填充来补偿因使用\(5 \times 5\)卷积核而导致的高度和宽度减小。顺便说一句,原始MNIST OCR数据集中的\(28 \times 28\)像素图像大小是由于从原始的\(32 \times 32\)像素扫描中*修剪*了两个像素行(和列)的结果。这主要是为了节省空间(减少了30%),在那个兆字节都很重要的时代。
相比之下,第二个卷积层放弃了填充,因此高度和宽度都减少了4个像素。随着我们向上层堆叠,通道数逐层增加,从输入的1个增加到第一个卷积层后的6个,再到第二个卷积层后的16个。然而,每个汇聚层都会将高度和宽度减半。最后,每个全连接层都会降低维度,最终输出一个维度与类别数量相匹配的输出。
7.6.2. 训练¶
既然我们已经实现了模型,让我们进行一个实验,看看LeNet-5模型在Fashion-MNIST上的表现如何。
虽然卷积神经网络的参数较少,但它们的计算成本可能比同样深度的多层感知机更高,因为每个参数都参与了更多的乘法运算。如果您有GPU,现在可能是时候使用它来加速训练了。请注意,d2l.Trainer
类会处理所有细节。默认情况下,它会在可用的设备上初始化模型参数。就像多层感知机一样,我们的损失函数是交叉熵,我们通过小批量随机梯度下降来最小化它。
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128)
model = LeNet(lr=0.1)
model.apply_init([next(iter(data.get_dataloader(True)))[0]], init_cnn)
trainer.fit(model, data)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128)
model = LeNet(lr=0.1)
trainer.fit(model, data)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128)
model = LeNet(lr=0.1)
trainer.fit(model, data)
trainer = d2l.Trainer(max_epochs=10)
data = d2l.FashionMNIST(batch_size=128)
with d2l.try_gpu():
model = LeNet(lr=0.1)
trainer.fit(model, data)
7.6.3. 小结¶
我们在本章中取得了重大进展。我们从20世纪80年代的多层感知机发展到90年代和21世纪初的卷积神经网络。所提出的架构,例如LeNet-5的形式,即使在今天仍然具有意义。值得将LeNet-5在Fashion-MNIST上可达到的错误率与使用多层感知机(5.2节)所能达到的最佳结果以及使用更先进的架构如ResNet(8.6节)的结果进行比较。LeNet与后者比与前者更相似。正如我们将看到的,主要区别之一是,更多的计算能力使得更复杂的架构成为可能。
第二个区别是我们实现LeNet的相对容易程度。过去需要数月C++和汇编代码的工程挑战,用于改进早期基于Lisp的深度学习工具SN(Bottou and Le Cun, 1988),以及最终的模型实验,现在都可以在几分钟内完成。正是这种令人难以置信的生产力提升,极大地促进了深度学习模型开发的民主化。在下一章中,我们将深入这个兔子洞,看看它会带我们去向何方。
7.6.4. 练习¶
让我们对LeNet进行现代化改造。实现并测试以下更改:
1. 将平均汇聚层替换为最大汇聚层。
2. 将softmax层替换为ReLU。
3. 尝试更改LeNet风格网络的大小,以期在最大汇聚层和ReLU的基础上进一步提高其准确性。
4. 调整卷积窗口大小。
5. 调整输出通道数。
6. 调整卷积层数。
7. 调整全连接层数。
8. 调整学习率和其他训练细节(例如,初始化和迭代周期数)。
9. 在原始MNIST数据集上尝试改进后的网络。
10. 显示LeNet第一层和第二层对不同输入(例如,毛衣和外套)的激活。
11. 当您向网络输入非常不同的图像(例如,猫、汽车甚至随机噪声)时,激活会发生什么变化?