知识架构:

封面

本文的主要作用是在阅读过程中做一些摘录。对于「机器学习」领域,reta 虽然曾尝试从各个领域入门,也尝试训过一些模型,但是还是缺少系统性、结构性的学习。希望阅读本书能带来更多的收获吧。

与前面的一些笔记相比,本文更加侧重于「实践」。也就是说切实地提升自己的代码能力。

Part B 包含:

  • § 5 CNN
    • 基本概念:卷积层、填充与步长、多通道、池化、批量归一化
    • 模型的例子:LeNet、AlexNet、VGG、NiN、GoogLeNet、ResNet、DenseNet
  • § 6 RNN
    • 语言模型及其计算,N-gram 的概念
    • RNN 基本模型及其实现,字符数据集的制作
    • GRU, LSTM 的原理
    • Deep-RNN, bi-RNN

CNN

基本概念

卷积层

  • 二维互相关运算

img

如图所示,输入是一个高和宽均为3的二维数组。我们将该数组的形状记为 $3×3$ 或$(3,3)$。

核数组的高和宽分别为 2。该数组在卷积计算中又称卷积核或过滤器(filter)。

卷积核窗口(又称卷积窗口)的形状取决于卷积核的高和宽,即 $2×2$。

图中的阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:

$0×0+1×1+3×2+4×3=19$。

在二维互相关运算中,卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,按照特定的步长,依次在输入数组上滑动。

当卷积窗口滑动到某一位置时,窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素。

  • 从互相关运算到卷积运算

实际上,卷积运算与互相关运算类似。为了得到卷积运算的输出,我们只需将核数组左右翻转并上下翻转,再与输入数组做互相关运算

  • Feature Map 与 Receptive Field

二维卷积层输出的二维数组可以看作是输入在空间维度(宽和高)上某一级的表征,也叫特征图(feature map)。

影响元素 x 的前向计算的所有可能输入区域(可能大于输入的实际尺寸)叫做的 x 感受野(receptive field)。

以图为例,输入中阴影部分的四个元素是输出中阴影部分元素的感受野。

我们将图中形状为 $2×2$ 的输出记为 $Y$,并考虑一个更深的卷积神经网络:将 $Y$ 与另一个形状为 $2×2$ 的核数组做互相关运算,输出单个元素$z$。那么,$z$ 在 $Y$ 上的 Receptive Field 为 $Y$ 的全部四个元素,在 $x$ 上的感受野包括其中全部 9 个元素。

可见,我们可以通过更深的卷积神经网络使特征图中单个元素的感受野变得更加广阔,从而捕捉输入上更大尺寸的特征

Padding & Stride

本节我们将介绍卷积层的两个超参数,即填充和步幅。它们可以对给定形状的输入和卷积核改变输出形状。

  • Padding

填充(padding)是指在输入高和宽的两侧填充元素(通常是 0 元素)。

img

图中我们在原输入高和宽的两侧分别添加了值为 0 的元素,使得输入高和宽从 3 变成了 5 ,并导致输出高和宽由 2 增加到 4。

一般来说,如果在高的两侧一共填充 $p_h$ 行,在宽的两侧一共填充 $p_w$ 列,在很多情况下,我们会设置 $p_h=k_h−1$ 和 $p_w=k_w−1$ 来使输入和输出具有相同的高和宽,其中 $k_h×k_w$ 是卷积核窗口形状。这样会方便在构造网络时推测每个层的输出形状。

假设这里 $k_h$ 是奇数,我们会在高的两侧分别填充 $p_h/2$ 行。如果 $k_h$ 是偶数,一种可能是在输入的顶端一侧填充 $⌈p_h/2⌉$ 行,而在底端一侧填充 $⌊p_h/2⌋$ 行。在宽的两侧填充同理。卷积神经网络经常使用奇数高宽的卷积核 $k_h×k_w$,如 1、3、5 和 7,所以两端上的填充个数相等。

对任意的二维数组 X,设它的第 i 行第 j 列的元素为 X[i,j]。当两端上的填充个数相等,并使输入和输出具有相同的高和宽时,我们就知道输出 Y[i,j] 是由输入以 X[i,j] 为中心的窗口同卷积核进行互相关计算得到的。

  • Stride

在上一节里我们介绍了二维互相关运算。卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。我们将每次滑动的行数和列数称为步幅(stride)。

img

目前我们看到的例子里,在高和宽两个方向上步幅均为1。我们也可以使用更大步幅。

图中展示了在高上步幅为 3、在宽上步幅为 2 的二维互相关运算。可以看到,输出第一列第二个元素时,卷积窗口向下滑动了 3 行,而在输出第一行第二个元素时卷积窗口向右滑动了 2 列。当卷积窗口在输入上再向右滑动 2 列时,由于输入元素无法填满窗口,无结果输出。

多通道

前面两节里我们用到的输入和输出都是二维数组,但真实数据的维度经常更高。

例如,彩色图像在高和宽 2 个维度外还有 RGB(红、绿、蓝)3 个颜色通道。

假设彩色图像的高和宽分别是 $h$ 和 $w$(像素),那么它可以表示为一个 $3×h×w$ 的多维数组。

我们将大小为 3 的这一维称为通道(channel)维。

本节我们将介绍含多个输入通道或多个输出通道的卷积核。

  • 多输入通道

img

当输入数据含多个通道时,我们需要构造一个输入通道数与输入数据的通道数相同的卷积核,从而能够与含多通道的输入数据做互相关运算。

含多个通道的输入数据与多输入通道的卷积核做二维互相关运算的输出:在各个通道上对输入的二维数组和卷积核的二维核数组做互相关运算,再将这些互相关运算的输出相加。

  • 多输出通道

当输入通道有多个时,因为我们对各个通道的结果做了累加,所以不论输入通道数是多少,输出通道数总是为 1。设卷积核输入通道数和输出通道数分别为 $c_i$ 和 $c_o$,高和宽分别为 $k_h$ 和 $k_w$。

如果希望得到含多个通道的输出,我们可以为每个输出通道分别创建形状为 $c_i×k_h×k_w$ 的核数组。将它们在输出通道维上连结,卷积核的形状即 $c_o×c_i×k_h×k_w$。

  • 1 x 1 卷积层

img

因为使用了最小窗口,$1×1$ 卷积失去了卷积层可以识别高和宽维度上相邻元素构成的模式的功能。实际上,$1×1$ 卷积的主要计算发生在通道维上。

值得注意的是,输入和输出具有相同的高和宽。输出中的每个元素来自输入中在高和宽上相同位置的元素在不同通道之间的按权重累加

假设我们将通道维当作特征维,将高和宽维度上的元素当成数据样本,那么 $1×1$ 卷积层的作用与全连接层等价

Pooling

设任意二维数组 Xij 列的元素为 X[i, j]。如果我们构造的 $1×2$卷积核 [1,−1] 输出 Y[i, j]=1,那么说明输入中 X[i, j]X[i, j+1] 数值不一样。这可能意味着物体边缘通过这两个元素之间。

实际图像里,我们感兴趣的物体不会总出现在固定位置:即使我们连续拍摄同一个物体也极有可能出现像素位置上的偏移。这会导致同一个边缘对应的输出可能出现在卷积输出 Y 中的不同位置,进而对后面的模式识别造成不便。

在本节中我们介绍池化(pooling)层,它的提出是为了缓解卷积层对位置的过度敏感性

  • 2D-MaxPooling & Mean Pooling

img

同卷积层一样,池化层每次对输入数据的一个固定形状窗口(又称池化窗口)中的元素计算输出。

不同于卷积层里计算输入和核的互相关性,池化层直接计算池化窗口内元素的最大值或者平均值。该运算也分别叫做最大池化或平均池化。

让我们再次回到本节开始提到的物体边缘检测的例子。现在我们将卷积层的输出作为 $2×2$ 最大池化的输入。设该卷积层输入是 X、池化层输出为 Y。无论是 X[i, j]X[i, j+1] 值不同,还是 X[i, j+1]X[i, j+2] 不同,池化层输出均有 Y[i, j]=1。也就是说,使用 $2×2$ 最大池化层时,只要卷积层识别的模式在高和宽上移动不超过一个元素,我们依然可以将它检测出来。

  • Padding & Stride

同卷积层一样,池化层也可以在输入的高和宽两侧的填充并调整窗口的移动步幅来改变输出形状。池化层填充和步幅与卷积层填充和步幅的工作机制一样。我们将通过 nn 模块里的二维最大池化层 MaxPool2d 来演示池化层填充和步幅的工作机制。

1
2
X = torch.arange(16, dtype=torch.float).view((1, 1, 4, 4)) # C_o * C_i * K_h * K_w
print(X)

输出:

1
2
3
4
tensor([[[[ 0.,  1.,  2.,  3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]]]])

默认情况下,MaxPool2d 实例里步幅和池化窗口形状相同。下面使用形状为 (3,3) 的池化窗口,默认获得形状为 (3,3) 的步幅。

1
2
pool2d = nn.MaxPool2d(3)
pool2d(X)

输出:

1
tensor([[[[10.]]]])

我们可以手动指定步幅和填充。

1
2
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)

输出:

1
2
tensor([[[[ 5.,  7.],
[13., 15.]]]])

当然,我们也可以指定非正方形的池化窗口,并分别指定高和宽上的填充和步幅。

1
2
pool2d = nn.MaxPool2d((2, 4), padding=(1, 2), stride=(2, 3))
pool2d(X)

输出:

1
2
3
tensor([[[[ 1.,  3.],
[ 9., 11.],
[13., 15.]]]])
  • 多通道

在处理多通道输入数据时,池化层对每个输入通道分别池化,而不是像卷积层那样将各通道的输入按通道相加

这意味着池化层的输出通道数与输入通道数相等。下面将数组 XX+1 在通道维上连结来构造通道数为 2 的输入。

1
2
X = torch.cat((X, X + 1), dim=1)
X

输出:

1
2
3
4
5
6
7
8
tensor([[[[ 0.,  1.,  2.,  3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]],
[[ 1., 2., 3., 4.],
[ 5., 6., 7., 8.],
[ 9., 10., 11., 12.],
[13., 14., 15., 16.]]]])

池化后,我们发现输出通道数仍然是2。

1
2
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)

输出:

1
2
3
4
tensor([[[[ 5.,  7.],
[13., 15.]],
[[ 6., 8.],
[14., 16.]]]])

批量归一化

本节我们介绍批量归一化(batch normalization)层,它能让较深的神经网络的训练变得更加容易。

  • 为什么要有 Batch Normalization?

在预测回归问题里,我们对输入数据做了标准化处理:处理后的任意一个特征在数据集中所有样本上的均值为 0、标准差为 1。标准化处理输入数据使各个特征的分布相近:这往往更容易训练出有效的模型。

通常来说,数据标准化预处理对于浅层模型就足够有效了。随着模型训练的进行,当每层中参数更新时,靠近输出层的输出较难出现剧烈变化。但对深层神经网络来说,即使输入数据已做标准化,训练中模型参数的更新依然很容易造成靠近输出层输出的剧烈变化。这种计算数值的不稳定性通常令我们难以训练出有效的深度模型。

批量归一化的提出正是为了应对深度模型训练的挑战。在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。批量归一化和下一节将要介绍的残差网络为训练和设计深度模型提供了两类重要思路

  • 怎么做 Batch Normalization?

对全连接层和卷积层做批量归一化的方法稍有不同。下面我们将分别介绍这两种情况下的批量归一化。

对 Fully Connected Layer 的 Batch Normalization

我们先考虑如何对全连接层做批量归一化。通常,我们将批量归一化层置于全连接层中的仿射变换和激活函数之间。设全连接层的输入为 $u$,权重参数和偏差参数分别为 $W$ 和 $b$,激活函数为 $ϕ$。设批量归一化的运算符为 $BN$。那么,使用批量归一化的全连接层的输出为 $ϕ(BN(Wu+b))$。

下面我们解释 $BN$ 算符是什么。

考虑一个由 $m$ 个样本组成的 Mini-batch,仿射变换的输出为一个新的 Mini-batch $\mathcal{B}=x^{(1)},…,x^{(m)}$。它们正是批量归一化层的输入。对于小批量 $\mathcal{B}$ 中任意样本 $x^{(i)}∈\mathbb{R}^d,1≤i≤m$,批量归一化层的输出同样是 $d$ 维向量$y^{(i)}=BN(x^{(i)})$,并由以下几步求得。

这里 $ϵ>0$ 是一个很小的常数,是为了保证分母大于 0。在上面标准化的基础上,批量归一化层引入了两个可以学习的模型参数,拉伸(scale)参数 $γ$ 和偏移(shift)参数 $β$。这两个参数和 $x^{(i)}$ 形状相同,皆为 $d$ 维向量。它们与 $x^{(i)}$ 分别做 Hadamard Product(符号$⊙$)和加法计算:

至此,我们得到了 $x^{(i)}$ 的批量归一化的输出 $y^{(i)}$。值得注意的是,可学习的拉伸和偏移参数保留了不对 $\hat{x}^{(i)}$ 做批量归一化的可能:此时只需学出 $γ=\sqrt{σ_\mathcal{B}^2+ϵ}$ 和 $β=μB$。我们可以对此这样理解:如果批量归一化无益,理论上,学出的模型可以不使用批量归一化。

对 Conv. Layer 的 Batch Normalization

对卷积层来说,批量归一化发生在卷积计算之后、应用激活函数之前。

如果卷积计算输出多个通道,我们需要对这些通道的输出分别做批量归一化,且每个通道都拥有独立的拉伸和偏移参数,并均为标量

设小批量中有 $m$ 个样本,在单个通道上,假设卷积计算输出的高和宽分别为 $p$ 和 $q$。我们需要对该通道中 $m×p×q$ 个元素同时做批量归一化。对这些元素做标准化计算时,我们使用相同的均值和方差,即该通道中 $m×p×q$ 个元素的均值和方差。

预测时的 Batch Normalization

使用批量归一化训练时,我们可以将批量大小设得大一点,从而使批量内样本的均值和方差的计算都较为准确。将训练好的模型用于预测时,我们希望模型对于任意输入都有确定的输出。因此,单个样本的输出不应取决于批量归一化所需要的随机小批量中的均值和方差。一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。可见,和丢弃层一样,批量归一化层在训练模式和预测模式下的计算结果也是不一样的。

  • 实现(Simple ver.)

与我们刚刚自己定义的 BatchNorm 类相比,Pytorch 中 nn 模块定义的 BatchNorm1dBatchNorm2d 类使用起来更加简单,二者分别用于全连接层和卷积层,都需要指定输入的 num_features 参数值。下面我们用 PyTorch 实现使用批量归一化的 LeNet。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
net = nn.Sequential(
nn.Conv2d(1, 6, 5), # in_channels, out_channels, kernel_size
nn.BatchNorm2d(6),
nn.Sigmoid(),

nn.MaxPool2d(2, 2), # kernel_size, stride

nn.Conv2d(6, 16, 5),
nn.BatchNorm2d(16),
nn.Sigmoid(),

nn.MaxPool2d(2, 2),

d2l.FlattenLayer(),

nn.Linear(16*4*4, 120),
nn.BatchNorm1d(120),
nn.Sigmoid(),

nn.Linear(120, 84),
nn.BatchNorm1d(84),
nn.Sigmoid(),

nn.Linear(84, 10)
)

CNN 的例子

LeNet

之前我们曾使用 MLP 对 Fashion-MNIST 数据集中的图像进行分类。每张图像高和宽均是 28 像素。我们将图像中的像素逐行展开,得到长度为 784 的向量,并输入进全连接层中。然而,这种分类方法有一定的局限性。

  1. 图像在同一列邻近的像素在这个向量中可能相距较远。它们构成的模式可能难以被模型识别。
  2. 对于大尺寸的输入图像,使用全连接层容易造成模型过大。假设输入是高和宽均为 1000 像素的彩色照片(含 3 个通道)。即使全连接层输出个数仍是 256,该层权重参数的形状是$3,000,000×256$:它占用了大约 3 GB 的内存或显存。这带来过复杂的模型和过高的存储开销。

卷积层尝试解决这两个问题。一方面,卷积层保留输入形状,使图像的像素在高和宽两个方向上的相关性均可能被有效识别;另一方面,卷积层通过滑动窗口将同一卷积核与不同位置的输入重复计算,从而避免参数尺寸过大。

卷积神经网络就是含卷积层的网络。本节里我们将介绍一个早期用来识别手写数字图像的卷积神经网络:LeNet。

img

LeNet分为卷积层块和全连接层块两个部分。下面我们分别介绍这两个模块。

卷积层块里的基本单位是卷积层后接最大池化层

  • 卷积层用来识别图像里的空间模式,如线条和物体局部
  • 之后的最大池化层则用来降低卷积层对位置的敏感性

卷积层块由两个这样的基本单位重复堆叠构成。在卷积层块中,每个卷积层都使用 5×5 的窗口,并在输出上使用 sigmoid 激活函数。第一个卷积层输出通道数为 6,第二个卷积层输出通道数则增加到 16。这是因为第二个卷积层比第一个卷积层的输入的高和宽要小,所以增加输出通道使两个卷积层的参数尺寸类似。卷积层块的两个最大池化层的窗口形状均为 2×2,且步幅为 2。由于池化窗口与步幅形状相同,池化窗口在输入上每次滑动所覆盖的区域互不重叠。

卷积层块的输出形状为 (批量大小, 通道, 高, 宽)。当卷积层块的输出传入全连接层块时,全连接层块会将小批量中每个样本变平(flatten)。也就是说,全连接层的输入形状将变成二维,其中第二维是每个样本变平后的向量表示,且向量长度为通道、高和宽的乘积。全连接层块含 3 个全连接层。它们的输出个数分别是 120、84 和 10,其中 10 为输出的类别个数。

下面我们通过 Sequential 类来实现 LeNet 模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(1, 6, 5), # in_channels, out_channels, kernel_size
nn.Sigmoid(),
nn.MaxPool2d(2, 2), # kernel_size, stride
nn.Conv2d(6, 16, 5),
nn.Sigmoid(),
nn.MaxPool2d(2, 2)
)
self.fc = nn.Sequential(
nn.Linear(16*4*4, 120),
nn.Sigmoid(),
nn.Linear(120, 84),
nn.Sigmoid(),
nn.Linear(84, 10)
)

def forward(self, img):
feature = self.conv(img)
output = self.fc(feature.view(img.shape[0], -1))
return output

这里使用 GPU 进行计算,对相关函数的修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 本函数已保存在d2lzh_pytorch包中方便以后使用
def train_ch5(net, train_iter, test_iter, batch_size, optimizer, device, num_epochs):
net = net.to(device)
print("training on ", device)
loss = torch.nn.CrossEntropyLoss()
for epoch in range(num_epochs):
train_l_sum, train_acc_sum, n, batch_count, start = 0.0, 0.0, 0, 0, time.time()
for X, y in train_iter:
X = X.to(device)
y = y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
optimizer.zero_grad()
l.backward()
optimizer.step()
train_l_sum += l.cpu().item()
train_acc_sum += (y_hat.argmax(dim=1) == y).sum().cpu().item()
n += y.shape[0]
batch_count += 1
test_acc = evaluate_accuracy(test_iter, net)
print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, time %.1f sec'
% (epoch + 1, train_l_sum / batch_count, train_acc_sum / n, test_acc, time.time() - start))

# 本函数已保存在d2lzh_pytorch包中方便以后使用。该函数将被逐步改进。
def evaluate_accuracy(data_iter, net, device=None):
if device is None and isinstance(net, torch.nn.Module):
# 如果没指定device就使用net的device
device = list(net.parameters())[0].device
acc_sum, n = 0.0, 0
with torch.no_grad():
for X, y in data_iter:
if isinstance(net, torch.nn.Module):
net.eval() # 评估模式, 这会关闭 dropout
acc_sum += (net(X.to(device)).argmax(dim=1) == y.to(device)).float().sum().cpu().item()
net.train() # 改回训练模式
else: # 自定义的模型, 3.13节之后不会用到, 不考虑GPU
if('is_training' in net.__code__.co_varnames): # 如果有 is_training 这个参数
# 将 is_training 设置成 False
acc_sum += (net(X, is_training=False).argmax(dim=1) == y).float().sum().item()
else:
acc_sum += (net(X).argmax(dim=1) == y).float().sum().item()
n += y.shape[0]
return acc_sum / n

AlexNet

img

  • Larger parameter size
  • Use ReLU instead of sigmoid
  • Introducing Dropout
  • Data augmentation

VGG

VGG 提出了可以通过重复使用简单的基础块来构建深度模型的思路。

VGG块的组成规律是:连续使用数个相同的填充为 1、窗口形状为 3×3 的卷积层后接上一个步幅为 2、窗口形状为 $2×2$ 的最大池化层。卷积层保持输入的高和宽不变,而池化层则对其减半。我们使用vgg_block函数来实现这个基础的VGG 块,它可以指定卷积层的数量和输入输出通道数。

1
2
3
4
5
6
7
8
9
10
def vgg_block(num_convs, in_channels, out_channels):
blk = []
for i in range(num_convs):
if i == 0:
blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
else:
blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
blk.append(nn.ReLU())
blk.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 这里会使宽高减半
return nn.Sequential(*blk)

现在我们构造一个 VGG 网络。它有 5 个 vgg_block,前 2 块使用单卷积层 num_convs=1,而后 3 块使用双卷积层 num_convs=2。第一块的输入输出通道分别是 1(因为下面要使用的 Fashion-MNIST 数据的通道数为 1)和 64,之后每次对输出通道数翻倍,直到变为 512。因为这个网络使用了 8 个卷积层和 3 个全连接层,所以经常被称为 VGG-11。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
conv_arch = ((1, 1, 64), (1, 64, 128), (2, 128, 256), (2, 256, 512), (2, 512, 512))
# 经过5个 vgg_block, 宽高会减半5次, 变成 224/32 = 7
fc_features = 512 * 7 * 7 # c * w * h
fc_hidden_units = 4096 # 任意

def vgg(conv_arch, fc_features, fc_hidden_units=4096):
net = nn.Sequential()
# 卷积层部分
for i, (num_convs, in_channels, out_channels) in enumerate(conv_arch):
# 每经过一个vgg_block都会使宽高减半
net.add_module("vgg_block_" + str(i+1), vgg_block(num_convs, in_channels, out_channels))
# 全连接层部分
net.add_module("fc", nn.Sequential(d2l.FlattenLayer(),
nn.Linear(fc_features, fc_hidden_units),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(fc_hidden_units, fc_hidden_units),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(fc_hidden_units, 10)
))
return net

NiN

前几节介绍的 LeNet、AlexNet 和 VGG 在设计上的共同之处是:先以由卷积层构成的模块充分抽取空间特征,再以由全连接层构成的模块来输出分类结果。其中,AlexNet 和 VGG 对 LeNet 的改进主要在于如何对这两个模块加宽(增加通道数)和加深。

本节我们介绍网络中的网络(NiN)。它提出了另外一个思路,即串联多个由卷积层和“全连接”层构成的小网络来构建一个深层网络。NiN使用 $1×1$ 卷积层来替代全连接层,从而使空间信息能够自然传递到后面的层中去。

img

左图是 AlexNet 和 VGG 的网络结构局部,右图是 NiN 的网络结构局部。

NiN 块是 NiN 中的基础块。它由一个卷积层加两个充当全连接层的 $1×1$ 卷积层串联而成。其中第一个卷积层的超参数可以自行设置,而第二和第三个卷积层的超参数一般是固定的。

1
2
3
4
5
6
7
8
def nin_block(in_channels, out_channels, kernel_size, stride, padding):
blk = nn.Sequential(nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding),
nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1),
nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1),
nn.ReLU())
return blk

NiN 模型使用卷积窗口形状分别为 11×11、5×5 和 3×3 的卷积层,相应的输出通道数也与 AlexNet 中的一致。每个 NiN 块后接一个步幅为 2、窗口形状为 3×3 的最大池化层。

除使用 NiN 块以外,NiN 还有一个设计与 AlexNet 显著不同:NiN 去掉了 AlexNet 最后的3个全连接层,取而代之地,NiN 使用了输出通道数等于标签类别数的NiN 块,然后使用全局平均池化层对每个通道中所有元素求平均并直接用于分类。这里的全局平均池化层即窗口形状等于输入空间维形状的平均池化层。NiN 的这个设计的好处是可以显著减小模型参数尺寸,从而缓解过拟合。然而,该设计有时会造成获得有效模型的训练时间的增加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import torch.nn.functional as F
class GlobalAvgPool2d(nn.Module):
# 全局平均池化层可通过将池化窗口形状设置成输入的高和宽实现
def __init__(self):
super(GlobalAvgPool2d, self).__init__()
def forward(self, x):
return F.avg_pool2d(x, kernel_size=x.size()[2:])

net = nn.Sequential(
nin_block(1, 96, kernel_size=11, stride=4, padding=0),
nn.MaxPool2d(kernel_size=3, stride=2),
nin_block(96, 256, kernel_size=5, stride=1, padding=2),
nn.MaxPool2d(kernel_size=3, stride=2),
nin_block(256, 384, kernel_size=3, stride=1, padding=1),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Dropout(0.5),
# 标签类别数是10
nin_block(384, 10, kernel_size=3, stride=1, padding=1),
GlobalAvgPool2d(),
# 将四维的输出转成二维的输出,其形状为(批量大小, 10)
d2l.FlattenLayer())

GoogLeNet

img

图为 Inception 块的结构。从这里我们可以意识到的一点是,以 Block 为单位来拼凑模型的这种方法逐渐火热…

ResNet

让我们先思考一个问题:对神经网络模型添加新的层,充分训练后的模型是否只可能更有效地降低训练误差?

理论上,原模型解的空间只是新模型解的空间的子空间。也就是说,如果我们能将新添加的层训练成恒等映射 $f(x)=x$,新模型和原模型将同样有效。由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。

然而在实践中,添加过多的层后训练误差往往不降反升。即使利用批量归一化带来的数值稳定性使训练深层模型更加容易,该问题仍然存在。针对这一问题,残差网络被提出。

  • 残差块

让我们聚焦于神经网络局部。如图所示,设输入为 $x$。假设我们希望学出的理想映射为 $f(x)$,从而作为图上方激活函数的输入。左图虚线框中的部分需要直接拟合出该映射 $f(x)$,而右图虚线框中的部分则需要拟合出有关恒等映射的残差映射 $f(x)−x$。

img

残差映射在实际中往往更容易优化。以本节开头提到的恒等映射作为我们希望学出的理想映射 $f(x)$。我们只需将图中右图虚线框内上方的加权运算(如仿射)的权重和偏差参数学成 $0$,那么 $f(x)$ 即为恒等映射。

实际中,当理想映射 $f(x)$ 极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。右图也是 ResNet 的基础块,即残差块(residual block)。在残差块中,输入可通过跨层的数据线路更快地向前传播。

ResNet 沿用了 VGG 全 $3×3$ 卷积层的设计。残差块里首先有 2 个有相同输出通道数的 $3×3$ 卷积层。每个卷积层后接一个批量归一化层和ReLU激活函数。然后我们将输入跳过这两个卷积运算后直接加在最后的ReLU激活函数前。这样的设计要求两个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,就需要引入一个额外的 $1×1$ 卷积层来将输入变换成需要的形状后再做相加运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Residual(nn.Module):  # 本类已保存在d2lzh_pytorch包中方便以后使用
def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
super(Residual, self).__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm2d(out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)

def forward(self, X):
Y = F.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
return F.relu(Y + X)
  • ResNet 模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
net = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
if first_block:
assert in_channels == out_channels # 第一个模块的通道数同输入通道数一致
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
else:
blk.append(Residual(out_channels, out_channels))
return nn.Sequential(*blk)

net.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
net.add_module("resnet_block2", resnet_block(64, 128, 2))
net.add_module("resnet_block3", resnet_block(128, 256, 2))
net.add_module("resnet_block4", resnet_block(256, 512, 2))

net.add_module("global_avg_pool", d2l.GlobalAvgPool2d()) # GlobalAvgPool2d的输出: (Batch, 512, 1, 1)
net.add_module("fc", nn.Sequential(d2l.FlattenLayer(), nn.Linear(512, 10)))

DenseNet

ResNet 中的跨层连接设计引申出了数个后续工作。本节我们介绍其中的一个:稠密连接网络。

img

图中将部分前后相邻的运算抽象为模块 A 和模块 B。

与 ResNet 的主要区别在于,DenseNet 里模块 B 的输出不是像 ResNet 那样和模块 A 的输出相加,而是在通道维上连结。这样模块 A 的输出可以直接传入模块 B 后面的层。在这个设计里,模块 A 直接跟模块 B 后面的所有层连接在了一起。这也是它被称为“稠密连接”的原因。

  • DenseBlock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def conv_block(in_channels, out_channels):
blk = nn.Sequential(nn.BatchNorm2d(in_channels),
nn.ReLU(),
nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
return blk

class DenseBlock(nn.Module):
def __init__(self, num_convs, in_channels, out_channels):
super(DenseBlock, self).__init__()
net = []
for i in range(num_convs):
in_c = in_channels + i * out_channels
net.append(conv_block(in_c, out_channels))
self.net = nn.ModuleList(net)
self.out_channels = in_channels + num_convs * out_channels # 计算输出通道数

def forward(self, X):
for blk in self.net:
Y = blk(X)
X = torch.cat((X, Y), dim=1) # 在通道维上将输入和输出连结
return X
  • 过渡层

由于每个稠密块都会带来通道数的增加,使用过多则会带来过于复杂的模型。过渡层用来控制模型复杂度。它通过 $1×1$ 卷积层来减小通道数,并使用步幅为 2 的平均池化层减半高和宽,从而进一步降低模型复杂度。

1
2
3
4
5
6
7
def transition_block(in_channels, out_channels):
blk = nn.Sequential(
nn.BatchNorm2d(in_channels),
nn.ReLU(),
nn.Conv2d(in_channels, out_channels, kernel_size=1),
nn.AvgPool2d(kernel_size=2, stride=2))
return blk
  • DenseNet 模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
net = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

num_channels, growth_rate = 64, 32 # num_channels为当前的通道数
num_convs_in_dense_blocks = [4, 4, 4, 4]

for i, num_convs in enumerate(num_convs_in_dense_blocks):
DB = DenseBlock(num_convs, num_channels, growth_rate)
net.add_module("DenseBlock_%d" % i, DB)
# 上一个稠密块的输出通道数
num_channels = DB.out_channels
# 在稠密块之间加入通道数减半的过渡层
if i != len(num_convs_in_dense_blocks) - 1:
net.add_module("transition_block_%d" % i, transition_block(num_channels, num_channels // 2))
num_channels = num_channels // 2

net.add_module("BN", nn.BatchNorm2d(num_channels))
net.add_module("relu", nn.ReLU())
net.add_module("global_avg_pool", d2l.GlobalAvgPool2d()) # GlobalAvgPool2d的输出: (Batch, num_channels, 1, 1)
net.add_module("fc", nn.Sequential(d2l.FlattenLayer(), nn.Linear(num_channels, 10)))

RNN

语言模型

语言模型(language model)是自然语言处理的重要技术。自然语言处理中最常见的数据是文本数据。

我们可以把一段自然语言文本看作一段离散的时间序列。假设一段长度为 $T$ 的文本中的词依次为 $w_1,w_2,…,w_T$,那么在离散的时间序列中,$w_t(1≤t≤T)$可看作在时间步(time step)$t$ 的输出或标签。

给定一个长度为 $T$ 的词的序列 $w_1,w_2,…,w_T$,语言模型将计算该序列的概率:$P(w_1,w_2,…,w_T)$.

语言模型可用于提升语音识别和机器翻译的性能。

例如,在语音识别中,给定一段“厨房里食油用完了”的语音,有可能会输出“厨房里食油用完了”和“厨房里石油用完了”这两个读音完全一样的文本序列。如果语言模型判断出前者的概率大于后者的概率,我们就可以根据相同读音的语音输出“厨房里食油用完了”的文本序列。

在机器翻译中,如果对英文“you go first”逐词翻译成中文的话,可能得到“你走先”“你先走”等排列方式的文本序列。如果语言模型判断出“你先走”的概率大于其他排列方式的文本序列的概率,我们就可以把“you go first”翻译成“你先走”。

语言模型的计算

根据《概率论》课程学过的有关知识,我们不难理解:

那么,这些概率该如何获得呢?

设训练数据集为一个大型文本语料库,如维基百科的所有条目。词的概率可以通过该词在训练数据集中的相对词频来计算。例如,$P(w_1)$ 可以计算为 $w_1$ 在训练数据集中的词频(词出现的次数)与训练数据集的总词数之比。因此,根据条件概率定义,一个词在给定前几个词的情况下的条件概率也可以通过训练数据集中的相对词频计算。再例如,$P(w_2∣w_1)$ 可以计算为 $w_1,w_2$ 两词相邻的频率与 $w_1$ 词频的比值,因为该比值即 $P(w_1,w_2)$ 与 $P(w_1)$ 之比;而 $P(w_3∣w_1,w_2)$ 同理可以计算为 $w_1$、$w_2$ 和 $w_3$ 三词相邻的频率与 $w_1$ 和 $w_2$ 两词相邻的频率的比值。以此类推。

N-grams

当序列长度增加时,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。$n$ 元语法通过马尔可夫假设(虽然并不一定成立)简化了语言模型的计算。这里的马尔可夫假设是指,一个词的出现只与前面 n 个词相关

如果 $n=1$,那么有 $P(w_3∣w_1,w_2)=P(w_3∣w_2)$。

如果基于 $n−1$ 阶马尔可夫链,我们可以将语言模型改写为:$P(w_1,w_2,…,w_T)\prod_{t=1}^{T}P(w_t∣w_{t−(n−1)},…,w_{t−1})$。

当 $n$ 分别为 1、2 和 3 时,我们将其分别称作一元语法(unigram)、二元语法(bigram)和三元语法(trigram)。

当 $n$ 较小时,$n$ 元语法往往并不准确。然而,当 $n$ 较大时,$n$ 元语法需要计算并存储大量的词频和多词相邻频率。那么,有没有方法在语言模型中更好地平衡以上这两点呢?我们将在本章探究这样的方法。

RNN

本节将介绍循环神经网络。它并非刚性地记忆所有固定长度的序列,而是通过隐藏状态来存储之前时间步的信息。首先我们回忆一下前面介绍过的多层感知机,然后描述如何添加隐藏状态来将它变成循环神经网络。

  • 不含隐藏状态的神经网络

让我们考虑一个含单隐藏层的多层感知机。给定样本数为 $n$、输入个数(特征数或特征向量维度)为 $d$ 的小批量数据样本 $X∈\mathbb{R}^{n×d}$。设隐藏层的激活函数为 $ϕ$,那么隐藏层的输出 $H∈\mathbb{R}^{n×h}$ 计算为

其中隐藏层权重参数 $W_{xh}∈\mathbb{R}^{d×h}$,隐藏层偏差参数 $b_h∈\mathbb{R}^{1×h}$,$h$ 为隐藏单元个数。上式相加的两项形状不同,因此将按照广播机制相加。把隐藏变量 $H$ 作为输出层的输入,且设输出个数为 $q$(如分类问题中的类别数),输出层的输出为 $O=HW_{hq}+b_q$. 其中输出变量 $O∈\mathbb{R}^{n×q}$, 输出层权重参数 $W_{hq}∈\mathbb{R}^{h×q}$, 输出层偏差参数 $b_q∈\mathbb{R}^{1×q}$。如果是分类问题,我们可以使用 $\mathbf{softmax}(O)$ 来计算输出类别的概率分布。

  • 含隐藏状态的 RNN

现在我们考虑输入数据存在时间相关性的情况。假设 $X_t∈\mathbb{R}^{n×d}$ 是序列中时间步 $t$ 的小批量输入,$H_t∈\mathbb{R}^{n×h}$ 是该时间步的隐藏变量。

与多层感知机不同的是,这里我们保存上一时间步的隐藏变量 $H_{t−1}$,并引入一个新的权重参数 $W_{hh}∈\mathbb{R}^{h×h}$,该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。

具体来说,时间步 t 的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:

与多层感知机相比,我们在这里添加了 $H_{t−1}W_{hh}$ 一项。由上式中相邻时间步的隐藏变量 $H_t$ 和 $H_{t−1}$ 之间的关系可知,这里的隐藏变量能够捕捉截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。因此,该隐藏变量也称为隐藏状态。

img

由于隐藏状态在当前时间步的定义使用了上一时间步的隐藏状态,上式的计算是循环的。使用循环计算的网络即循环神经网络(recurrent neural network)。

而在时间步 $t$,输出层的输出和多层感知机中的计算类似:$O_t=H_tW_{hq}+b_q$.

字符数据集的制作

  • 读取数据集
  • 建立字符索引 idx_to_char 与 char_to_idx
  • 时序数据的采样
    • 随机采样:在随机采样中,每个样本是原始序列上任意截取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。在训练模型时,每次随机采样前都需要重新初始化隐藏状态。
    • 相邻采样:令相邻的两个随机小批量在原始序列上的位置相毗邻。这时候,我们就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态,从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。
      • 在训练模型时,我们只需在每一个迭代周期开始时初始化隐藏状态。
      • 当多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度计算将依赖所有串联起来的小批量序列。同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图中分离出来。

RNN 的实现

From scratch

  • 单个词的表示:One-hot 向量
  • 初始化模型参数与模型定义
  • 预测函数的定义
  • 裁剪梯度
  • 模型评估:困惑度
    • 困惑度是对交叉熵损失函数做指数运算后得到的值
      • 最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
      • 最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
      • 基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。

Simple

Pytorch 实现:大调库

1
2
3
num_hiddens = 256
# rnn_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens) # 已测试
rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)

与上一节中实现的循环神经网络不同,这里 rnn_layer 的输入形状为 (时间步数, 批量大小, 输入个数)。其中输入个数即 one-hot 向量长度(词典大小)。

此外,rnn_layer 作为 nn.RNN 实例,在前向计算后会分别返回隐藏层的输出 H隐藏状态 h

  • $H$ 指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输入,形状为 (时间步数, 批量大小, 隐藏单元个数)
  • $h$ 指的是隐藏层在最后时间步的隐藏状态:当隐藏层有多层时,每一层的隐藏状态都会记录在该变量中;对于像长短期记忆(LSTM),隐藏状态是一个元组 $(h,c)$,即 hidden state 和 cell state。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 本类已保存在d2lzh_pytorch包中方便以后使用
class RNNModel(nn.Module):
def __init__(self, rnn_layer, vocab_size):
super(RNNModel, self).__init__()
self.rnn = rnn_layer
self.hidden_size = rnn_layer.hidden_size * (2 if rnn_layer.bidirectional else 1)
self.vocab_size = vocab_size
self.dense = nn.Linear(self.hidden_size, vocab_size)
self.state = None

def forward(self, inputs, state): # inputs: (batch, seq_len)
# 获取 one-hot 向量表示
X = d2l.to_onehot(inputs, self.vocab_size) # X 是个 list
Y, self.state = self.rnn(torch.stack(X), state)
# 全连接层会首先将 Y 的形状变成 (num_steps * batch_size, num_hiddens),它的输出
# 形状为 (num_steps * batch_size, vocab_size)
output = self.dense(Y.view(-1, Y.shape[-1]))
return output, self.state

在前面两节中,如果不裁剪梯度,模型将无法正常训练。当总的时间步数较大或者当前时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。

RNN 的改进

GRU

虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。通常由于这个原因,循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。

门控循环神经网络(gated recurrent neural network)的提出,正是为了更好地捕捉时间序列中时间步距离较大的依赖关系。它通过可以学习的门来控制信息的流动。

  • 门控循环单元

img

  • 重置门有助于捕捉时间序列里短期的依赖关系
    • 重置门控制了上一时间步的隐藏状态如何流入当前时间步的候选隐藏状态
    • 上一时间步的隐藏状态可能包含了时间序列截至上一时间步的全部历史信息
    • 重置门可以用来丢弃与预测无关的历史信息
  • 更新门有助于捕捉时间序列里长期的依赖关系
    • 更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新
    • 假设更新门在时间步 $t^′$ 到 $t$($t^′<t$)之间一直近似 1。那么,在时间步 $t^′$ 到 $t$ 之间的输入信息几乎没有流入时间步 $t$ 的隐藏状态 $H_t$。实际上,这可以看作是较早时刻的隐藏状态 $H_{t^′−1}$ 一直通过时间保存并传递至当前时间步 $t$。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。

实现也可以直接大调库:

1
2
3
4
5
6
7
lr = 1e-2 # 注意调整学习率
gru_layer = nn.GRU(input_size=vocab_size, hidden_size=num_hiddens)
model = d2l.RNNModel(gru_layer, vocab_size).to(device)
d2l.train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
corpus_indices, idx_to_char, char_to_idx,
num_epochs, num_steps, lr, clipping_theta,
batch_size, pred_period, pred_len, prefixes)

LSTM

img

  • 遗忘门控制上一时间步的记忆细胞 $C_{t−1}$ 中的信息是否传递到当前时间步,而输入门则控制当前时间步的输入 $X_t$ 通过候选记忆细胞 $\tilde{C}_t$ 如何流入当前时间步的记忆细胞。

    • 如果遗忘门一直近似 1 且输入门一直近似 0,过去的记忆细胞将一直通过时间保存并传递至当前时间步。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。
  • 当输出门近似 1 时,记忆细胞信息将传递到隐藏状态供输出层使用;当输出门近似 0 时,记忆细胞信息只自己保留。

实现也是大调库。

1
2
3
4
5
6
7
lr = 1e-2 # 注意调整学习率
lstm_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens)
model = d2l.RNNModel(lstm_layer, vocab_size)
d2l.train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
corpus_indices, idx_to_char, char_to_idx,
num_epochs, num_steps, lr, clipping_theta,
batch_size, pred_period, pred_len, prefixes)

Deep-RNN

本章到目前为止介绍的循环神经网络只有一个单向的隐藏层,在深度学习应用里,我们通常会用到含有多个隐藏层的循环神经网络,也称作深度循环神经网络。

图中演示了一个有 $L$ 个隐藏层的深度循环神经网络,每个隐藏状态不断传递至当前层的下一时间步和当前时间步的下一层。

img

bi-RNN

之前介绍的循环神经网络模型都是假设当前时间步是由前面的较早时间步的序列决定的,因此它们都将信息通过隐藏状态从前往后传递。

有时候,当前时间步也可能由后面时间步决定。例如,当我们写下一个句子时,可能会根据句子后面的词来修改句子前面的用词。双向循环神经网络通过增加从后往前传递信息的隐藏层来更灵活地处理这类信息。

img

然后我们连结两个方向的隐藏状态 $\overrightarrow{H}_t$ 和 $\overleftarrow{H}_t$ 来得到隐藏状态 $H_t∈\mathbb{R}^{n×2h}$,并将其输入到输出层。