Python-深度学习第三版-四-

Python 深度学习第三版(四)

原文:deeplearningwithpython.io/chapters/

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:物体检测

原文:deeplearningwithpython.io/chapters/chapter12_object-detection

物体检测主要是围绕图像中感兴趣对象的绘制框(称为 边界框)(参见图 12.1)。这使得你不仅知道图像中有什么对象,还能知道它们的位置。其最常见的一些应用包括

  • 计数 — 查找图像中对象的实例数量。

  • 跟踪 — 通过对电影每一帧执行物体检测来跟踪场景中物体随时间移动。

  • 裁剪 — 识别图像中包含感兴趣对象的区域进行裁剪,并将图像块的高分辨率版本发送到分类器或光学字符识别(OCR)模型。

图片

图 12.1:物体检测器在图像中绘制边界框并对它们进行标记。

你可能会想,如果我有一个对象实例的分割掩码,我已能计算包含掩码的最小框的坐标。那么我们是否可以一直使用图像分割?我们还需要物体检测模型吗?

事实上,分割是检测的严格超集。它返回检测模型可能返回的所有信息——以及更多。这种信息量的增加带来了显著的计算成本:一个好的物体检测模型通常比图像分割模型运行得快得多。它还有数据标注成本:要训练分割模型,你需要收集像素级精确的掩码,这比物体检测模型所需的边界框生产耗时得多。

因此,如果你不需要像素级信息,你总是想使用物体检测模型——例如,如果你只想在图像中计数对象。

单阶段与两阶段物体检测器

物体检测架构主要分为两大类:

  • 两阶段检测器,首先提取区域提议,称为基于区域的卷积神经网络(R-CNN)模型

  • 单阶段检测器,例如 RetinaNet 或 You Only Look Once 系列模型

这是它们的工作原理。

两阶段 R-CNN 检测器

基于区域的卷积神经网络(R-CNN)模型是一个两阶段模型。第一阶段接收一个图像,并在看起来像物体的区域周围生成几千个部分重叠的边界框。这些框被称为 区域提议。这一阶段并不十分智能,所以在那个阶段我们还不确定提议的区域是否确实包含对象,以及如果包含,包含哪些对象。

这是第二阶段的工作——一个卷积神经网络,它查看每个区域提议并将其分类为多个预定义的类别,就像你在第九章中看到的模型一样(见图 12.2)。具有低得分的区域提议被丢弃。然后我们剩下的一组箱子,每个箱子都有一个特定类别的较高类别存在分数。最后,围绕每个对象的边界框进一步细化,以消除重复并尽可能使每个边界框尽可能精确。

图片

图 12.2:R-CNN 首先提取区域提议,然后使用卷积神经网络(CNN)对这些提议进行分类。

在早期 R-CNN 版本中,第一阶段是一个名为选择性搜索的启发式模型,它使用一些空间一致性的定义来识别类似物体的区域。"启发式"是你在机器学习中会经常听到的一个术语——它仅仅意味着“某人编造的一套硬编码的规则。”它通常用于与学习模型(规则是自动导出的)或理论导出的模型相对立。在 R-CNN 的后期版本中,如 Faster-R-CNN,框生成阶段变成了一个深度学习模型,称为区域提议网络。

R-CNN 的双阶段方法在实践中效果很好,但计算成本相当高,最显著的是因为它要求你为每张处理的图像分类数千个补丁。这使得它不适合大多数实时应用和嵌入式系统。我的观点是,在实际应用中,你通常不需要像 R-CNN 这样的计算密集型目标检测系统,因为如果你在服务器端使用强大的 GPU 进行推理,那么你可能会更愿意使用像我们在上一章中看到的 Segment Anything 模型这样的分割模型。如果你资源有限,那么你将想要使用一个计算效率更高的目标检测架构——单阶段检测器。

单阶段检测器

大约在 2015 年,研究人员和从业者开始尝试使用单个深度学习模型来联合预测边界框坐标及其标签,这种架构被称为单阶段检测器。单阶段检测器的主要家族包括 RetinaNet、单次多框检测器(SSD)和 YOLO 系列,简称 YOLO。是的,就像那个梗。这是故意的。

单阶段检测器,尤其是最近的 YOLO 迭代版本,与双阶段检测器相比,具有显著更快的速度和更高的效率,尽管在准确性方面存在一些潜在的小型权衡。如今,YOLO 可以说是最受欢迎的目标检测模型,尤其是在实时应用方面。通常每年都会有一个新版本出现——有趣的是,每个新版本往往是由不同的组织开发的。

在下一节中,我们将从头开始构建一个简化的 YOLO 模型。

从头开始训练 YOLO 模型

总体来说,构建一个目标检测器可能是一项相当艰巨的任务——并不是说它在理论上有什么复杂之处。只是需要大量的代码来处理边界框和预测输出的操作。为了保持简单,我们将重新创建 2015 年的第一个 YOLO 模型。截至本文写作时,已有 12 个 YOLO 版本,但原始版本更易于操作。

下载 COCO 数据集

在我们开始创建模型之前,我们需要用于训练的数据。COCO 数据集^([1]),简称Common Objects in Context,是众所周知且最常用的目标检测数据集之一。它由多个不同来源的真实世界照片以及人类创建的注释组成。这包括对象标签、边界框注释和完整的分割掩码。我们将忽略分割掩码,只使用边界框。

让我们下载 2017 版本的 COCO 数据集。虽然按照今天的标准这不是一个大型数据集,但这个 18GB 的数据集将是本书中我们使用的最大数据集。如果您在阅读代码时运行,这是一个休息的好机会。

import keras
import keras_hub

images_path = keras.utils.get_file(
    "coco",
    "http://images.cocodataset.org/zips/train2017.zip",
    extract=True,
)
annotations_path = keras.utils.get_file(
    "annotations",
    "http://images.cocodataset.org/annotations/annotations_trainval2017.zip",
    extract=True,
) 

列表 12.1:下载 2017 年 COCO 数据集

在我们准备好使用这些数据之前,我们需要进行一些输入处理。第一次下载给我们提供了一个所有 COCO 图像的无标签目录。第二次下载包含所有图像元数据,通过一个 JSON 文件。COCO 将每个图像文件与一个 ID 关联,每个边界框都与这些 ID 之一配对。我们需要将所有框和图像数据汇总在一起。

每个边界框都包含x, y, width, height像素坐标,从图像的左上角开始。在我们加载数据时,我们可以调整所有边界框坐标,使它们成为 [0, 1] 单位正方形中的点。这将使操作这些框变得更加容易,而无需检查每个输入图像的大小。

import json

with open(f"{annotations_path}/annotations/instances_train2017.json", "r") as f:
    annotations = json.load(f)

# Sorts image metadata by ID
images = {image["id"]: image for image in annotations["images"]}

# Converts bounding box to coordinates on a unit square
def scale_box(box, width, height):
    scale = 1.0 / max(width, height)
    x, y, w, h = [v * scale for v in box]
    x += (height - width) * scale / 2 if height > width else 0
    y += (width - height) * scale / 2 if width > height else 0
    return [x, y, w, h]

# Aggregates all bounding box annotations by image ID
metadata = {}
for annotation in annotations["annotations"]:
    id = annotation["image_id"]
    if id not in metadata:
        metadata[id] = {"boxes": [], "labels": []}
    image = images[id]
    box = scale_box(annotation["bbox"], image["width"], image["height"])
    metadata[id]["boxes"].append(box)
    metadata[id]["labels"].append(annotation["category_id"])
    metadata[id]["path"] = images_path + "/train2017/" + image["file_name"]
metadata = list(metadata.values()) 

列表 12.2:解析 COCO 数据

让我们看看我们刚刚加载的数据。

>>> len(metadata)
117266
>>> min([len(x["boxes"]) for x in metadata])
1
>>> max([len(x["boxes"]) for x in metadata])
63
>>> max(max(x["labels"]) for x in metadata) + 1
91
>>> metadata[435]
{"boxes": [[0.12, 0.27, 0.57, 0.33],
  [0.0, 0.15, 0.79, 0.69],
  [0.0, 0.12, 1.0, 0.75]],
 "labels": [17, 15, 2],
 "path": "/root/.keras/datasets/coco/train2017/000000171809.jpg"}
>>> [keras_hub.utils.coco_id_to_name(x) for x in metadata[435]["labels"]]
["cat", "bench", "bicycle"]

列表 12.3:检查 COCO 数据

我们有 117,266 张图像。每张图像可以有 1 到 63 个与边界框关联的对象。COCO 数据集创建者选择了 91 个可能的标签。

我们可以使用 KerasHub 实用工具keras_hub.utils.coco_id_to_name(id)将这些整数标签映射到可读的人名,类似于我们在第八章中用来解码 ImageNet 预测到文本标签的实用工具。

让我们可视化一个示例图像,使这一点更加具体。我们可以定义一个函数来使用 Matplotlib 绘制图像,另一个函数来在这个图像上绘制标记的边界框。我们将在本章中需要这两个函数。我们可以使用 HSV 颜色空间作为一个简单的技巧来为每个新标签生成新的颜色。通过固定颜色的饱和度和亮度,只更新其色调,我们可以生成鲜艳的新颜色,这些颜色可以从我们的图像中清楚地脱颖而出。

import matplotlib.pyplot as plt
from matplotlib.colors import hsv_to_rgb
from matplotlib.patches import Rectangle

color_map = {0: "gray"}

def label_to_color(label):
    # Uses the golden ratio to generate new hues of a bright color with
    # the HSV colorspace
    if label not in color_map:
        h, s, v = (len(color_map) * 0.618) % 1, 0.5, 0.9
        color_map[label] = hsv_to_rgb((h, s, v))
    return color_map[label]

def draw_box(ax, box, text, color):
    x, y, w, h = box
    ax.add_patch(Rectangle((x, y), w, h, lw=2, ec=color, fc="none"))
    textbox = dict(fc=color, pad=1, ec="none")
    ax.text(x, y, text, c="white", size=10, va="bottom", bbox=textbox)

def draw_image(ax, image):
    # Draws the image on a unit cube with (0, 0) at the top left
    ax.set(xlim=(0, 1), ylim=(1, 0), xticks=[], yticks=[], aspect="equal")
    image = plt.imread(image)
    height, width = image.shape[:2]
    # Pads the image so it fits inside the unit cube
    hpad = (1 - height / width) / 2 if width > height else 0
    wpad = (1 - width / height) / 2 if height > width else 0
    extent = [wpad, 1 - wpad, 1 - hpad, hpad]
    ax.imshow(image, extent=extent) 

列表 12.4:使用框注释可视化 COCO 图像

让我们使用我们的新可视化来查看我们之前检查的样本图像^([2])(见图 12.3):

sample = metadata[435]
ig, ax = plt.subplots(dpi=300)
draw_image(ax, sample["path"])
for box, label in zip(sample["boxes"], sample["labels"]):
    label_name = keras_hub.utils.coco_id_to_name(label)
    draw_box(ax, box, label_name, label_to_color(label))
plt.show() 

图片

图 12.3:YOLO 为每个图像区域输出一个边界框预测和类别标签。

虽然在所有 18GB 的输入数据上训练会很刺激,但我们希望这本书中的示例能够在普通的硬件上轻松运行。如果我们只限制使用只有四个或更少框的图像,我们将使我们的训练问题更容易,并且将数据大小减半。让我们这样做,并打乱我们的数据——图像按对象类型分组,这对训练来说会很糟糕:

import random

metadata = list(filter(lambda x: len(x["boxes"]) <= 4, metadata))
random.shuffle(metadata) 

数据加载就到这里!让我们开始创建我们的 YOLO 模型。

创建 YOLO 模型

如前所述,YOLO 模型是一个单阶段检测器。而不是首先尝试在场景中识别所有候选对象,然后对对象区域进行分类,YOLO 将一次性提出边界框和对象标签。

我们将把图像分割成网格,并在每个网格位置预测两个不同的输出——一个边界框和一个类别标签。在 Redmon 等人原始论文中,模型实际上在每个网格位置预测了多个边界框,但我们保持简单,只在每个网格方块中预测一个边界框。

大多数图像在网格上不会均匀分布对象,为了解决这个问题,模型将输出一个置信度分数,与每个框一起,如图 12.4 所示。我们希望当在某个位置检测到对象时,这个置信度很高,而没有对象时为零。大多数网格位置将没有对象,应该报告接近零的置信度。

图片

图 12.4:YOLO 在第一篇 YOLO 论文中的输出可视化

与计算机视觉中的许多模型一样,YOLO 模型使用 ConvNet 骨干来获取输入图像的有趣高级特征,这是我们首次在第八章中探讨的概念。在他们的论文中,作者创建了自己的骨干模型,并使用 ImageNet 对其进行预训练以进行分类。我们不必自己这样做,而是可以使用 KerasHub 来加载预训练的骨干。

与本书中迄今为止使用的 Xception 骨干网络不同,我们将切换到 ResNet,这是我们在第九章首次提到的模型系列。结构相当类似,但 ResNet 使用步长而不是池化层来下采样图像。正如我们在第十一章中提到的,当我们关注输入的 空间位置 时,步长卷积更好。

让我们加载我们的预训练模型和匹配的预处理(以调整图像大小)。我们将调整图像大小到 448 × 448;图像输入大小对于目标检测任务非常重要。

image_size = 448

backbone = keras_hub.models.Backbone.from_preset(
    "resnet_50_imagenet",
)
preprocessor = keras_hub.layers.ImageConverter.from_preset(
    "resnet_50_imagenet",
    image_size=(image_size, image_size),
) 

列表 12.5:加载 ResNet 模型

接下来,我们可以通过添加用于输出框和类别预测的新层,将骨干网络转换为一个检测模型。YOLO 论文中提出的设置相当简单。取卷积网络骨干网络的输出,通过中间带有激活函数的两个密集连接层,然后分割输出。前五个数字将用于边界框预测(四个用于框和一个是框的置信度)。其余的将用于图 12.4 中显示的 类别概率图 —— 对所有可能的 91 个标签在每个网格位置上的分类预测。

让我们把它写出来。

from keras import layers

grid_size = 6
num_labels = 91

inputs = keras.Input(shape=(image_size, image_size, 3))
x = backbone(inputs)
# Makes our backbone outputs smaller and then flattens the output
# features
x = layers.Conv2D(512, (3, 3), strides=(2, 2))(x)
x = keras.layers.Flatten()(x)
# Passes our flattened feature maps through two densely connected
# layers
x = layers.Dense(2048, activation="relu", kernel_initializer="glorot_normal")(x)
x = layers.Dropout(0.5)(x)
x = layers.Dense(grid_size * grid_size * (num_labels + 5))(x)
# Reshapes outputs to a 6 × 6 grid
x = layers.Reshape((grid_size, grid_size, num_labels + 5))(x)
# Split box and class predictions
box_predictions = x[..., :5]
class_predictions = layers.Activation("softmax")(x[..., 5:])
outputs = {"box": box_predictions, "class": class_predictions}
model = keras.Model(inputs, outputs) 

列表 12.6:附加 YOLO 预测头

我们可以通过查看模型摘要来更好地理解模型:

>>> model.summary()
Model: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)          ┃ Output Shape      ┃     Param # ┃ Connected to       ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ input_layer_7         │ (None, 448, 448,  │           0 │ -                  │
│ (InputLayer)          │ 3)                │             │                    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ res_net_backbone_12   │ (None, 14, 14,    │  23,580,512 │ input_layer_7[0][… │
│ (ResNetBackbone)      │ 2048)             │             │                    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ conv2d_3 (Conv2D)     │ (None, 6, 6, 512) │   9,437,696 │ res_net_backbone_… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ flatten_3 (Flatten)   │ (None, 18432)     │           0 │ conv2d_3[0][0]     │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense_6 (Dense)       │ (None, 2048)      │  37,750,784 │ flatten_3[0][0]    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dropout_3 (Dropout)   │ (None, 2048)      │           0 │ dense_6[0][0]      │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense_7 (Dense)       │ (None, 3456)      │   7,081,344 │ dropout_3[0][0]    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ reshape_3 (Reshape)   │ (None, 6, 6, 96)  │           0 │ dense_7[0][0]      │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ get_item_7 (GetItem)  │ (None, 6, 6, 91)  │           0 │ reshape_3[0][0]    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ get_item_6 (GetItem)  │ (None, 6, 6, 5)   │           0 │ reshape_3[0][0]    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ activation_33         │ (None, 6, 6, 91)  │           0 │ get_item_7[0][0]   │
│ (Activation)          │                   │             │                    │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
 Total params: 77,850,336 (296.98 MB)
 Trainable params: 77,797,088 (296.77 MB)
 Non-trainable params: 53,248 (208.00 KB)

我们的骨干网络输出形状为 (batch_size, 14, 14, 2048)。这意味着每张图像有 401,408 个输出浮点数,对于输入到我们的密集层来说有点太多。我们使用步长卷积层将特征图下采样到 (batch_size, 6, 6, 512),每张图像有 18,432 个浮点数,更容易处理。

接下来,我们可以添加我们的两个密集连接层。我们将整个特征图展平,通过一个带有 relu 激活的 Dense 层,然后通过一个最终带有我们确切数量的输出预测的 Dense 层 — 5 个用于边界框和置信度,以及每个网格位置上的每个对象类别的 91 个。

最后,我们将输出重新塑形回 6 × 6 的网格,并分割我们的框和类别预测。对于我们的分类输出,我们通常应用 softmax。框输出需要更多的特殊考虑;我们将在后面讨论这个问题。

看起来不错!请注意,由于我们通过分类层展平整个特征图,每个网格检测器都可以使用整个图像的特征;没有局部性约束。这是有意为之的 — 大型对象不会局限于单个网格单元。

准备 COCO 数据以供 YOLO 模型使用

我们的模式相对简单,但我们仍然需要预处理我们的输入,以便与预测网格对齐。每个网格检测器将负责检测任何中心落在网格框内的框。我们的模型将为框 (x, y, w, h, confidence) 输出五个浮点数。xy 将表示对象中心相对于网格单元边界的相对位置(从 0 到 1)。wh 将表示对象大小相对于图像大小的相对位置。

我们已经在训练数据中有了正确的 wh 值。然而,我们需要将我们的 xy 值从网格中转换出来。让我们定义两个实用工具:

def to_grid(box):
    x, y, w, h = box
    cx, cy = (x + w / 2) * grid_size, (y + h / 2) * grid_size
    ix, iy = int(cx), int(cy)
    return (ix, iy), (cx - ix, cy - iy, w, h)

def from_grid(loc, box):
    (xi, yi), (x, y, w, h) = loc, box
    x = (xi + x) / grid_size - w / 2
    y = (yi + y) / grid_size - h / 2
    return (x, y, w, h) 

让我们重新整理我们的训练数据,使其符合这个新的网格结构。只要我们的数据集与我们的网格一样长,我们就可以创建两个数组:

  • 第一个将包含我们的类别概率图。我们将标记所有与边界框相交的网格单元,并使用正确的标签。为了使我们的代码简单,我们不会担心重叠的框。

  • 第二个将包含实际的框。我们将所有框转换到网格中,并用框的坐标为正确的网格单元标记。在我们标记的数据中,实际框的置信度始终为 1,而所有其他位置的置信度将为 0。

import numpy as np
import math

class_array = np.zeros((len(metadata), grid_size, grid_size))
box_array = np.zeros((len(metadata), grid_size, grid_size, 5))

for index, sample in enumerate(metadata):
    boxes, labels = sample["boxes"], sample["labels"]
    for box, label in zip(boxes, labels):
        (x, y, w, h) = box
        # Finds all grid cells whose center falls inside the box
        left, right = math.floor(x * grid_size), math.ceil((x + w) * grid_size)
        bottom, top = math.floor(y * grid_size), math.ceil((y + h) * grid_size)
        class_array[index, bottom:top, left:right] = label

for index, sample in enumerate(metadata):
    boxes, labels = sample["boxes"], sample["labels"]
    for box, label in zip(boxes, labels):
        # Transforms the box to the grid coordinate system
        (xi, yi), (grid_box) = to_grid(box)
        box_array[index, yi, xi] = [*grid_box, 1.0]
        # Makes sure the class label for the box's center location
        # matches the box
        class_array[index, yi, xi] = label 

列表 12.7:创建 YOLO 目标

让我们使用我们的框绘制助手可视化我们的 YOLO 训练数据(图 12.5)。我们将在第一个输入图像上绘制整个类别激活图^([4]),并添加框的置信度分数及其标签。

def draw_prediction(image, boxes, classes, cutoff=None):
    fig, ax = plt.subplots(dpi=300)
    draw_image(ax, image)
    # Draws the YOLO output grid and class probability map
    for yi, row in enumerate(classes):
        for xi, label in enumerate(row):
            color = label_to_color(label) if label else "none"
            x, y, w, h = (v / grid_size for v in (xi, yi, 1.0, 1.0))
            r = Rectangle((x, y), w, h, lw=2, ec="black", fc=color, alpha=0.5)
            ax.add_patch(r)
    # Draws all boxes at each grid location above our cutoff
    for yi, row in enumerate(boxes):
        for xi, box in enumerate(row):
            box, confidence = box[:4], box[4]
            if not cutoff or confidence >= cutoff:
                box = from_grid((xi, yi), box)
                label = classes[yi, xi]
                color = label_to_color(label)
                name = keras_hub.utils.coco_id_to_name(label)
                draw_box(ax, box, f"{name} {max(confidence, 0):.2f}", color)
    plt.show()

draw_prediction(metadata[0]["path"], box_array[0], class_array[0], cutoff=1.0) 

列表 12.8:可视化 YOLO 目标

图 12.5:YOLO 为每个图像区域输出一个边界框预测和类别标签。

最后,让我们使用 tf.data 加载我们的图像数据。我们将从磁盘加载我们的图像,应用我们的预处理,并将它们分批。我们还应该分割一个验证集来监控训练。

import tensorflow as tf

# Loads and resizes the model with tf.data
def load_image(path):
    x = tf.io.read_file(path)
    x = tf.image.decode_jpeg(x, channels=3)
    return preprocessor(x)

images = tf.data.Dataset.from_tensor_slices([x["path"] for x in metadata])
images = images.map(load_image, num_parallel_calls=8)
labels = {"box": box_array, "class": class_array}
labels = tf.data.Dataset.from_tensor_slices(labels)

# Creates a merged dataset and batches it
dataset = tf.data.Dataset.zip(images, labels).batch(16).prefetch(2)
# Splits off some validation data
val_dataset, train_dataset = dataset.take(500), dataset.skip(500) 

列表 12.9:创建用于训练的数据集

有了这些,我们的数据就准备好进行训练了。

训练 YOLO 模型

我们已经有了我们的模型和训练数据,但在我们实际运行 fit() 之前,我们还需要一个最后的元素:损失函数。我们的模型输出预测框和预测网格标签。在第七章中,我们看到了如何为每个输出定义多个损失——Keras 将在训练期间简单地将损失相加。我们可以像往常一样用 sparse_categorical_crossentropy 处理分类损失。

然而,框损失需要一些特别的考虑。YOLO 作者提出的基损失相当简单。他们使用目标框参数与预测参数之间差异的平方和误差。我们只为标记数据中有实际框的网格单元计算这个误差。

损失函数中的难点在于边界框置信度的输出。作者希望置信度输出不仅反映物体的存在,还要反映预测框的好坏。为了创建一个平滑的框预测好坏估计,作者提出使用我们在上一章看到的交并比(IoU)度量。如果一个网格单元为空,该位置的预测置信度应该是零。然而,如果一个网格单元包含一个物体,我们可以使用当前框预测与实际框之间的 IoU 分数作为目标置信度值。这样,随着模型在预测框位置方面变得更好,IoU 分数和学习的置信度值将上升。

这需要自定义损失函数。我们可以先定义一个计算目标和预测框的 IoU 分数的实用工具。

from keras import ops

# Unpacks a tensor of boxes
def unpack(box):
    return box[..., 0], box[..., 1], box[..., 2], box[..., 3]

# Computes the intersection area between two box tensors
def intersection(box1, box2):
    cx1, cy1, w1, h1 = unpack(box1)
    cx2, cy2, w2, h2 = unpack(box2)
    left = ops.maximum(cx1 - w1 / 2, cx2 - w2 / 2)
    bottom = ops.maximum(cy1 - h1 / 2, cy2 - h2 / 2)
    right = ops.minimum(cx1 + w1 / 2, cx2 + w2 / 2)
    top = ops.minimum(cy1 + h1 / 2, cy2 + h2 / 2)
    return ops.maximum(0.0, right - left) * ops.maximum(0.0, top - bottom)

# Computes the IoU between two box tensors
def intersection_over_union(box1, box2):
    cx1, cy1, w1, h1 = unpack(box1)
    cx2, cy2, w2, h2 = unpack(box2)
    intersection_area = intersection(box1, box2)
    a1 = ops.maximum(w1, 0.0) * ops.maximum(h1, 0.0)
    a2 = ops.maximum(w2, 0.0) * ops.maximum(h2, 0.0)
    union_area = a1 + a2 - intersection_area
    return ops.divide_no_nan(intersection_area, union_area) 

代码列表 12.10:计算两个框的 IoU

让我们使用这个实用工具来定义我们的自定义损失。Redmon 等人提出了一些损失缩放技巧来提高训练质量:

  • 他们将框放置损失放大五倍,使其成为整体训练中更重要的一部分。

  • 由于大多数网格单元是空的,他们还将空位置的置信度损失缩小两倍。这保持了这些零置信度预测不会压倒损失。

  • 他们计算损失之前先取宽度和高度的平方根。这是为了防止大框相对于小框产生不成比例的影响。我们将使用一个保留输入符号的sqrt函数,因为我们的模型在训练开始时可能会预测负的宽度和高度。

让我们把它写出来。

def signed_sqrt(x):
    return ops.sign(x) * ops.sqrt(ops.absolute(x) + keras.config.epsilon())

def box_loss(true, pred):
    # Unpacks values
    xy_true, wh_true, conf_true = true[..., :2], true[..., 2:4], true[..., 4:]
    xy_pred, wh_pred, conf_pred = pred[..., :2], pred[..., 2:4], pred[..., 4:]
    # If confidence_true is 0.0, there is no object in this grid cell.
    no_object = conf_true == 0.0
    # Computes box placement errors
    xy_error = ops.square(xy_true - xy_pred)
    wh_error = ops.square(signed_sqrt(wh_true) - signed_sqrt(wh_pred))
    # Computes confidence error
    iou = intersection_over_union(true, pred)
    conf_target = ops.where(no_object, 0.0, ops.expand_dims(iou, -1))
    conf_error = ops.square(conf_target - conf_pred)
    # Concatenates the errors weith scaling hacks
    error = ops.concatenate(
        (
            ops.where(no_object, 0.0, xy_error * 5.0),
            ops.where(no_object, 0.0, wh_error * 5.0),
            ops.where(no_object, conf_error * 0.5, conf_error),
        ),
        axis=-1,
    )
    # Returns one loss value per sample; Keras will sum over the batch.
    return ops.sum(error, axis=(1, 2, 3)) 

代码列表 12.11:定义 YOLO 边界框损失

我们终于准备好开始训练我们的 YOLO 模型了。为了使这个例子简短,我们将跳过指标。在实际应用中,你在这里会想要很多指标——例如,模型在不同置信度截止值下的准确性。

model.compile(
    optimizer=keras.optimizers.Adam(2e-4),
    loss={"box": box_loss, "class": "sparse_categorical_crossentropy"},
)
model.fit(
    train_dataset,
    validation_data=val_dataset,
    epochs=4,
) 

代码列表 12.12:训练 YOLO 模型

训练在 Colab 免费 GPU 运行时需要超过一小时,而且我们的模型仍然欠训练(验证损失仍在下降!)让我们尝试可视化我们模型的输出(图 12.6)。我们将使用低置信度截止值,因为我们的模型目前不是一个很好的物体检测器。

# Rebatches our dataset to get a single sample instead of 16
x, y = next(iter(val_dataset.rebatch(1)))
preds = model.predict(x)
boxes = preds["box"][0]
# Uses argmax to find the most likely label at each grid location
classes = np.argmax(preds["class"][0], axis=-1)
# Loads the image from disk to view it a full size
path = metadata[0]["path"]
draw_prediction(path, boxes, classes, cutoff=0.1) 

代码列表 12.13:训练 YOLO 模型

图 12.6:对我们样本图像的预测

我们可以看到我们的模型开始理解框位置和类别标签,尽管它仍然不够准确。让我们可视化模型预测的每一个框(图 12.7),即使那些置信度为零的框:

draw_prediction(path, boxes, classes, cutoff=None) 

图 12.7:YOLO 模型预测的每一个边界框

我们的模型学习到非常低置信度的值,因为它还没有学会在场景中一致地定位物体。为了进一步提高模型,我们应该尝试以下几种方法:

  • 训练更多轮次

  • 使用整个 COCO 数据集

  • 数据增强(例如,平移和旋转输入图像和框)

  • 改善重叠框的类别概率图

  • 使用更大的输出网格在每个网格位置预测多个框

所有这些都会对模型性能产生积极影响,并让我们更接近原始 YOLO 训练配方。然而,这个例子实际上只是为了让我们对目标检测训练有一个感觉——从头开始训练一个准确的 COCO 检测模型需要大量的计算和时间。相反,为了获得一个性能更好的检测模型的感觉,让我们尝试使用一个名为 RetinaNet 的预训练目标检测模型。

使用预训练的 RetinaNet 检测器

RetinaNet 也是一个单阶段目标检测器,其工作原理与 YOLO 模型相同。我们模型与 RetinaNet 之间最大的概念性区别在于,RetinaNet 使用其底层的 ConvNet 的方式不同,以更好地同时处理小和大物体。

在我们的 YOLO 模型中,我们简单地取了 ConvNet 的最终输出,并使用它们来构建我们的目标检测器。这些输出特征映射到输入图像的大区域——因此,它们在寻找场景中的小物体方面不是很有效。

解决这个尺度问题的一个选项是直接使用我们 ConvNet 中早期层的输出。这将提取映射到我们输入图像小局部区域的高分辨率特征。然而,这些早期层的输出并不是非常 语义上有意义。它们可能映射到不同类型的简单特征,如边缘和曲线,但只有在 ConvNet 的后期层中,我们才开始构建整个物体的潜在表示。

RetinaNet 使用的解决方案被称为特征金字塔网络。从 ConvNet 基础模型得到的最终特征通过渐进的 Conv2DTranspose 层上采样,正如我们在上一章所看到的。但关键的是,我们还包含了 侧向连接,其中我们将这些上采样的特征图与原始 ConvNet 中相同大小的特征图相加。这结合了 ConvNet 末尾的语义有趣、低分辨率的特征与 ConvNet 开头的具有高分辨率、小尺度的特征。这种架构的粗略草图如图 12.8 所示。

图片

图 12.8:特征金字塔网络在不同尺度上创建了具有语义意义的特征图。

特征金字塔网络可以通过为像素足迹大小的小和大物体构建有效特征来显著提升性能。YOLO 的最新版本也使用了相同的设置。

让我们尝试使用在 COCO 数据集上训练的 RetinaNet 模型,为了使这个过程更有趣,让我们尝试一个对于模型来说是分布外的图像,即点彩画《大岛星期日下午》。

我们可以先下载图像并将其转换为 NumPy 数组:

url = "https://s3.us-east-1.amazonaws.com/book.keras.io/3e/seurat.jpg"
path = keras.utils.get_file(origin=url)
image = np.array([keras.utils.load_img(path)]) 

接下来,让我们下载模型并进行预测。正如我们在上一章中所做的那样,我们可以使用 KerasHub 中的高级任务 API 创建一个ObjectDetector并使用它——包括预处理。

detector = keras_hub.models.ObjectDetector.from_preset(
    "retinanet_resnet50_fpn_v2_coco",
    bounding_box_format="rel_xywh",
)
predictions = detector.predict(image) 

代码清单 12.14:创建 ResNet 模型

你会注意到我们传递了一个额外的参数来指定边界框格式。我们可以为大多数支持边界框的 Keras 模型和层这样做。我们传递"rel_xywh"以使用与 YOLO 模型相同的格式,这样我们就可以使用相同的框绘制工具。在这里,rel代表相对于图像大小(例如,从[0, 1])。让我们检查我们刚刚做出的预测:

>>> [(k, v.shape) for k, v in predictions.items()]
[("boxes", (1, 100, 4)),
 ("confidence", (1, 100)),
 ("labels", (1, 100)),
 ("num_detections", (1,))]
>>> predictions["boxes"][0][0]
array([0.53, 0.00, 0.81, 0.29], dtype=float32)

我们有四种不同的模型输出:边界框、置信度、标签和检测总数。这总体上与我们的 YOLO 模型非常相似。模型可以为每个输入模型预测总共 100 个对象。

让我们尝试使用我们的框绘制工具显示预测结果(图 12.9)。

fig, ax = plt.subplots(dpi=300)
draw_image(ax, path)
num_detections = predictions["num_detections"][0]
for i in range(num_detections):
    box = predictions["boxes"][0][i]
    label = predictions["labels"][0][i]
    label_name = keras_hub.utils.coco_id_to_name(label)
    draw_box(ax, box, label_name, label_to_color(label))
plt.show() 

代码清单 12.15:使用 RetinaNet 进行推理

图片

图 12.9:RetinaNet 模型在测试图像上的预测

RetinaNet 模型能够轻松地将点彩画泛化到这种风格,尽管没有在这个输入风格上进行训练!这实际上是单阶段目标检测器的一个优点。绘画和照片在像素级别上非常不同,但在高层次上具有相似的结构。与 R-CNNs 这样的两阶段检测器相比,它们被迫独立地对输入图像的小块进行分类,当小块像素看起来与训练数据非常不同时,这会变得更加困难。单阶段检测器可以借鉴整个输入的特征,并且对新颖的测试时间输入更加鲁棒。

有了这些,你已经到达了这本书计算机视觉部分的结尾!我们从零开始训练了图像分类器、分割器和目标检测器。我们对卷积神经网络的工作原理有了很好的直觉,这是深度学习时代的第一次重大成功。我们还没有完全结束图像;你将在第十七章中再次看到它们,当我们开始生成图像输出时。

摘要

  • 目标检测通过使用边界框在图像中识别和定位对象。这基本上是图像分割的一个较弱版本,但可以运行得更加高效。

  • 目标检测主要有两种方法:

    • 基于区域的卷积神经网络(R-CNNs),这是一种两阶段模型,首先提出感兴趣区域,然后使用卷积神经网络对其进行分类。

    • 单阶段检测器(如 RetinaNet 和 YOLO),它们在单步中执行两项任务。单阶段检测器通常更快、更高效,使其适用于实时应用(例如,自动驾驶汽车)。

  • YOLO 在训练期间同时计算两个独立的输出——可能的边界框和类别概率图:

    • 每个候选边界框都与一个置信度分数配对,该分数被训练以针对预测框和真实框的交并比

    • 类别概率图将图像的不同区域分类为属于不同的对象。

  • RetinaNet 通过使用特征金字塔网络(FPN)来构建这一想法,该网络结合了多个 ConvNet 层的特征以创建不同尺度的特征图,使其能够更准确地检测不同大小的对象。

脚注

  1. COCO 2017 检测数据集可在 cocodataset.org/ 探索。本章中的大多数图像都来自该数据集。[↩]

  2. 来自 COCO 2017 数据集的图像,cocodataset.org/。图像来自 Flickr,farm8.staticflickr.com/7250/7520201840_3e01349e3f_z.jpg,CC BY 2.0 creativecommons.org/licenses/by/2.0/[↩]

  3. Redmon 等人,“你只看一次:统一、实时目标检测”,CoRR (2015),arxiv.org/abs/1506.02640. [↩]

  4. 来自 COCO 2017 数据集的图像,cocodataset.org/。图像来自 Flickr,farm9.staticflickr.com/8081/8387882360_5b97a233c4_z.jpg,CC BY 2.0 creativecommons.org/licenses/by/2.0/[↩]

第十三章:时间序列预测

原文:deeplearningwithpython.io/chapters/chapter13_timeseries-forecasting

本章探讨时间序列,其中时间顺序至关重要。我们将重点关注最常见且最有价值的时间序列任务:预测。使用最近的过去来预测近未来的能力非常强大,无论您是试图预测能源需求、管理库存还是简单地预测天气。

不同类型的时间序列任务

一个 时间序列 可以是任何通过定期测量获得的数据,例如股票的每日价格、城市的每小时电力消耗或商店的每周销售额。时间序列无处不在,无论是观察自然现象(如地震活动、河流中鱼类种群的变化或某地的天气)还是人类活动模式(如网站访问者、一个国家的 GDP 或信用卡交易)。与您迄今为止遇到的数据类型不同,处理时间序列需要理解系统的 动态 —— 它的周期性循环、随时间的变化趋势、常规状态和突然的峰值。

到目前为止,最常见的时间序列相关任务是 预测:预测序列中接下来会发生什么。提前几小时预测电力消耗,以便您可以预测需求;提前几个月预测收入,以便您可以规划预算;提前几天预测天气,以便您可以规划日程。预测是本章的重点。但实际上,您可以用时间序列做很多事情,例如

  • 异常检测 —— 在连续数据流中检测任何异常事件。公司网络上的异常活动?可能是攻击者。生产线上的异常读数?是时候让人类去看看了。异常检测通常通过无监督学习来完成,因为您通常不知道您在寻找哪种异常,因此您无法在特定的异常示例上进行训练。

  • 分类 —— 将一个或多个分类标签分配给时间序列。例如,给定网站访问者活动的时间序列,判断该访问者是机器人还是人类。

  • 事件检测 —— 在连续数据流中识别特定、预期的事件发生。特别有用的应用是“热词检测”,其中模型监控音频流并检测“OK, Google”或“Hey, Alexa”等语音。

在本章中,您将了解循环神经网络(RNNs)及其在时间序列预测中的应用。

一个温度预测示例

在本章中,我们所有的代码示例都将针对一个问题:根据最近过去由一组安装在建筑物屋顶的传感器记录的诸如大气压力和湿度等量的每小时测量值的时间序列,预测未来 24 小时的温度。正如你将看到的,这是一个相当具有挑战性的问题!

我们将使用这个温度预测任务来突出时间序列数据与迄今为止你遇到的数据集的基本不同之处,以表明密集连接的网络和卷积网络并不适合处理它,并展示一种新的机器学习技术,这种技术在处理这类问题上表现得非常出色:循环神经网络(RNNs)。

我们将使用德国耶拿马克斯·普朗克生物地球化学研究所气象站记录的气象时间序列数据集进行工作.^([1]) 在这个数据集中,记录了 14 个不同的量(如温度、大气压力、湿度、风向等),每隔 10 分钟记录一次,持续了数年。原始数据可以追溯到 2003 年,但我们将要下载的数据子集限制在 2009-2016 年。

让我们开始下载和解压缩数据:

!wget https://s3.amazonaws.com/keras-datasets/jena_climate_2009_2016.csv.zip
!unzip jena_climate_2009_2016.csv.zip 

让我们来看看数据。

import os

fname = os.path.join("jena_climate_2009_2016.csv")

with open(fname) as f:
    data = f.read()

lines = data.split("\n")
header = lines[0].split(",")
lines = lines[1:]
print(header)
print(len(lines)) 

代码列表 13.1:检查耶拿气象数据集的数据

这输出了 420,551 行数据的计数(每行是一个时间步长:一个日期和 14 个与天气相关的值的记录),以及以下标题:

["Date Time",
 "p (mbar)",
 "T (degC)",
 "Tpot (K)",
 "Tdew (degC)",
 "rh (%)",
 "VPmax (mbar)",
 "VPact (mbar)",
 "VPdef (mbar)",
 "sh (g/kg)",
 "H2OC (mmol/mol)",
 "rho (g/m**3)",
 "wv (m/s)",
 "max. wv (m/s)",
 "wd (deg)"] 

现在,将所有 420,551 行数据转换为 NumPy 数组:一个数组用于温度(以摄氏度为单位),另一个数组用于其余的数据——我们将使用这些特征来预测未来的温度。请注意,我们丢弃了“日期时间”列。

import numpy as np

temperature = np.zeros((len(lines),))
raw_data = np.zeros((len(lines), len(header) - 1))

for i, line in enumerate(lines):
    values = [float(x) for x in line.split(",")[1:]]
    # We store column 1 in the temperature array.
    temperature[i] = values[1]
    # We store all columns (including the temperature) in the raw_data
    # array.
    raw_data[i, :] = values[:] 

代码列表 13.2:解析数据

图 13.1 显示了温度(以摄氏度为单位)随时间变化的图表。在这张图上,你可以清楚地看到温度的年度周期性——数据跨度为八年。

from matplotlib import pyplot as plt

plt.plot(range(len(temperature)), temperature) 

代码列表 13.3:绘制温度时间序列图

图 13.1:数据集整个时间范围内的温度(ºC)

图 13.2 显示了温度数据的前 10 天的更窄的图表。因为数据每 10 分钟记录一次,所以每天有 24 × 6 = 144 个数据点。

plt.plot(range(1440), temperature[:1440]) 

代码列表 13.4:绘制温度时间序列的前 10 天

图 13.2:数据集前 10 天的温度(ºC)

在这张图上,你可以看到日周期性,特别是在最后四天尤为明显。同时请注意,这个 10 天的周期必须来自一个相当寒冷的冬季月份。

在我们的数据集中,如果您试图根据几个月的过去数据预测下一个月的平均温度,这个问题将很容易,因为数据的可靠年周期性。但是,从日的时间尺度来看,温度看起来要混乱得多。这个时间序列在日尺度上可预测吗?让我们来看看。

在我们所有的实验中,我们将使用数据的前 50%进行训练,接下来的 25%进行验证,最后的 25%进行测试。当处理时间序列数据时,使用比训练数据更近期的验证和测试数据非常重要,因为您试图根据过去预测未来,而不是相反,并且您的验证/测试拆分应该反映这种时间顺序。如果反转时间轴,某些问题可能会变得相当简单!

>>> num_train_samples = int(0.5 * len(raw_data))
>>> num_val_samples = int(0.25 * len(raw_data))
>>> num_test_samples = len(raw_data) - num_train_samples - num_val_samples
>>> print("num_train_samples:", num_train_samples)
>>> print("num_val_samples:", num_val_samples)
>>> print("num_test_samples:", num_test_samples)
num_train_samples: 210225
num_val_samples: 105112
num_test_samples: 105114

列表 13.5:计算每个数据拆分的样本数量

准备数据

问题的确切表述如下:给定覆盖前五天且每小时采样一次的数据,我们能否预测 24 小时后的温度?

首先,让我们预处理数据,使其成为神经网络可以摄入的格式。这很简单:数据已经是数值的,因此您不需要进行任何向量化。但是,数据中的每个时间序列都在不同的尺度上(例如,大气压力,以毫巴为单位,约为 1,000,而 H2OC,以每摩尔毫摩尔为单位,约为 3)。我们将独立归一化每个时间序列,使它们都在相似的尺度上取小值。我们将使用前 210,225 个时间步作为训练数据,因此我们只计算这个数据分量的均值和标准差。

mean = raw_data[:num_train_samples].mean(axis=0)
raw_data -= mean
std = raw_data[:num_train_samples].std(axis=0)
raw_data /= std 

列表 13.6:数据归一化

接下来,让我们创建一个 Dataset 对象,它提供过去五天的数据批次以及未来 24 小时的温度目标。由于数据集中的样本高度冗余(样本 N 和样本 N + 1 将有大部分时间步长是相同的),明确为每个样本分配内存将是浪费的。相反,我们将动态生成样本,同时只在内存中保留原始 raw_datatemperature 数组,不再需要其他任何东西。

我们可以轻松地编写一个 Python 生成器来完成这项工作,但 Keras 中有一个内置的数据集实用工具可以做到这一点(timeseries_dataset_from_array()),因此我们可以通过使用它来节省一些工作。您通常可以使用它来完成任何类型的时序预测任务。

我们将使用 timeseries_dataset_from_array 来实例化三个数据集:一个用于训练,一个用于验证,一个用于测试。

我们将使用以下参数值:

  • sampling_rate = 6 — 观测将以每小时一个数据点的频率进行采样:我们将只保留六个数据点中的一个。

  • sequence_length = 120 — 观测将回溯五天(120 小时)。

  • delay = sampling_rate * (sequence_length + 24 - 1) — 序列的目标将是序列结束后的 24 小时温度。

  • start_index = 0end_index = num_train_samples — 对于训练数据集,仅使用前 50%的数据。

  • start_index = num_train_samplesend_index = num_train_samples + num_val_samples — 对于验证数据集,仅使用接下来的 25%的数据。

  • start_index = num_train_samples + num_val_samples — 对于测试数据集,使用剩余的样本。

sampling_rate = 6
sequence_length = 120
delay = sampling_rate * (sequence_length + 24 - 1)
batch_size = 256

train_dataset = keras.utils.timeseries_dataset_from_array(
    raw_data[:-delay],
    targets=temperature[delay:],
    sampling_rate=sampling_rate,
    sequence_length=sequence_length,
    shuffle=True,
    batch_size=batch_size,
    start_index=0,
    end_index=num_train_samples,
)

val_dataset = keras.utils.timeseries_dataset_from_array(
    raw_data[:-delay],
    targets=temperature[delay:],
    sampling_rate=sampling_rate,
    sequence_length=sequence_length,
    shuffle=True,
    batch_size=batch_size,
    start_index=num_train_samples,
    end_index=num_train_samples + num_val_samples,
)

test_dataset = keras.utils.timeseries_dataset_from_array(
    raw_data[:-delay],
    targets=temperature[delay:],
    sampling_rate=sampling_rate,
    sequence_length=sequence_length,
    shuffle=True,
    batch_size=batch_size,
    start_index=num_train_samples + num_val_samples,
) 

列表 13.7:实例化用于训练、验证和测试的数据集

每个数据集都产生一个元组 (samples, targets),其中 samples 是包含 256 个样本的批次,每个样本包含 120 个连续小时的输入数据,而 targets 是相应的 256 个目标温度数组。请注意,样本是随机打乱的,因此批次中的连续序列(如 samples[0]samples[1])不一定在时间上接近。

>>> for samples, targets in train_dataset:
>>>     print("samples shape:", samples.shape)
>>>     print("targets shape:", targets.shape)
>>>     break
samples shape: (256, 120, 14)
targets shape: (256,)

列表 13.8:检查数据集

常识性、非机器学习基线

在您开始使用黑盒深度学习模型来解决温度预测问题之前,让我们尝试一种简单、常识性的方法。这将作为合理性检查,并建立一个基线,您必须超越这个基线才能证明更高级、基于机器学习模型的实用性。这种常识性基线在您面对一个尚未找到已知解决方案的新问题时可能很有用。一个经典的例子是不平衡分类任务,其中某些类别比其他类别更常见。如果您的数据集中包含 90%的类别 A 实例和 10%的类别 B 实例,那么对分类任务的常识性方法是,在呈现新样本时始终预测“A”。这样的分类器总体准确率为 90%,因此任何基于学习的算法都应该超越这个 90%的分数以证明其有用性。有时,这样的基本基线可能难以超越。

在这个情况下,可以安全地假设温度时间序列是连续的(明天的温度很可能接近今天的温度),并且具有每日周期性。因此,一种常识性的方法是始终预测 24 小时后的温度将与现在的温度相等。让我们使用以下定义的均方误差(MAE)指标来评估这种方法:

np.mean(np.abs(preds - targets)) 

这里是评估循环。

def evaluate_naive_method(dataset):
    total_abs_err = 0.0
    samples_seen = 0
    for samples, targets in dataset:
        # The temperature feature is at column 1, so `samples[:, -1,
        # 1]` is the last temperature measurement in the input
        # sequence. Recall that we normalized our features to retrieve
        # a temperature in Celsius degrees, we need to un-normalize it,
        # by multiplying it by the standard deviation and adding back
        # the mean.
        preds = samples[:, -1, 1] * std[1] + mean[1]
        total_abs_err += np.sum(np.abs(preds - targets))
        samples_seen += samples.shape[0]
    return total_abs_err / samples_seen

print(f"Validation MAE: {evaluate_naive_method(val_dataset):.2f}")
print(f"Test MAE: {evaluate_naive_method(test_dataset):.2f}") 

列表 13.9:计算常识性基线 MAE

这个常识性基线实现了 2.44 摄氏度的验证 MAE 和 2.62 摄氏度的测试 MAE。所以如果你总是假设 24 小时后的温度将与现在相同,你平均会差两度半。这并不太糟糕,但你可能不会基于这个启发式方法推出天气预报服务。现在,游戏规则是利用你对深度学习的知识做得更好。

让我们尝试一个基本的机器学习模型

就像在尝试机器学习方法之前建立常识性基线一样有用,在考虑复杂且计算成本高的模型(如 RNNs)之前,尝试简单的、便宜的机器学习模型(如小型、密集连接的网络)也是有用的。这是确保你向问题添加的任何进一步复杂性都是合法的并且能带来真正好处的方法。

列表 13.10 显示了从数据开始扁平化,然后通过两个Dense层运行的完全连接模型。注意最后一个Dense层上缺少激活函数,这在回归问题中是典型的。我们使用均方误差(MSE)作为损失,而不是 MAE,因为与 MAE 不同,它在零点周围是平滑的,这对于梯度下降是一个有用的属性。我们将通过在compile()中添加它作为度量来监控 MAE。

import keras
from keras import layers

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Flatten()(inputs)
x = layers.Dense(16, activation="relu")(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
    # We use a callback to save the best-performing model.
    keras.callbacks.ModelCheckpoint("jena_dense.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=10,
    validation_data=val_dataset,
    callbacks=callbacks,
)

# Reloads the best model and evaluates it on the test data
model = keras.models.load_model("jena_dense.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}") 

列表 13.10:训练和评估密集连接模型

让我们展示验证和训练的损失曲线(见图 13.3)。

import matplotlib.pyplot as plt

loss = history.history["mae"]
val_loss = history.history["val_mae"]
epochs = range(1, len(loss) + 1)
plt.figure()
plt.plot(epochs, loss, "r--", label="Training MAE")
plt.plot(epochs, val_loss, "b", label="Validation MAE")
plt.title("Training and validation MAE")
plt.legend()
plt.show() 

列表 13.11:绘制结果

图 13.3:使用简单、密集连接网络在 Jena 温度预测任务上的训练和验证 MAE

一些验证损失接近无学习基线,但并不可靠。这证明了最初建立这个基线的价值:结果证明很难超越。你的常识中包含了许多机器学习模型无法访问的有价值信息。

你可能会想,如果存在一个简单且表现良好的模型可以从数据到目标(常识性基线)进行转换,为什么你正在训练的模型找不到并改进它?好吧,你正在寻找解决方案的模型空间——即你的假设空间——是你定义的配置下所有可能的二层网络的空间。常识性启发式方法只是这个空间中可以表示的数百万个模型之一。这就像在干草堆里找针一样。仅仅因为你的假设空间中技术上存在一个好的解决方案,并不意味着你能够通过梯度下降找到它。

这是对机器学习的一般限制相当大的一个:除非学习算法被硬编码为寻找特定类型的简单模型,否则它有时可能无法找到简单问题的简单解决方案。这就是为什么使用良好的特征工程和相关的架构先验是至关重要的:你需要精确地告诉你的模型它应该寻找什么。

让我们尝试一个 1D 卷积模型

说到使用正确的架构先验:由于我们的输入序列具有日周期,也许一个卷积模型可以工作?时间卷积网络可以在不同天之间重用相同的表示,就像空间卷积网络可以在图像的不同位置重用相同的表示一样。

你已经了解 Conv2DSeparableConv2D 层,这些层通过小窗口在 2D 网格上滑动来观察其输入。这些层也有 1D 和甚至 3D 的版本:Conv1DSeparableConv1DConv3D。^([2]) Conv1D 层依赖于在输入序列上滑动的 1D 窗口,而 Conv3D 层依赖于在输入体积上滑动的立方窗口。

因此,你可以构建 1D ConvNets,严格类似于 2D ConvNets。它们非常适合遵循平移不变性假设的任何序列数据(这意味着如果你在序列上滑动一个窗口,窗口的内容应该独立于窗口的位置遵循相同的属性)。

让我们在温度预测问题上尝试一下。我们将选择一个初始窗口长度为 24,这样我们一次查看 24 小时的数据(一个周期)。当我们通过 MaxPooling1D 层下采样序列时,我们将相应地减小窗口大小(图 13.4):

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Conv1D(8, 24, activation="relu")(inputs)
x = layers.MaxPooling1D(2)(x)
x = layers.Conv1D(8, 12, activation="relu")(x)
x = layers.MaxPooling1D(2)(x)
x = layers.Conv1D(8, 6, activation="relu")(x)
x = layers.GlobalAveragePooling1D()(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
    keras.callbacks.ModelCheckpoint("jena_conv.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=10,
    validation_data=val_dataset,
    callbacks=callbacks,
)

model = keras.models.load_model("jena_conv.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}") 

图 13.4:使用 1D ConvNet 在 Jena 温度预测任务上的训练和验证 MAE

结果表明,这个模型的表现甚至比密集连接的模型还要差,只实现了大约 2.9 度的验证 MAE,远低于常识基线。这里出了什么问题?两件事:

  • 首先,天气数据并不完全遵守平移不变性假设。虽然数据确实具有日周期,但早晨的数据与傍晚或半夜的数据具有不同的属性。天气数据只在非常特定的时间尺度上具有平移不变性。

  • 其次,我们数据中的顺序很重要——非常重要。最近的数据对于预测第二天温度的信息量远大于五天前的数据。1D ConvNet 无法利用这一事实。特别是,我们的最大池化和全局平均池化层在很大程度上破坏了顺序信息。

循环神经网络

无论是全连接方法还是卷积方法都没有取得好成绩,但这并不意味着机器学习不适用于这个问题。全连接方法首先将时间序列展平,这从输入数据中移除了时间的概念。卷积方法以相同的方式处理数据的每个部分,甚至应用池化,这破坏了顺序信息。让我们相反地看待数据:它是一个序列,其中因果关系和顺序很重要。

有一种神经网络架构族是专门为这种用例设计的:循环神经网络。其中,特别是长短期记忆(LSTM)层长期以来一直非常受欢迎。我们将在下一分钟看到这些模型是如何工作的——但让我们先尝试一下 LSTM 层。

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.LSTM(16)(inputs)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
    keras.callbacks.ModelCheckpoint("jena_lstm.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=10,
    validation_data=val_dataset,
    callbacks=callbacks,
)

model = keras.models.load_model("jena_lstm.keras")
print("Test MAE: {model.evaluate(test_dataset)[1]:.2f}") 

列表 13.12:一个简单的基于 LSTM 的模型

图 13.5 展示了结果。好多了!我们实现了验证 MAE 低至 2.39 度,测试 MAE 为 2.55 度。基于 LSTM 的模型终于打败了常识基线(尽管目前只是略微领先),展示了机器学习在此任务中的价值。

图 13.5:使用基于 LSTM 的模型在耶拿温度预测任务上的训练和验证 MAE。(注意,我们在此图中省略了第 1 个 epoch,因为第 1 个 epoch 的高训练 MAE(7.75)会扭曲比例。)

但为什么 LSTM 模型的表现明显优于密集连接模型或卷积神经网络?我们如何进一步优化模型?为了回答这个问题,让我们更深入地研究循环神经网络。

理解循环神经网络

你迄今为止看到的所有神经网络的主要特征,如密集连接网络和卷积神经网络,是它们没有记忆。它们展示给每个输入的处理都是独立的,输入之间没有保持状态。对于这样的网络,要处理一个序列或时间序列数据点,你必须一次性将整个序列展示给网络:将其转换成一个单一的数据点。例如,这就是我们在密集连接网络示例中所做的:我们将五天的数据展平成一个大的向量,并一次性处理它。这样的网络被称为 前馈网络

相比之下,当你阅读本句时,你正在逐字逐句地处理它——或者说,通过眼球运动逐个处理——同时保持对之前内容的记忆;这为你提供了对句子所传达意义的流畅表征。生物智能在处理信息时是逐步进行的,同时保持一个内部模型,该模型由过去的信息构建而成,并随着新信息的到来而不断更新。

一个 循环神经网络 (RNN) 采用相同的原理,尽管是一个极其简化的版本:它通过迭代序列元素并维护一个包含有关其迄今为止所看到的信息的 状态 来处理序列。实际上,RNN 是一种具有内部 循环 的神经网络(见图 13.6)。

图片

图 13.6:循环网络:具有循环的网络

在处理两个不同、独立的序列(例如批处理中的两个样本)之间,RNN 的状态被重置,因此你仍然将一个序列视为一个单独的数据点:网络的单一输入。变化的是,这个数据点不再在单个步骤中处理;相反,网络内部遍历序列元素。

为了使这些关于 循环状态 的概念清晰,让我们实现一个玩具 RNN 的前向传递。这个 RNN 将一个向量序列作为输入,我们将它编码为一个大小为 (timesteps, input_features) 的二维张量。它遍历时间步长,并在每个时间步长,它考虑其在 t 时的当前状态和 t 时的输入(形状为 (input_features,)),并将它们结合起来以获得 t 时的输出。然后我们将下一个步骤的状态设置为这个先前的输出。对于第一个时间步长,先前的输出没有定义;因此,没有当前状态。所以我们将状态初始化为名为网络的 初始状态 的全零向量。

在伪代码中,这是 RNN。

# The state at t
state_t = 0
# Iterates over sequence elements
for input_t in input_sequence:
    output_t = f(input_t, state_t)
    # The previous output becomes the state for the next iteration.
    state_t = output_t 

代码列表 13.13:伪代码 RNN

你甚至可以细化函数 f:输入和状态到输出的转换将由两个矩阵 WU 以及一个偏置向量来参数化。这类似于前馈网络中密集连接层所进行的转换。

state_t = 0
for input_t in input_sequence:
    output_t = activation(dot(W, input_t) + dot(U, state_t) + b)
    state_t = output_t 

代码列表 13.14:RNN 的更详细伪代码

为了使这些概念绝对明确,让我们编写一个简单的 NumPy 实现,用于简单 RNN 的前向传递。

import numpy as np

# Number of timesteps in the input sequence
timesteps = 100
# Dimensionality of the input feature space
input_features = 32
# Dimensionality of the output feature space
output_features = 64
# Input data: random noise for the sake of the example
inputs = np.random.random((timesteps, input_features))
# Initial state: an all-zero vector
state_t = np.zeros((output_features,))
# Creates random weight matrices
W = np.random.random((output_features, input_features))
U = np.random.random((output_features, output_features))
b = np.random.random((output_features,))
successive_outputs = []
# input_t is a vector of shape (input_features,).
for input_t in inputs:
    # Combines the input with the current state (the previous output)
    # to obtain the current output. We use tanh to add nonlinearity (we
    # could use any other activation function).
    output_t = np.tanh(np.dot(W, input_t) + np.dot(U, state_t) + b)
    # Stores this output in a list
    successive_outputs.append(output_t)
    # Updates the state of the network for the next timestep
    state_t = output_t
# The final output is a rank-2 tensor of shape (timesteps,
# output_features).
final_output_sequence = np.concatenate(successive_outputs, axis=0) 

代码列表 13.15:简单 RNN 的 NumPy 实现

足够简单:总的来说,RNN 是一个 for 循环,它重复使用循环前一次迭代中计算出的量,仅此而已。当然,有许多不同的 RNN 符合这个定义,你可以构建——这个例子是其中最简单的 RNN 公式之一。RNN 以其步函数为特征,例如本例中的以下函数(见图 13.7):

output_t = tanh(matmul(input_t, W) + matmul(state_t, U) + b) 

图片

图 13.7:随时间展开的简单 RNN

Keras 中的循环层

你刚才在 NumPy 中天真地实现的流程对应于实际的 Keras 层——SimpleRNN 层。

有一个细微的差别:SimpleRNN 处理序列批次,就像所有其他 Keras 层一样,而不是像 NumPy 示例中的单个序列。这意味着它接受形状为 (batch_size, timesteps, input_features) 的输入,而不是 (timesteps, input_features)。当指定你的初始 Input()shape 参数时,请注意你可以将 timesteps 项设置为 None,这使你的网络能够处理任意长度的序列。

num_features = 14
inputs = keras.Input(shape=(None, num_features))
outputs = layers.SimpleRNN(16)(inputs) 

列表 13.16:一个可以处理任意长度序列的 RNN 层

如果你的模型旨在处理可变长度的序列,这尤其有用。然而,如果你的所有序列长度都相同,我建议指定完整的输入形状,因为它使 model.summary() 能够显示输出长度信息,这总是很棒,并且它可以解锁一些性能优化(请参阅本章后面的“关于 RNN 运行时性能”的注释)。

Keras 中的所有循环层(SimpleRNNLSTMGRU)都可以以两种不同的模式运行:它们可以返回每个时间步的连续输出序列(形状为 (batch_size, timesteps, output_features) 的秩 3 张量)或每个输入序列的最后一个输出(形状为 (batch_size, output_features) 的秩 2 张量)。这两个模式由 return_sequences 构造函数参数控制。让我们看看使用 SimpleRNN 并仅返回最后一个时间步输出的示例。

>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> # Note that return_sequences=False is the default.
>>> outputs = layers.SimpleRNN(16, return_sequences=False)(inputs)
>>> print(outputs.shape)
(None, 16)

列表 13.17:一个仅返回其最后一个输出步骤的 RNN 层

以下示例返回完整的输出序列。

>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> # Sets return_sequences to True
>>> outputs = layers.SimpleRNN(16, return_sequences=True)(inputs)
>>> print(outputs.shape)
(None, 120, 16)

列表 13.18:一个返回其完整输出序列的 RNN 层

有时,将几个循环层一个接一个地堆叠以增加网络的表示能力是有用的。在这种设置中,你必须确保所有中间层都返回完整的输出序列。

inputs = keras.Input(shape=(steps, num_features))
x = layers.SimpleRNN(16, return_sequences=True)(inputs)
x = layers.SimpleRNN(16, return_sequences=True)(x)
outputs = layers.SimpleRNN(16)(x) 

列表 13.19:堆叠 RNN 层

现在,在实践中,你很少会使用 SimpleRNN 层。它通常过于简单,没有实际用途。特别是,SimpleRNN 有一个主要问题:尽管从理论上讲,它应该能够在时间 t 保留关于许多时间步之前看到的输入的信息,但在实践中,这样的长期依赖关系证明是无法学习的。这是由于 梯度消失问题,这是一种类似于观察到的非循环网络(前馈网络)深层的效应:当你继续向网络添加层时,网络最终变得无法训练。这种效应的理论原因在 20 世纪 90 年代初期由 Hochreiter、Schmidhuber 和 Bengio 研究了.^([3])

幸运的是,SimpleRNN 并不是 Keras 中唯一的循环层。还有两个其他选项:LSTMGRU,它们被设计来解决这个问题。

让我们考虑 LSTM 层。在 1997 年,Hochreiter 和 Schmidhuber 开发了底层的长短期记忆(LSTM)算法;^([4]) 这是他们关于梯度消失问题的研究的高潮。

这一层是你已经了解的 SimpleRNN 层的一个变体;它增加了一种在许多时间步长之间传递信息的方式。想象一下一个与你要处理的序列平行运行的传送带。序列中的信息可以在任何一点跳上传送带,被运送到一个更晚的时间步长,并在你需要时完整地跳下来。这正是 LSTM 所做的:它为以后保存信息,从而防止在处理过程中较老的信号逐渐消失。这应该让你想起在第九章中学到的 残差连接,基本上是同一个想法。

要详细了解这一过程,让我们从 SimpleRNN 单元开始(见图 13.8)。因为你会有很多权重矩阵,所以用字母 oWoUo)对单元中的 WU 矩阵进行索引,代表 output

图 13.8:LSTM 层的起点:一个 SimpleRNN

让我们向这幅图添加一个额外的数据流,它携带信息跨越时间步长。在不同的时间步长中,称其值为 Ct,其中 C 代表 carry。这一信息将对细胞产生以下影响:它将与输入连接和循环连接(通过一个密集变换:与权重矩阵的点积,然后加上偏差并应用激活函数)相结合,并且它将影响发送到下一个时间步长的状态(通过一个激活函数和一个乘法操作)。从概念上讲,携带数据流是一种调节下一个输出和下一个状态的方式(见图 13.9)。到目前为止,很简单。

图 13.9:从 SimpleRNN 到 LSTM:添加一个携带轨迹

现在的微妙之处:计算携带数据流下一个值的方式。它涉及三个不同的变换。所有三个都具有 SimpleRNN 单元的形态:

y = activation(dot(state_t, U) + dot(input_t, W) + b) 

但所有三个变换都有自己的权重矩阵,你将用字母 ifk 来索引。到目前为止,你有的如下(可能看起来有点随意,但请耐心等待)。

output_t = activation(dot(state_t, Uo) + dot(input_t, Wo) + dot(C_t, Vo) + bo)
i_t = activation(dot(state_t, Ui) + dot(input_t, Wi) + bi)
f_t = activation(dot(state_t, Uf) + dot(input_t, Wf) + bf)
k_t = activation(dot(state_t, Uk) + dot(input_t, Wk) + bk) 

列表 13.20:LSTM 架构的伪代码细节(1/2)

你通过结合 i_tf_tk_t 来获得新的携带状态(下一个 c_t)。

c_t+1 = i_t * k_t + c_t * f_t 

列表 13.21:LSTM 架构的伪代码细节(2/2)

如图 13.10 所示添加。就是这样。并不复杂——只是稍微有点复杂。

图 13.10:LSTM 的解剖结构

如果你想要从哲学的角度来解释,你可以解释这些操作的目的。例如,你可以认为乘以 c_tf_t 是在携带数据流中故意忘记无关信息的一种方式。同时,i_tk_t 提供了关于当前的信息,用新信息更新携带轨迹。但最终,这些解释并没有太多意义,因为这些操作实际上做什么是由参数化它们的权重内容决定的,而权重是以端到端的方式学习的,每次训练循环都会重新开始,这使得无法将特定目的归因于这个或那个操作。正如刚才所描述的,RNN 单元的指定确定了你的假设空间——你在训练过程中将搜索良好模型配置的空间——但它并不确定单元做什么;这取决于单元权重。具有不同权重的相同单元可以执行非常不同的操作。因此,构成 RNN 单元的操作组合最好解释为对搜索的约束,而不是从工程角度的设计

争议性地,这种约束的选择——如何实现 RNN 单元的疑问——最好留给优化算法(如遗传算法或强化学习过程)而不是人类工程师。在未来,我们将以此方式构建我们的模型。总之,你不需要了解 LSTM 单元的具体架构;作为人类,这不应该成为你的工作。只需记住 LSTM 单元的目的:允许过去的信息在稍后时间重新注入,从而对抗梯度消失问题。

充分利用循环神经网络

到目前为止,你已经学习了

  • RNN 是什么以及它们是如何工作的

  • LSTM 是什么以及为什么它在长序列上比简单的 RNN 工作得更好

  • 如何使用 Keras RNN 层处理序列数据

接下来,我们将回顾 RNN 的许多更高级功能,这些功能可以帮助你充分利用你的深度学习序列模型。到本节结束时,你将了解关于使用 Keras 中的循环网络的大部分知识。

我们将涵盖以下内容:

  • 循环 dropout  — 这是 dropout 的一种变体,用于对抗循环层中的过拟合。

  • 堆叠循环层  — 这增加了模型的表示能力(但以更高的计算负载为代价)。

  • 双向循环层 — 这些层以不同的方式向循环网络呈现相同的信息,从而提高准确率并减轻遗忘问题。

我们将使用这些技术来完善我们的温度预测 RNN。

使用循环 dropout 来对抗过拟合

让我们回到本章早期使用的基于 LSTM 的模型——我们第一个能够击败常识基线的模型。如果你查看训练和验证曲线,很明显,尽管模型只有非常少的单元,但它很快就开始过拟合了:训练和验证损失在几个 epoch 之后开始显著发散。你已经熟悉了对抗这种现象的经典技术:dropout,它随机将层的输入单元置零,以打破层所暴露的训练数据中的偶然相关性。但是,如何在循环网络中正确应用 dropout 并不是一个简单的问题。

很早就已经知道,在循环层之前应用 dropout 会阻碍学习而不是帮助正则化。2015 年,Yarin Gal 在他的关于贝叶斯深度学习的博士论文中,^([5])确定了在循环网络中使用 dropout 的正确方法:应该在每个 timestep 应用相同的 dropout 掩码(相同的丢弃单元模式),而不是在每个 timestep 随机变化的 dropout 掩码。更重要的是,为了正则化由GRULSTM等层的循环门形成的表示,应该对层的内部循环激活应用时间常数 dropout 掩码(循环 dropout 掩码)。在每个 timestep 使用相同的 dropout 掩码允许网络正确地通过时间传播其学习误差;时间随机的 dropout 掩码会破坏这个误差信号,对学习过程有害。

Yarin Gal 使用 Keras 进行了他的研究,并帮助将这种机制直接集成到 Keras 循环层中。Keras 中的每个循环层都有两个与 dropout 相关的参数:dropout,一个浮点数,指定层输入单元的 dropout 率,以及recurrent_dropout,指定循环单元的 dropout 率。让我们将循环 dropout 添加到我们第一个 LSTM 示例的LSTM层中,看看这样做会如何影响过拟合。

多亏了 dropout,我们不需要那么依赖网络大小来进行正则化,所以我们将使用具有两倍单元数量的LSTM层,这应该能够更充分地表达(如果没有 dropout,这个网络会立即开始过拟合——试试看)。因为使用 dropout 进行正则化的网络总是需要更长的时间才能完全收敛,我们将模型训练的 epoch 数增加到五倍。

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.LSTM(32, recurrent_dropout=0.25)(inputs)
# To regularize the Dense layer, we also add a Dropout layer after the
# LSTM.
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
    keras.callbacks.ModelCheckpoint(
        "jena_lstm_dropout.keras", save_best_only=True
    )
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=50,
    validation_data=val_dataset,
    callbacks=callbacks,
) 

代码列表 13.22:使用 dropout 正则化的 LSTM 进行训练和评估

图 13.11 显示了结果。成功了!在前 20 个 epoch 中,我们不再过拟合。我们实现了 2.27 度的验证 MAE(相对于无学习基线提高了 7%)和 2.45 度的测试 MAE(相对于基线提高了 6.5%)。还不错。

图片

图 13.11:使用 dropout 正则化的 LSTM 在耶拿温度预测任务上的训练和验证损失

堆叠循环层

由于你不再过度拟合,但似乎已经达到了性能瓶颈,你应该考虑增加网络的容量和表达能力。回想一下通用机器学习工作流程的描述:通常,直到过度拟合成为主要障碍(假设你已经采取了基本步骤来减轻过度拟合,例如使用 dropout),增加你模型的容量是一个好主意。只要你不严重过度拟合,你很可能容量不足。

增加网络容量通常是通过增加层中的单元数量或添加更多层来实现的。循环层堆叠是构建更强大循环网络的经典方法:例如,不久前,谷歌翻译算法是由七个大型 LSTM 层堆叠而成的——这非常庞大。

在 Keras 中堆叠循环层时,所有中间层应返回它们的完整输出序列(一个秩为 3 的张量),而不是最后一个时间步的输出。正如你已经学到的,这是通过指定 return_sequences=True 来实现的。

在下面的示例中,我们将尝试堆叠两个带有 dropout 正则化的循环层。为了改变一下,我们将使用 GRU 层而不是 LSTM。门控循环单元(GRU)与 LSTM 非常相似——你可以将其视为 LSTM 架构的一个略微简单、精简的版本。它由 Cho 等人在 2014 年引入,当时循环网络正开始重新引起当时微小研究社区的注意.^([6])

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.GRU(32, recurrent_dropout=0.5, return_sequences=True)(inputs)
x = layers.GRU(32, recurrent_dropout=0.5)(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
    keras.callbacks.ModelCheckpoint(
        "jena_stacked_gru_dropout.keras", save_best_only=True
    )
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=50,
    validation_data=val_dataset,
    callbacks=callbacks,
)
model = keras.models.load_model("jena_stacked_gru_dropout.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}") 

列表 13.23:训练和评估一个带有 dropout 正则化的堆叠 GRU 模型

图 13.12 展示了结果。我们实现了 2.39 度的测试 MAE(相对于基线提高了 8.8%)。你可以看到添加的层确实略微提高了结果,但并不显著。你可能会看到增加网络容量所带来的回报正在减少。

图 13.12:使用堆叠 GRU 网络在耶拿温度预测任务上的训练和验证损失

使用双向 RNN

本节最后介绍的技术称为 双向 RNN。双向 RNN 是一种常见的 RNN 变体,在某些任务上可以提供比常规 RNN 更好的性能。它经常用于自然语言处理——你可以称其为自然语言处理的深度学习瑞士军刀。

RNNs(循环神经网络)特别依赖于顺序:它们按顺序处理输入序列的时间步长,并且打乱或反转时间步长可以完全改变 RNN 从序列中提取的表示。这正是它们在顺序有意义的任务上表现良好的原因,例如温度预测问题。双向 RNN 利用了 RNN 的顺序敏感性:它由两个常规 RNN 组成,例如你已熟悉的GRULSTM层,每个层都按一个方向(按时间顺序和逆时间顺序)处理输入序列,然后合并它们的表示。通过两种方式处理序列,双向 RNN 可以捕捉到单方向 RNN 可能忽略的图案。

值得注意的是,本节中 RNN 层按时间顺序处理序列(较早的时间步长首先)可能是一个任意决定。至少,这是我们迄今为止没有尝试质疑的决定。如果 RNN 按逆时间顺序处理输入序列,例如,先处理较新的时间步长,它们是否仍然能足够好地表现?让我们在实践中尝试一下,看看会发生什么。你所需要做的就是编写一个数据生成器的变体,其中输入序列沿时间维度被反转(将最后一行替换为yield samples[:, ::-1, :], targets)。

当训练本节第一实验中使用的相同基于 LSTM 的模型时,你会发现这种逆序 LSTM 的表现甚至比常识基线还要差。这表明,在这种情况下,按时间顺序处理对于方法的成功至关重要。这完全说得通:底层的LSTM层通常在记住近期过去方面比记住遥远过去要好,而且,自然地,对于这个问题(这就是常识基线相当强大)来说,较近的天气数据点比较旧的数据点更有预测性。因此,按时间顺序的层版本必然会优于逆序版本。

然而,这种情况并不适用于许多其他问题,包括自然语言:直观上,一个词在理解一个句子中的重要性并不强烈依赖于它在句子中的位置。在文本数据上,逆序处理与按时间顺序处理一样有效——你可以很好地倒着阅读文本(试试看!)尽管词序在理解语言中确实很重要,但使用哪种顺序并不关键。

重要的是,在反转序列上训练的 RNN 将学习与在原始序列上训练的不同表示,就像如果你在真实世界中时间倒流,你会拥有不同的心理模型——如果你在第一天死去,在最后一天出生。在机器学习中,不同有用的表示总是值得利用的,而且它们越不同,越好:它们提供了从新的角度看待你的数据,捕捉到其他方法遗漏的数据方面,从而有助于提高任务的性能。这是集成背后的直觉,我们将在第十八章中探讨这个概念。

双向 RNN 利用这个想法来提高按时间顺序的 RNN 的性能。它以两种方式查看其输入序列(见图 13.13),获得可能更丰富的表示并捕捉到仅由时间顺序版本单独遗漏的模式。

图片

图 13.13:双向 RNN 层的工作原理

在 Keras 中实例化一个双向 RNN,你使用Bidirectional层,它将一个循环层实例作为其第一个参数。Bidirectional创建这个循环层的第二个、独立的实例,并使用一个实例按时间顺序处理输入序列,另一个实例按相反顺序处理输入序列。你可以在我们的温度预测任务上尝试它。

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Bidirectional(layers.LSTM(16))(inputs)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
    train_dataset,
    epochs=10,
    validation_data=val_dataset,
) 

列表 13.24:训练和评估双向 LSTM

你会发现它的表现不如普通的LSTM层。这很容易理解:所有的预测能力都必须来自网络的时间顺序部分,因为已知反时间顺序部分在这个任务上表现严重不足(再次强调,在这个案例中,近期过去比遥远过去更重要)。同时,反时间顺序部分的存在使网络的能力加倍,并导致它过早地开始过拟合。

然而,双向 RNN 非常适合文本数据——或者任何其他顺序很重要,但使用哪种顺序无关紧要的数据。事实上,在 2016 年一段时间内,双向 LSTM 被认为在许多自然语言处理任务中是最佳实践(在 Transformer 架构兴起之前,你将在第十五章中了解到这一点)。

更进一步

你还可以尝试许多其他方法来提高温度预测问题的性能:

  • 调整堆叠设置中每个循环层的单元数量以及 dropout 的数量。当前的选择很大程度上是任意的,因此可能不是最优的。

  • 调整Adam优化器使用的学习率或尝试不同的优化器。

  • 尝试使用Dense层的堆叠作为循环层上的回归器,而不是单个Dense层。

  • 改善模型的输入:尝试使用更长或更短的序列,或不同的采样率,或者开始进行特征工程。

和往常一样,深度学习更是一门艺术而不是科学。我们可以提供一些指南,这些指南可能会告诉你某个特定问题可能或可能不会有效,但最终,每个数据集都是独特的;你必须通过经验评估不同的策略。目前还没有一种理论可以提前精确地告诉你应该做什么来最优地解决问题。你必须迭代。

根据我们的经验,通过大约 10%提高无学习基准可能是你用这个数据集能做的最好了。这并不那么出色,但这些结果是有道理的:如果你能获取来自不同位置广泛网格的数据,那么近未来的天气是高度可预测的,但如果只有来自单个位置的数据,那么它就不是很可预测了。你所在地区的天气演变取决于周围地区的当前天气模式。

摘要

  • 正如你在第六章首次学习的那样,在处理一个新问题时,首先为你的选择指标建立常识性基准是好的。如果你没有基准可以超越,你就无法判断你是否真的取得了进步。

  • 在尝试昂贵的模型之前,先尝试简单的模型,以证明额外支出的合理性。有时一个简单的模型最终会变成你最佳的选择。

  • 当你拥有顺序重要数据时——特别是对于时间序列数据——循环网络是一个很好的选择,并且可以轻易超越首先将时间数据展平的模型。Keras 中可用的两个基本 RNN 层是LSTM层和GRU层。

  • 要在循环网络中使用 dropout,你应该使用时间恒定的 dropout 掩码和循环 dropout 掩码。这些已经内置在 Keras 循环层中,所以你只需要使用循环层的recurrent_dropout参数。

  • 堆叠的 RNN 比单个 RNN 层具有更强的表示能力。但它们也昂贵得多,因此并不总是值得。尽管它们在复杂问题(如机器翻译)上提供了明显的收益,但它们可能并不总是适用于较小、较简单的问题。

脚注

  1. Adam Erickson 和 Olaf Kolle,www.bgc-jena.mpg.de/wetter[↩]

  2. 没有实现SeparableConv3D层,不是出于任何理论原因,而是因为我们还没有实现它。[↩]

  3. 例如,参见 Yoshua Bengio、Patrice Simard 和 Paolo Frasconi,“使用梯度下降学习长期依赖性是困难的”,IEEE 神经网络杂志 5,第 2 期(1994 年)。[↩]

  4. Sepp Hochreiter 和 Jürgen Schmidhuber,“长短期记忆”,神经计算 9,第 8 期(1997 年)。[↩]

  5. 参见 Yarin Gal 的论文,“深度学习中的不确定性(博士论文)”,2016 年 10 月 13 日,www.cs.ox.ac.uk/people/yarin.gal/website/blog_2248.html. [↩]

  6. 参见 Cho 等人,“关于神经机器翻译的性质:编码器-解码器方法”,2014 年,arxiv.org/abs/1409.1259. [↩]

第十四章:文本分类

原文:deeplearningwithpython.io/chapters/chapter14_text-classification

本章将为处理文本输入奠定基础,我们将在本书的下一章中继续构建。到本章结束时,你将能够以多种不同的方式构建一个简单的文本分类器。这将为本章构建更复杂的模型,如下一章中的 Transformer,做好准备。

自然语言处理简史

在计算机科学中,我们将人类语言,如英语或普通话,称为“自然”语言,以区别于为机器设计的语言,如 LISP、汇编和 XML。每种机器语言都是被设计的:它的起点是一位工程师写下一系列形式规则来描述你可以做出哪些陈述以及它们的含义。规则先出现,人们只有在规则集完成后才开始使用这种语言。对于人类语言来说,情况正好相反:使用先于规则的出现。自然语言是通过一个进化过程形成的,就像生物有机体一样——这就是它被称为“自然”的原因。它的“规则”,比如英语的语法,是在事后形式化的,并且经常被使用者忽略或违反。因此,尽管机器可读语言高度结构化和严格,自然语言却是混乱的——模糊的、混乱的、蔓延的,并且始终处于变化之中。

计算机科学家长期以来一直专注于能够摄入或产生自然语言的系统的潜力。语言,尤其是书面文本,支撑着我们的大多数沟通和文化生产。几个世纪的人类知识都是通过文本存储的;互联网主要是文本,甚至我们的思想也是基于语言的!使用计算机来解释和操作语言的做法被称为自然语言处理,简称 NLP。它是在第二次世界大战后立即作为一个研究领域提出的,当时有人认为我们可以将理解语言视为一种“密码破解”,其中自然语言是传输信息的“密码”。

在该领域的早期,许多人天真地认为可以写下“英语的规则集”,就像可以写下 LISP 的规则集一样。在 20 世纪 50 年代初,IBM 和乔治敦的研究人员展示了一个可以将俄语翻译成英语的系统。该系统使用了一个包含六个硬编码规则的语法和一个包含几百个元素(单词和后缀)的查找表,以准确翻译 60 个精心挑选的俄语句子。目标是激起对机器翻译的热情和资金支持,从这个意义上说,这是一个巨大的成功。尽管演示的性质有限,但作者声称,在五年内,翻译问题将得到解决。在接下来的十年中,资金大量涌入。然而,将这样的系统推广开来证明是极其困难的。单词的意义会根据上下文发生巨大变化。任何语法规则都需要无数例外。开发一个能够在几个精心挑选的例子上表现出色的程序是足够的简单,但建立一个能够与人类翻译者竞争的健壮系统则是另一回事。十年后,一份有影响力的美国报告剖析了缺乏进展的原因,资金也随之枯竭。

尽管有这些挫折和从兴奋到幻灭的反复波动,手工编写的规则在 20 世纪 90 年代中期仍然作为主导方法存在。问题很明显,但简单地写下描述语法的符号规则似乎没有可行的替代方案。然而,随着 20 世纪 80 年代末更快计算机和更大数据量的可用性,研究开始走向新的方向。当你发现自己正在构建大量临时规则的系统时,作为一个聪明的工程师,你可能会开始问自己,“我能用数据语料库来自动化寻找这些规则的过程吗?我能否在某个规则空间内搜索规则,而不是自己想出来?”就这样,你进入了机器学习的领域。

在 20 世纪 80 年代末,我们开始看到自然语言处理中机器学习方法的兴起。最早的这些方法基于决策树——其意图实际上是要自动化开发类似于硬编码语言系统的 if/then/else 规则。随后,统计方法开始加速发展,从逻辑回归开始。随着时间的推移,学习到的参数模型逐渐取代了主导地位,并且有些人认为,当直接嵌入到模型中时,语言学成了一种阻碍。早期语音识别研究者弗雷德里克·杰利内克在 20 世纪 90 年代开玩笑说:“每次我解雇一个语言学家,语音识别器的性能就会提高。”

就像计算机视觉是将模式识别应用于像素一样,现代自然语言处理领域完全是关于将模式识别应用于文本中的单词。实际应用并不缺乏:

  • 给定一封电子邮件的文本,它被判定为垃圾邮件的概率是多少?(文本分类

  • 给定一个英文句子,最可能的俄语翻译是什么?(翻译)

  • 给定一个不完整的句子,下一个可能出现的单词是什么?(语言建模)

在这本书中你将要训练的文本处理模型不会拥有类似人类对语言的理解;相反,它们只是在输入数据中寻找统计规律,而事实证明这足以在广泛的现实世界任务中表现良好。

在过去十年中,NLP 研究人员和实践者发现了学习关于文本的狭窄统计问题的答案可以有多么惊人地有效。在 2010 年代,研究人员开始将 LSTM 模型应用于文本,大大增加了 NLP 模型中的参数数量以及训练它们所需的计算资源。结果是令人鼓舞的——LSTMs 能够以比以前的方法更高的准确性泛化到未见过的例子,但它们最终遇到了限制。LSTMs 在处理包含许多句子和段落的文本中的长链依赖关系时遇到了困难,与计算机视觉模型相比,它们的训练既慢又难以控制。

到 2010 年代末,谷歌的研究人员发现了一种名为 Transformer 的新架构,该架构解决了许多困扰 LSTMs 的可扩展性问题。只要同时增加模型的大小和训练数据,Transformer 似乎就能越来越准确地执行。更好的是,训练 Transformer 所需的计算可以有效地并行化,即使是对于长序列也是如此。如果你将进行训练的机器数量加倍,你大约可以将等待结果的时间减半。

Transformer 架构的发现,以及 GPU 和 CPU 的持续加速,在过去几年中导致了 NLP 模型投资和兴趣的显著增长。ChatGPT 等聊天系统凭借其能够在看似任意的话题和问题上产生流畅自然的文本的能力,吸引了公众的注意。用于训练这些模型的原始文本是互联网上所有可用书面语言的一个很大部分,而训练单个模型所需的计算成本可能高达数百万美元。一些炒作是值得削减的——这些是模式识别机器。尽管我们持续的人类倾向是在“会说话的事物”中寻找智能,但这些模型以与人类智能截然不同(而且效率低得多)的方式复制和综合训练数据。然而,公平地说,从极其简单的“猜测缺失单词”训练设置中产生复杂行为,是过去十年机器学习中最令人震惊的实证结果之一。

在接下来的三个章节中,我们将探讨一系列用于文本数据的机器学习技术。我们将跳过关于直到 20 世纪 90 年代盛行的硬编码语言特征的讨论,但我们将从运行逻辑回归以对文本进行分类到训练 LSTM 进行机器翻译的一切进行探讨。我们将仔细检查 Transformer 模型,并讨论它在文本领域进行泛化时为什么如此可扩展和有效。让我们深入探讨。

准备文本数据

让我们考虑一个英文句子:

The quick brown fox jumped over the lazy dog. 

在我们可以开始应用前几章中提到的任何深度学习技术之前,有一个明显的障碍——我们的输入不是数字!在开始任何建模之前,我们需要将书面文字转换成数字张量。与具有相对自然数字表示的图像不同,你可以以几种方式构建文本的数字表示。

一种简单的方法是借鉴标准的文本文件格式,并使用类似 ASCII 编码的东西。我们可以将输入切割成字符序列,并为每个字符分配一个唯一的索引。另一种直观的方法是构建基于单词的表示,首先在所有空格和标点符号处将句子分开,然后将每个单词映射到一个唯一的数字表示。

这两种方法都是值得尝试的,通常,所有文本预处理都会包括一个分割步骤,即将文本分割成小的单个单元,称为标记。分割文本的一个强大工具是正则表达式,它可以灵活地匹配文本中字符的模式。

让我们看看如何使用正则表达式将字符串分割成字符序列。我们可以应用的最基本的正则表达式是".",它可以匹配输入文本中的任何字符:

import regex as re

def split_chars(text):
    return re.findall(r".", text) 

我们可以将该函数应用于我们的示例输入字符串:

>>> chars = split_chars("The quick brown fox jumped over the lazy dog.")
>>> chars[:12]
["T", "h", "e", " ", "q", "u", "i", "c", "k", " ", "b", "r"]

正则表达式可以很容易地应用于将我们的文本分割成单词。正则表达式"[\w]+"将抓取连续的非空白字符,而"[.,!?;]"可以匹配括号中的标点符号。我们可以将这两个结合起来,得到一个正则表达式,将每个单词和标点符号分割成标记:

def split_words(text):
    return re.findall(r"[\w]+|[.,!?;]", text) 

这里展示了它对一个测试句子的作用:

>>> split_words("The quick brown fox jumped over the dog.")
["The", "quick", "brown", "fox", "jumped", "over", "the", "dog", "."]

分割将我们从一个单独的字符串转换成标记序列,但我们仍然需要将我们的字符串标记转换成数字输入。迄今为止最常见的方法是将每个标记映射到一个唯一的整数索引,通常称为索引我们的输入。这是一种灵活且可逆的表示,可以与广泛的建模方法一起工作。稍后,我们可以决定如何将标记索引映射到模型摄入的潜在空间。

对于字符标记,我们可以使用 ASCII 查找来索引每个标记——例如,ord('A') → 65ord('z') → 122。然而,当你开始考虑其他语言时,这可能会扩展得不好——Unicode 规范中超过一百万个字符!一种更稳健的技术是从我们的训练数据中的特定标记构建到我们关心的数据的索引的映射,这在 NLP 中称为词汇表。它有一个很好的特性,即它对单词级标记和字符级标记都同样有效。

让我们看看我们如何可能使用一个词汇表来转换文本。我们将构建一个简单的 Python 字典,将标记映射到索引,将输入拆分为标记,并最终索引我们的标记:

vocabulary = {
    "[UNK]": 0,
    "the": 1,
    "quick": 2,
    "brown": 3,
    "fox": 4,
    "jumped": 5,
    "over": 6,
    "dog": 7,
    ".": 8,
}
words = split_words("The quick brown fox jumped over the lazy dog.")
indices = [vocabulary.get(word, 0) for word in words] 

这会输出以下内容:

[0, 2, 3, 4, 5, 6, 1, 0, 7, 8] 

我们向我们的词汇表中引入一个特殊标记"[UNK]",它代表一个对词汇表来说是未知的标记。这样,我们可以索引我们遇到的所有输入,即使某些术语只出现在我们的测试数据中。在先前的例子中,"lazy"映射到"[UNK]"索引 0,因为它不包括在我们的词汇表中。

通过这些简单的文本转换,我们正在稳步构建一个文本预处理管道。然而,我们还需要考虑一种常见的文本操作类型——标准化。

考虑这两个句子:

  • “日落时分。我凝视着墨西哥的天空。大自然多么壮丽??”

  • “Sunset came; I stared at the México sky. Isn’t nature splendid?”

它们非常相似——事实上,它们几乎是相同的。然而,如果你将它们转换为之前描述的索引,你会得到非常不同的表示,因为“i”和“I”是两个不同的字符,“Mexico”和“México”是两个不同的单词,“isnt”不是“isn’t”,等等。将文本标准化是一种基本的特征工程形式,旨在消除你不想让模型处理的编码差异。这也不局限于机器学习——如果你在构建搜索引擎,你也必须做同样的事情。

一个简单且广泛使用的标准化方案是将所有内容转换为小写并删除标点符号。我们的两个句子将变成

  • “日落时分,我凝视着墨西哥的天空,大自然多么壮丽”

  • “日落时分,我凝视着墨西哥的天空,大自然多么壮丽”

已经非常接近了。如果我们从所有字符中移除重音符号,我们可以更接近。

标准化有很多用途,它曾经是提高模型性能最关键的领域之一。在几十年的自然语言处理中,使用正则表达式尝试将单词映射到共同的词根(例如,“tired”→“tire”和“trophies”→“trophy”),称为词干提取词形还原,是一种常见的做法。但随着模型的表达能力增强,这种标准化往往弊大于利。单词的时态和复数是传达其意义的重要信号。对于今天使用的较大模型,大多数标准化尽可能轻量级——例如,在进一步处理之前将所有输入转换为标准字符编码。

通过标准化,我们现在已经看到了文本预处理的三个不同阶段(图 14.1):

  1. 标准化 — 我们通过基本的文本到文本转换来规范化输入

  2. 分割 — 我们将文本分割成标记序列

  3. 索引化 — 我们使用词汇表将我们的标记映射到索引

图 14.1:文本预处理流程

人们通常将整个过程称为分词,将映射文本到标记索引序列的对象称为分词器。让我们尝试构建几个。

字符和词分词

首先,让我们构建一个字符级分词器,该分词器将输入字符串中的每个字符映射到一个整数。为了简化问题,我们将只使用一个标准化步骤——我们将所有输入转换为小写。

class CharTokenizer:
    def __init__(self, vocabulary):
        self.vocabulary = vocabulary
        self.unk_id = vocabulary["[UNK]"]

    def standardize(self, inputs):
        return inputs.lower()

    def split(self, inputs):
        return re.findall(r".", inputs)

    def index(self, tokens):
        return [self.vocabulary.get(t, self.unk_id) for t in tokens]

    def __call__(self, inputs):
        inputs = self.standardize(inputs)
        tokens = self.split(inputs)
        indices = self.index(tokens)
        return indices 

列表 14.1:一个基本的字符级分词器

非常简单。在使用这个之前,我们还需要构建一个函数,该函数根据一些输入文本计算标记的词汇表。而不是简单地将所有字符映射到唯一的索引,让我们给自己一个能力,将我们的词汇表大小限制在我们输入数据中最常见的标记。当我们进入建模方面的事情时,限制词汇表大小将是一个限制模型中参数数量的重要方法。

import collections

def compute_char_vocabulary(inputs, max_size):
    char_counts = collections.Counter()
    for x in inputs:
        x = x.lower()
        tokens = re.findall(r".", x)
        char_counts.update(tokens)
    vocabulary = ["[UNK]"]
    most_common = char_counts.most_common(max_size - len(vocabulary))
    for token, count in most_common:
        vocabulary.append(token)
    return dict((token, i) for i, token in enumerate(vocabulary)) 

列表 14.2:计算字符级词汇表

我们现在可以为词级分词器做同样的事情。我们可以使用与我们的字符级分词器相同的代码,但使用不同的分割步骤。

class WordTokenizer:
    def __init__(self, vocabulary):
        self.vocabulary = vocabulary
        self.unk_id = vocabulary["[UNK]"]

    def standardize(self, inputs):
        return inputs.lower()

    def split(self, inputs):
        return re.findall(r"[\w]+|[.,!?;]", inputs)

    def index(self, tokens):
        return [self.vocabulary.get(t, self.unk_id) for t in tokens]

    def __call__(self, inputs):
        inputs = self.standardize(inputs)
        tokens = self.split(inputs)
        indices = self.index(tokens)
        return indices 

列表 14.3:一个基本的词级分词器

我们还可以将这个新的分割规则替换到我们的词汇函数中。

def compute_word_vocabulary(inputs, max_size):
    word_counts = collections.Counter()
    for x in inputs:
        x = x.lower()
        tokens = re.findall(r"[\w]+|[.,!?;]", x)
        word_counts.update(tokens)
    vocabulary = ["[UNK]"]
    most_common = word_counts.most_common(max_size - len(vocabulary))
    for token, count in most_common:
        vocabulary.append(token)
    return dict((token, i) for i, token in enumerate(vocabulary)) 

列表 14.4:计算词级词汇表

让我们在一些真实世界的输入上尝试我们的分词器——赫尔曼·梅尔维尔的全文本《白鲸》。我们将首先为这两个分词器构建一个词汇表,然后使用它来分词一些文本:

import keras

filename = keras.utils.get_file(
    origin="https://www.gutenberg.org/files/2701/old/moby10b.txt",
)
moby_dick = list(open(filename, "r"))

vocabulary = compute_char_vocabulary(moby_dick, max_size=100)
char_tokenizer = CharTokenizer(vocabulary) 

让我们检查一下我们的字符级分词器计算出的结果:

>>> print("Vocabulary length:", len(vocabulary))
Vocabulary length: 64
>>> print("Vocabulary start:", list(vocabulary.keys())[:10])
Vocabulary start: ["[UNK]", " ", "e", "t", "a", "o", "n", "i", "s", "h"]
>>> print("Vocabulary end:", list(vocabulary.keys())[-10:])
Vocabulary end: ["@", "$", "%", "#", "=", "~", "&", "+", "<", ">"]
>>> print("Line length:", len(char_tokenizer(
...    "Call me Ishmael. Some years ago--never mind how long precisely."
... )))
Line length: 63

那么,关于词级分词器呢?

vocabulary = compute_word_vocabulary(moby_dick, max_size=2_000)
word_tokenizer = WordTokenizer(vocabulary) 

我们也可以为我们的词级分词器打印出相同的数据:

>>> print("Vocabulary length:", len(vocabulary))
Vocabulary length: 2000
>>> print("Vocabulary start:", list(vocabulary.keys())[:5])
Vocabulary start: ["[UNK]", ",", "the", ".", "of"]
>>> print("Vocabulary end:", list(vocabulary.keys())[-5:])
Vocabulary end: ["tambourine", "subtle", "perseus", "elevated", "repose"]
>>> print("Line length:", len(word_tokenizer(
...    "Call me Ishmael. Some years ago--never mind how long precisely."
... )))
Line length: 13

我们已经可以看到这两种标记化技术的优势和劣势。字符级标记化器只需要 64 个词汇项就可以覆盖整本书,但会将每个输入编码为非常长的序列。词级标记化器很快就会填满一个 2,000 个术语的词汇表(你需要一个包含 17,000 个术语的字典来索引书中的每个单词!),但词级标记化器的输出要短得多。

随着机器学习从业者使用越来越多的数据和参数扩展模型,词和字符标记化的缺点变得明显。词级标记化提供的“压缩”实际上非常重要——它允许将更长的序列输入到模型中。然而,如果你尝试为大型数据集(今天,你可能看到包含万亿个单词的数据集)构建词级词汇表,你将得到一个无法工作的巨大词汇表,包含数亿个术语。如果你激进地限制你的词级词汇表大小,你将把大量文本编码为"[UNK]"标记,从而丢弃有价值的信息。

这些问题导致了第三种类型的标记化方法流行起来,称为子词标记化,它试图弥合词和字符级方法之间的差距。

子词标记化

子词标记化旨在结合字符级和词级编码技术的优点。我们希望WordTokenizer能够产生简洁的输出,同时希望CharTokenizer能够用一个小型的词汇表编码广泛的输入。

我们可以将寻找理想标记化器的搜索视为寻找理想输入数据压缩的狩猎。减少标记长度压缩了我们的示例的整体长度。一个小型的词汇表减少了表示每个标记所需的字节数。如果我们两者都能实现,我们就能向我们的深度学习模型提供短而信息丰富的序列。

压缩和标记化之间的这种类比并不总是显而易见,但它证明是非常有力的。在过去十年自然语言处理研究中最实用的技巧之一是将用于无损压缩的 1990 年代算法字节对编码^([1])重新用于标记化。它至今仍被 ChatGPT 和其他许多模型使用。在本节中,我们将构建一个使用字节对编码算法的标记化器。

字节对编码的思路是从一个基本的字符词汇表开始,逐步“合并”常见的配对到越来越长的字符序列中。假设我们从一个以下输入文本开始:

data = [
    "the quick brown fox",
    "the slow brown fox",
    "the quick brown foxhound",
] 

WordTokenizer类似,我们首先会计算文本中所有单词的词频。在我们创建词频字典的同时,我们会将所有文本拆分为字符,并用空格连接字符。这将使我们在下一步考虑字符对变得更容易。

def count_and_split_words(data):
    counts = collections.Counter()
    for line in data:
        line = line.lower()
        for word in re.findall(r"[\w]+|[.,!?;]", line):
            chars = re.findall(r".", word)
            split_word = " ".join(chars)
            counts[split_word] += 1
    return dict(counts)

counts = count_and_split_words(data) 

列表 14.5:初始化字节对编码算法的状态

让我们在我们的数据上试一试:

>>> counts
{"t h e": 3,
 "q u i c k": 2,
 "b r o w n": 3,
 "f o x": 2,
 "s l o w": 1,
 "f o x h o u n d": 1}

要将字节对编码应用于我们的单词分割计数,我们将找到两个字符并将它们合并成一个新的符号。我们考虑所有单词中的所有字符对,并且只合并我们找到的最常见的字符对。在先前的例子中,最常见的字符对是 ("o", "w"),在单词 "brown"(在我们的数据中出现了三次)和 "slow"(出现了一次)中。我们将这个字符对组合成一个新的符号 "ow",并将所有 "o w" 的出现合并。

然后,我们继续,计数字符对并合并字符对,但现在 "ow" 将是一个单独的单元,它可以与,比如说,"l" 合并形成 "low"。通过逐步合并最频繁的符号对,我们构建起一个更大和更大的子词词汇表。

让我们在我们的玩具数据集上试一试:

def count_pairs(counts):
    pairs = collections.Counter()
    for word, freq in counts.items():
        symbols = word.split()
        for pair in zip(symbols[:-1], symbols[1:]):
            pairs[pair] += freq
    return pairs

def merge_pair(counts, first, second):
    # Matches an unmerged pair
    split = re.compile(f"(?<!\S){first} {second}(?!\S)")
    # Replaces all occurances with a merged version
    merged = f"{first}{second}"
    return {split.sub(merged, word): count for word, count in counts.items()}

for i in range(10):
    pairs = count_pairs(counts)
    first, second = max(pairs, key=pairs.get)
    counts = merge_pair(counts, first, second)
    print(list(counts.keys())) 

列表 14.6:运行字节对合并的几个步骤

这是我们的结果:

["t h e", "q u i c k", "b r ow n", "f o x", "s l ow", "f o x h o u n d"]
["th e", "q u i c k", "b r ow n", "f o x", "s l ow", "f o x h o u n d"]
["the", "q u i c k", "b r ow n", "f o x", "s l ow", "f o x h o u n d"]
["the", "q u i c k", "br ow n", "f o x", "s l ow", "f o x h o u n d"]
["the", "q u i c k", "brow n", "f o x", "s l ow", "f o x h o u n d"]
["the", "q u i c k", "brown", "f o x", "s l ow", "f o x h o u n d"]
["the", "q u i c k", "brown", "fo x", "s l ow", "fo x h o u n d"]
["the", "q u i c k", "brown", "fox", "s l ow", "fox h o u n d"]
["the", "qu i c k", "brown", "fox", "s l ow", "fox h o u n d"]
["the", "qui c k", "brown", "fox", "s l ow", "fox h o u n d"] 

我们可以看到常见单词被完全合并,而较少见的单词则只部分合并。

现在,我们可以将此扩展为计算字节对编码词汇表的完整函数。我们以输入文本中找到的所有字符开始我们的词汇表,并将逐步添加合并符号(更大和更大的子词)到我们的词汇表中,直到它达到我们期望的长度。我们还将保留一个包含我们应用顺序的合并规则单独字典。接下来,我们将看到如何使用这些合并规则来分词新的输入文本。

def compute_sub_word_vocabulary(dataset, vocab_size):
    counts = count_and_split_words(dataset)

    char_counts = collections.Counter()
    for word in counts:
        for char in word.split():
            char_counts[char] += counts[word]
    most_common = char_counts.most_common()
    vocab = ["[UNK]"] + [char for char, freq in most_common]
    merges = []

    while len(vocab) < vocab_size:
        pairs = count_pairs(counts)
        if not pairs:
            break
        first, second = max(pairs, key=pairs.get)
        counts = merge_pair(counts, first, second)
        vocab.append(f"{first}{second}")
        merges.append(f"{first} {second}")

    vocab = dict((token, index) for index, token in enumerate(vocab))
    merges = dict((token, rank) for rank, token in enumerate(merges))
    return vocab, merges 

列表 14.7:计算字节对编码词汇表

让我们构建一个 SubWordTokenizer,它将我们的合并规则应用于对新输入文本进行分词。standardize()index() 步骤可以保持与 WordTokenizer 相同,所有更改都发生在 split() 方法中。

在我们的分割步骤中,我们首先将所有输入分割成单词,然后将所有单词分割成字符,最后将我们学到的合并规则应用于分割后的字符。剩下的就是子词——根据输入单词在我们训练数据中的频率,这些子词可能是完整的单词、部分单词或简单的字符。这些子词是我们输出中的标记。

class SubWordTokenizer:
    def __init__(self, vocabulary, merges):
        self.vocabulary = vocabulary
        self.merges = merges
        self.unk_id = vocabulary["[UNK]"]

    def standardize(self, inputs):
        return inputs.lower()

    def bpe_merge(self, word):
        while True:
            # Matches all symbol pairs in the text
            pairs = re.findall(r"(?<!\S)\S+ \S+(?!\S)", word, overlapped=True)
            if not pairs:
                break
            # We apply merge rules in "rank" order. More frequent pairs
            # are merged first.
            best = min(pairs, key=lambda pair: self.merges.get(pair, 1e9))
            if best not in self.merges:
                break
            first, second = best.split()
            split = re.compile(f"(?<!\S){first} {second}(?!\S)")
            merged = f"{first}{second}"
            word = split.sub(merged, word)
        return word

    def split(self, inputs):
        tokens = []
        # Split words
        for word in re.findall(r"[\w]+|[.,!?;]", inputs):
            # Joins all characters with a space
            word = " ".join(re.findall(r".", word))
            # Applies byte-pair encoding merge rules
            word = self.bpe_merge(word)
            tokens.extend(word.split())
        return tokens

    def index(self, tokens):
        return [self.vocabulary.get(t, self.unk_id) for t in tokens]

    def __call__(self, inputs):
        inputs = self.standardize(inputs)
        tokens = self.split(inputs)
        indices = self.index(tokens)
        return indices 

列表 14.8:字节对编码分词器

让我们在 Moby Dick 的全文上试一试我们的分词器:

vocabulary, merges = compute_sub_word_vocabulary(moby_dick, 2_000)
sub_word_tokenizer = SubWordTokenizer(vocabulary, merges) 

我们可以查看我们的词汇表,并在我们的分词器上尝试一个测试句子,就像我们在 WordTokenizerCharTokenizer 上做的那样:

>>> print("Vocabulary length:", len(vocabulary))
Vocabulary length: 2000
>>> print("Vocabulary start:", list(vocabulary.keys())[:10])
Vocabulary start: ["[UNK]", "e", "t", "a", "o", "n", "i", "s", "h", "r"]
>>> print("Vocabulary end:", list(vocabulary.keys())[-7:])
Vocabulary end: ["bright", "pilot", "sco", "ben", "dem", "gale", "ilo"]
>>> print("Line length:", len(sub_word_tokenizer(
...    "Call me Ishmael. Some years ago--never mind how long precisely."
... )))
Line length: 16

SubWordTokenizer 对于我们的测试句子比 WordTokenizer 的长度略长(16 个标记与 13 个标记),但与 WordTokenizer 不同,它可以在不使用 "[UNK]" 标记的情况下对 Moby Dick 中的每个单词进行分词。词汇表包含我们源文本中的每个字符,所以最坏的情况是将单词分词成单个字符。我们在处理罕见单词的同时,实现了较短的 平均 标记长度。这是子词分词器的优势。

你可能会注意到运行这段代码比单词和字符分词器慢得多;在我们的参考硬件上大约需要一分钟。学习合并规则比简单地统计输入数据集中的单词要复杂得多。虽然这是子词分词的一个缺点,但在实践中很少是一个重要的问题。你只需要为每个模型学习一次词汇表,与模型训练相比,学习子词词汇表的成本通常是可以忽略不计的。

我们已经看到了三种不同的输入分词方法。现在我们能够将文本转换为数值输入,我们可以继续进行模型训练。

关于分词的最后一点说明——虽然理解分词器的工作原理非常重要,但很少需要你自己去构建一个分词器。Keras 自带了用于分词文本输入的实用工具,大多数深度学习框架也是如此。在接下来的章节中,我们将利用 Keras 内置的分词功能。

集合与序列

机器学习模型应该如何表示单个标记是一个相对无争议的问题:它们是分类特征(来自预定义集合的值),我们知道如何处理这些特征。它们应该被编码为特征空间中的维度或作为类别向量(在这种情况下是标记向量)。然而,一个更成问题的问题是,如何编码文本中标记的顺序。

自然语言中的顺序问题很有趣:与时间序列的步骤不同,句子中的单词没有自然、规范化的顺序。不同的语言以非常不同的方式排列相似的单词。例如,英语的句子结构与日语的句子结构大不相同。即使在给定的语言中,你通常也可以通过稍微重新排列单词来用不同的方式表达相同的事情。如果你要完全随机化一个短句中的单词,有时你仍然可以弄清楚它在说什么——尽管在许多情况下,会出现显著的歧义。顺序显然很重要,但它的意义关系并不直接。

如何表示词序是不同类型的 NLP 架构产生的关键问题。你可以做的最简单的事情就是忽略顺序,将文本视为一个无序的单词集合——这给你的是词袋模型。你也可以决定单词应该严格按照它们出现的顺序进行处理,一次一个,就像时间序列中的步骤一样——然后你可以使用上一章中的循环模型。最后,还可以采用混合方法:Transformer 架构在技术上是无序的,但它将单词位置信息注入到它处理的表示中,这使得它能够同时查看句子的不同部分(与 RNN 不同),同时仍然保持对顺序的感知。由于它们考虑了词序,RNN 和 Transformer 都被称为序列模型

历史上,大多数早期将机器学习应用于 NLP 的应用仅涉及丢弃序列数据的词袋模型。对序列模型感兴趣的情况直到 2015 年才开始上升,随着 RNN 的复兴。今天,这两种方法仍然相关。让我们看看它们是如何工作的,以及在何时使用哪种方法。

我们将在一个著名的文本分类基准数据集上展示每种方法:IMDb 电影评论情感分类数据集。在第四章和第五章中,你使用的是 IMDb 数据集的预向量化版本;现在让我们处理原始的 IMDb 文本数据,就像你在现实生活中处理一个新的文本分类问题一样。

加载 IMDb 分类数据集

首先,让我们下载并提取我们的数据集。

import os, pathlib, shutil, random

zip_path = keras.utils.get_file(
    origin="https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz",
    fname="imdb",
    extract=True,
)

imdb_extract_dir = pathlib.Path(zip_path) / "aclImdb" 

列表 14.9:下载 IMDb 电影评论数据集

让我们列出我们的目录结构:

>>> for path in imdb_extract_dir.glob("*/*"):
...     if path.is_dir():
...         print(path)
~/.keras/datasets/aclImdb/train/pos
~/.keras/datasets/aclImdb/train/unsup
~/.keras/datasets/aclImdb/train/neg
~/.keras/datasets/aclImdb/test/pos
~/.keras/datasets/aclImdb/test/neg

我们可以看到包含正例和负例的训练集和测试集。在 IMDb 网站上用户评分低的影评被归类到neg/目录中,而评分高的影评被归类到pos/目录中。我们还可以看到一个unsup/目录,这是无监督的缩写。这些是数据集创建者故意留下未标记的评论;它们可能是负评或正评。

让我们查看一些这些文本文件的内容。无论你是在处理文本数据还是图像数据,记得在深入建模之前检查你的数据看起来是什么样子。这将帮助你理解你的模型实际上在做什么。

>>> print(open(imdb_extract_dir / "train" / "pos" / "4077_10.txt", "r").read())
I first saw this back in the early 90s on UK TV, i did like it then but i missed
the chance to tape it, many years passed but the film always stuck with me and i
lost hope of seeing it TV again, the main thing that stuck with me was the end,
the hole castle part really touched me, its easy to watch, has a great story,
great music, the list goes on and on, its OK me saying how good it is but
everyone will take there own best bits away with them once they have seen it,
yes the animation is top notch and beautiful to watch, it does show its age in a
very few parts but that has now become part of it beauty, i am so glad it has
came out on DVD as it is one of my top 10 films of all time. Buy it or rent it
just see it, best viewing is at night alone with drink and food in reach so you
don't have to stop the film.<br /><br />Enjoy

列表 14.10:预览单个 IMDb 评论

在我们开始标记输入文本之前,我们将对训练数据进行一些重要修改的副本。现在我们可以忽略无监督评论,并创建一个单独的验证集来监控训练过程中的准确率。我们通过将 20%的训练文本文件分割到一个新目录中来实现这一点。

train_dir = pathlib.Path("imdb_train")
test_dir = pathlib.Path("imdb_test")
val_dir = pathlib.Path("imdb_val")

# Moves the test data unaltered
shutil.copytree(imdb_extract_dir / "test", test_dir)

# Splits the training data into a train set and a validation set
val_percentage = 0.2
for category in ("neg", "pos"):
    src_dir = imdb_extract_dir / "train" / category
    src_files = os.listdir(src_dir)
    random.Random(1337).shuffle(src_files)
    num_val_samples = int(len(src_files) * val_percentage)

    os.makedirs(val_dir / category)
    for file in src_files[:num_val_samples]:
        shutil.copy(src_dir / file, val_dir / category / file)
    os.makedirs(train_dir / category)
    for file in src_files[num_val_samples:]:
        shutil.copy(src_dir / file, train_dir / category / file) 

列表 14.11:从 IMDb 数据集中分割验证集

现在,我们已经准备好加载数据了。记得在第八章中,我们是如何使用image_dataset_from_directory实用工具来创建一个包含图像及其标签的Dataset对象,用于目录结构?你可以使用text_dataset_from_directory实用工具做完全相同的事情来处理文本文件。让我们创建三个Dataset对象,分别用于训练、验证和测试。

from keras.utils import text_dataset_from_directory

batch_size = 32
train_ds = text_dataset_from_directory(train_dir, batch_size=batch_size)
val_ds = text_dataset_from_directory(val_dir, batch_size=batch_size)
test_ds = text_dataset_from_directory(test_dir, batch_size=batch_size) 

列表 14.12:使用 Keras 加载 IMDb 数据集

最初我们各有 25,000 个训练和测试示例,经过验证分割后,我们有 20,000 条评论用于训练,5,000 条用于验证。让我们尝试从这些数据中学习一些东西。

设置模型

关于文本中标记顺序的最简单方法就是忽略它。我们仍然将输入评论正常标记为一系列标记 ID,但在标记化之后,立即将整个训练示例转换为集合——一个简单的无序“包”,其中包含或不存在于影评中的标记。

这里的想法是使用这些集合构建一个非常简单的模型,为每个评论中的每个单词分配一个权重。单词"terrible"的存在可能(尽管不总是)表示一个差评,而"riveting"可能表示一个好评。我们可以构建一个小型模型来学习这些权重——称为词袋模型。

例如,假设你有一个简单的输入句子和词汇表:

"this movie made me cry"

{"[UNK]": 0, "movie": 1, "film": 2, "made": 3, "laugh": 4, "cry": 5} 

我们将这个微小的回顾文本标记化如下

[0, 1, 3, 0, 5] 

忽略顺序,我们可以将其转换为一系列标记 ID:

{0, 1, 3, 5} 

最后,我们可以使用多热编码将集合转换为一个与词汇表长度相同的固定大小的向量:

[1, 1, 0, 1, 0, 1] 

这里第五位上的 0 表示"laugh"这个词在我们的回顾中不存在,而第六位上的 1 表示"cry"这个词存在。这种简单的编码可以直接用于训练模型。

训练词袋模型

要在代码中执行此文本处理,可以很容易地扩展本章早些时候的WordTokenizer。一个更简单的解决方案是使用 Keras 内置的TextVectorization层。TextVectorization处理单词和字符标记化,并附带一些附加功能,包括层输出的多热编码。

TextVectorization层,像 Keras 中的许多预处理层一样,有一个adapt()方法,可以从输入数据中学习层状态。在TextVectorization的情况下,adapt()将通过遍历输入数据集动态地学习数据集的词汇表。让我们使用它来标记化和编码我们的输入数据。我们将构建一个包含 20,000 个单词的词汇表,这对于文本分类问题是一个很好的起点。

from keras import layers

max_tokens = 20_000
text_vectorization = layers.TextVectorization(
    max_tokens=max_tokens,
    # Learns a word-level vocabulary
    split="whitespace",
    output_mode="multi_hot",
)
train_ds_no_labels = train_ds.map(lambda x, y: x)
text_vectorization.adapt(train_ds_no_labels)

bag_of_words_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
bag_of_words_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
bag_of_words_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
) 

列表 14.13:将词袋编码应用于 IMDb 评论

让我们看看我们预处理后的单个输入数据批次:

>>> x, y = next(bag_of_words_train_ds.as_numpy_iterator())
>>> x.shape
(32, 20000)
>>> y.shape
(32, 1)

你可以看到,在预处理之后,我们批次中的每个样本都被转换成了一个包含 20,000 个数字的向量,每个数字跟踪词汇表中每个术语的存在或不存在。

接下来,我们可以训练一个非常简单的线性模型。我们将把我们的模型构建代码保存为一个函数,以便以后再次使用。

def build_linear_classifier(max_tokens, name):
    inputs = keras.Input(shape=(max_tokens,))
    outputs = layers.Dense(1, activation="sigmoid")(inputs)
    model = keras.Model(inputs, outputs, name=name)
    model.compile(
        optimizer="adam",
        loss="binary_crossentropy",
        metrics=["accuracy"],
    )
    return model

model = build_linear_classifier(max_tokens, "bag_of_words_classifier") 

列表 14.14:构建词袋回归模型

让我们来看看我们模型的摘要:

>>> model.summary()
Model: "bag_of_words_classifier"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer (InputLayer)          │ (None, 20000)            │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense (Dense)                     │ (None, 1)                │        20,001 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 20,001 (78.13 KB)
 Trainable params: 20,001 (78.13 KB)
 Non-trainable params: 0 (0.00 B)

这个模型非常简单。我们只有 20,001 个参数,一个对应于词汇表中的每个词,一个对应于偏置项。让我们来训练它。我们将添加在第七章中首次介绍的EarlyStopping回调,当验证损失停止改进时,它将自动停止训练,并从最佳时期恢复权重。

early_stopping = keras.callbacks.EarlyStopping(
    monitor="val_loss",
    restore_best_weights=True,
    patience=2,
)
history = model.fit(
    bag_of_words_train_ds,
    validation_data=bag_of_words_val_ds,
    epochs=10,
    callbacks=[early_stopping],
) 

列表 14.15:训练词袋回归模型

我们的模型训练时间不到一分钟,考虑到其规模,这并不令人惊讶。实际上,我们的输入的标记化和编码比更新模型参数要昂贵得多。让我们绘制模型准确率(图 14.2):

import matplotlib.pyplot as plt

accuracy = history.history["accuracy"]
val_accuracy = history.history["val_accuracy"]
epochs = range(1, len(accuracy) + 1)

plt.plot(epochs, accuracy, "r--", label="Training accuracy")
plt.plot(epochs, val_accuracy, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.legend()
plt.show() 

图片

图 14.2:我们的词袋模型的训练和验证指标

我们可以看到,验证性能趋于平稳而不是显著下降;我们的模型如此简单,实际上无法过度拟合。让我们尝试在测试集上评估它。

>>> test_loss, test_acc = model.evaluate(bag_of_words_test_ds)
>>> test_acc
0.88388

列表 14.16:评估词袋回归模型

我们可以在训练作业足够轻的情况下正确预测 88% 的评论情感,这样它就可以在单个 CPU 上高效运行。

值得注意的是,我们在这个例子中选择的单词分词方式。避免字符级分词的原因很明显——一个包含电影评论中所有字符的“袋”将告诉你很少关于其内容的信息。使用足够大的词汇量的子词分词将是一个不错的选择,但在这里几乎没有必要。由于我们正在训练的模型非常小,使用一个快速训练且权重对应实际英语单词的词汇表是很方便的。

训练二元组模型

当然,我们可以直观地猜测,丢弃所有单词顺序是非常简化的,因为即使是原子概念也可以通过多个单词来表达:术语“美国”传达的概念与单独的“州”和“联合”这两个词的意义截然不同。一部“不错”的电影和一部“糟糕”的电影可能应该得到不同的情感分数。

因此,通常在模型中注入一些关于局部单词顺序的知识是一个好主意,即使对于我们目前正在构建的这些简单的集合模型也是如此。一个简单的方法是考虑二元组——一个术语,指的是在输入文本中连续出现的两个标记。在我们的例子“这部电影让我哭了”中,{"this", "movie", "made", "me", "cry"}是输入中所有单词单语素的集合,而{"this movie", "movie made", "made me", "me cry"}是所有二元组的集合。我们刚刚训练的词袋模型可以等价地称为单语素模型,而n-gram术语指的是任何n的有序标记序列。

要将二元组添加到我们的模型中,我们希望在构建词汇表时考虑所有二元组的频率。我们可以通过两种方式做到这一点:创建仅包含二元组的词汇表,或者允许二元组和单语素在同一个词汇表中竞争空间。在后一种情况下,如果“美国”在输入文本中出现的频率高于"ventriloquism",则“美国”将包含在我们的词汇表中。

同样,我们可以通过扩展本章早些时候的WordTokenizer来构建这个模型,但这是不必要的。TextVectorization提供了这个功能。我们将训练一个稍微大一点的词汇表来考虑二元组的存在,adapt()一个新的词汇表,并对包括二元组的多热编码输出向量。

max_tokens = 30_000
text_vectorization = layers.TextVectorization(
    max_tokens=max_tokens,
    # Learns a word-level vocabulary
    split="whitespace",
    output_mode="multi_hot",
    # Considers all unigrams and bigrams
    ngrams=2,
)
text_vectorization.adapt(train_ds_no_labels)

bigram_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
bigram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
bigram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
) 

列表 14.17:将二元编码应用于 IMDb 评论

让我们再次检查我们预处理的输入批次:

>>> x, y = next(bigram_train_ds.as_numpy_iterator())
>>> x.shape
(32, 30000)

如果我们查看词汇表的一个小部分,我们可以看到单语和二元术语:

>>> text_vectorization.get_vocabulary()[100:108]
["in a", "most", "him", "dont", "it was", "one of", "for the", "made"]

使用我们为输入数据的新编码,我们可以训练一个与之前相同的线性模型。

model = build_linear_classifier(max_tokens, "bigram_classifier")
model.fit(
    bigram_train_ds,
    validation_data=bigram_val_ds,
    epochs=10,
    callbacks=[early_stopping],
) 

列表 14.18:训练二元回归模型

这个模型将比我们的词袋模型稍大一些(30,001 个参数而不是 20,001 个参数),但它仍然需要大约相同的时间来训练。它做得怎么样?

>>> test_loss, test_acc = model.evaluate(bigram_test_ds)
>>> test_acc
0.90116

列表 14.19:评估二元回归模型

我们现在获得了 90% 的测试准确率,这是一个明显的提升!

通过考虑三元组(单词的三重组合),我们可以进一步提高这个数字,尽管超过三元组,问题会迅速变得难以解决。英语语言中可能的 4-gram 单词空间是巨大的,随着序列变得越来越长,问题呈指数增长。你需要一个庞大的词汇量来提供对 4-gram 的良好覆盖,而你的模型将失去其泛化能力,仅仅通过附加权重来记忆整个句子的片段。为了稳健地考虑更长的有序文本序列,我们需要更高级的建模技术。

序列模型

我们最后两个模型表明,序列信息很重要。我们通过添加一些关于局部单词顺序的信息来改进了一个基本的线性模型。

然而,这是通过手动设计输入特征来完成的,我们可以看到这种方法只能扩展到只有几个单词的局部顺序。在深度学习中,通常不是试图自己构建这些特征,而应该让模型直接接触到原始单词序列,并让它直接学习标记之间的位置依赖关系。

消费完整标记序列的模型简单地被称为 序列模型。在这里我们有几种架构选择。我们可以构建一个 RNN 模型,就像我们刚才为时间序列建模所做的那样。我们可以构建一个 1D ConvNet,类似于我们的图像处理模型,但只是在单个序列维度上卷积滤波器。而且正如我们将在下一章中深入探讨的,我们可以构建一个 Transformer。

在采取任何这些方法之前,我们必须将我们的输入预处理为有序序列。我们希望有一个整数序列的标记 ID,就像我们在本章的标记化部分所看到的那样,但有一个额外的复杂性要处理。当我们对一个输入批次运行计算时,我们希望所有输入都是矩形的,这样所有计算都可以在 GPU 上有效地并行化。然而,标记化输入几乎总是具有不同的长度。IMDb 电影评论从只有几句话到多个段落不等,单词数量也各不相同。

为了适应这一事实,我们可以截断我们的输入序列或“填充”它们,使用另一个特殊标记 "[PAD]",类似于我们之前使用的 "[UNK]" 标记。例如,给定两个输入句子和一个期望的长度为八

"the quick brown fox jumped over the lazy dog"

"the slow brown badger" 

我们将对以下标记进行标记化到整数 ID:

["the", "quick", "brown", "fox", "jumped", "over", "the", "lazy"]
["the", "slow", "brown", "badger", "[PAD]", "[PAD]", "[PAD]", "[PAD]"] 

这将使我们的批次计算速度大大加快,尽管我们需要小心处理填充标记,以确保它们不会影响我们模型预测的质量。

为了保持可管理的输入大小,我们可以在第一个 600 个单词之后截断我们的 IMDb 评论。这是一个合理的选择,因为平均评论长度为 233 个单词,只有 5% 的评论长度超过 600 个单词。再次,我们可以使用 TextVecotorization 层,该层具有填充或截断输入的选项,并在学习词汇表的索引 0 处包含一个 "[PAD]"

max_length = 600
max_tokens = 30_000
text_vectorization = layers.TextVectorization(
    max_tokens=max_tokens,
    # Learns a word-level vocabulary
    split="whitespace",
    # Outputs a integer sequence of token IDs
    output_mode="int",
    # Pads and truncates to 600 tokens
    output_sequence_length=max_length,
)
text_vectorization.adapt(train_ds_no_labels)

sequence_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
sequence_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
)
sequence_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y), num_parallel_calls=8
) 

列表 14.20:将 IMDb 评论填充到固定序列长度

让我们看看单个输入批次:

>>> x, y = next(sequence_test_ds.as_numpy_iterator())
>>> x.shape
(32, 600)
>>> x
array([[   11,    29,     7, ...,     0,     0,     0],
       [  132,   115,    35, ...,     0,     0,     0],
       [ 1825,     3, 25819, ...,     0,     0,     0],
       ...,
       [    4,   576,    56, ...,     0,     0,     0],
       [   30,   203,     4, ...,     0,     0,     0],
       [ 5104,     1,    14, ...,     0,     0,     0]])

每个批次在预处理后具有形状 (batch_size, sequence_length),并且几乎所有训练样本在末尾都有一定数量的 0 用于填充。

训练循环模型

让我们尝试训练一个 LSTM。正如我们在上一章中看到的,LSTM 可以有效地处理序列数据。在我们能够应用它之前,我们仍然需要将我们的标记 ID 整数 映射到 Dense 层可以接受的浮点数据。

最直接的方法是将我们的输入 ID one-hot 编码,类似于我们对整个序列所做的多热编码。每个标记将变成一个长向量,其中所有元素都是 0,只有一个 1 在词汇表中的标记索引处。让我们构建一个层来 one-hot 编码我们的输入序列。

from keras import ops

class OneHotEncoding(keras.Layer):
    def __init__(self, depth, **kwargs):
        super().__init__(**kwargs)
        self.depth = depth

    def call(self, inputs):
        # Flattens the inputs
        flat_inputs = ops.reshape(ops.cast(inputs, "int"), [-1])
        # Builds an identity matrix with all possible one-hot vectors
        one_hot_vectors = ops.eye(self.depth)
        # Uses our input token IDs to gather the correct vector for
        # each token
        outputs = ops.take(one_hot_vectors, flat_inputs, axis=0)
        # Unflattens the output
        return ops.reshape(outputs, ops.shape(inputs) + (self.depth,))

one_hot_encoding = OneHotEncoding(max_tokens) 

列表 14.21:使用 Keras 操作构建 one-hot 编码层

让我们在单个输入批次上尝试这个层:

>>> x, y = next(sequence_train_ds.as_numpy_iterator())
>>> one_hot_encoding(x).shape
(32, 600, 30000)

我们可以直接将这个层构建到模型中,并使用双向 LSTM 来允许信息在标记序列中向前和向后传播。稍后,当我们查看生成时,我们将看到需要单向序列模型(其中标记状态只依赖于它之前的标记状态)的需求。对于分类任务,双向 LSTM 是一个很好的选择。

让我们构建我们的模型。

hidden_dim = 64
inputs = keras.Input(shape=(max_length,), dtype="int32")
x = one_hot_encoding(inputs)
x = layers.Bidirectional(layers.LSTM(hidden_dim))(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs, name="lstm_with_one_hot")
model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
) 

列表 14.22:构建 LSTM 序列模型

我们可以查看我们的模型摘要,以了解我们的参数数量:

>>> model.summary()
Model: "lstm_with_one_hot"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_2 (InputLayer)        │ (None, 600)              │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ one_hot_encoding (OneHotEncoding) │ (None, 600, 30000)       │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ bidirectional (Bidirectional)     │ (None, 128)              │    15,393,280 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dropout (Dropout)                 │ (None, 128)              │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_2 (Dense)                   │ (None, 1)                │           129 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 15,393,409 (58.72 MB)
 Trainable params: 15,393,409 (58.72 MB)
 Non-trainable params: 0 (0.00 B)

这在规模上比单语模型和二元模型有了相当大的提升。大约有 1500 万个参数,这是我们迄今为止在书中训练的较大模型之一,只有一个 LSTM 层。让我们尝试训练这个模型。

model.fit(
    sequence_train_ds,
    validation_data=sequence_val_ds,
    epochs=10,
    callbacks=[early_stopping],
) 

列表 14.23:训练 LSTM 序列模型

它的表现如何?

>>> test_loss, test_acc = model.evaluate(sequence_test_ds)
>>> test_acc
0.84811

列表 14.24:评估 LSTM 序列模型

这个模型是可行的,但它训练得非常慢,尤其是与上一节中的轻量级模型相比。这是因为我们的输入相当大:每个输入样本被编码为一个大小为(600, 30000)的矩阵(每个样本 600 个单词,30000 个可能的单词)。这是一篇电影评论的 1800 万个浮点数!我们的双向 LSTM 有很多工作要做。除了速度慢之外,该模型在测试集上的准确率只有 84%——它远不如我们非常快速的基于集合的模型。

显然,使用独热编码将单词转换为向量,这是我们能够做的最简单的事情,并不是一个好主意。有更好的方法——词嵌入。

理解词嵌入

当你通过独热编码对某个事物进行编码时,你正在做出一个特征工程决策。你正在向你的模型注入一个关于你的特征空间结构的根本性假设。这个假设是,你正在编码的不同标记之间都是相互独立的:确实,独热向量都是相互正交的。在单词的情况下,这个假设显然是错误的。单词构成一个结构化的空间:它们相互之间共享信息。在大多数句子中,“电影”和“电影”是可以互换的,所以代表“电影”的向量不应该与代表“电影”的向量正交——它们应该是同一个向量,或者足够接近。

要更抽象一点,两个词向量之间的几何关系应该反映这些词之间的语义关系。例如,在一个合理的词向量空间中,你预计同义词将被嵌入到相似的词向量中,通常,你预计任何两个词向量之间的几何距离(如余弦距离或 L2 距离)将与相关单词之间的“语义距离”相关。意义不同的单词应该彼此远离,而相关的单词应该更接近。

词嵌入是单词的向量表示,它们精确地实现了这一点:它们将人类语言映射到结构化的几何空间中。

与通过独热编码获得的向量是二进制、稀疏(主要由零组成)且非常高维(与词汇表中的单词数量相同维度的维度)不同,词嵌入是低维浮点向量(即密集向量,与稀疏向量相对);参见图 14.3。在处理非常大的词汇表时,常见的词嵌入维度是 256 维、512 维或 1024 维。另一方面,在我们的当前词汇表中,独热编码单词通常会导致 30,000 维的向量。因此,词嵌入将更多信息压缩到更少的维度中。

图片

图 14.3:从 one-hot 编码或哈希得到的词表示是稀疏的、高维的且是硬编码的。词嵌入是密集的、相对低维的,并且是从数据中学习的。

除了是密集表示之外,词嵌入也是结构化表示,它们的结构是从数据中学习的。相似词语被嵌入在接近的位置,并且更进一步,嵌入空间中的特定方向是有意义的。为了使这一点更清晰,让我们看一个具体的例子。在图 14.4 中,四个词语被嵌入在一个 2D 平面上:猫、狗、狼和老虎。使用我们这里选择的向量表示,这些词语之间的一些语义关系可以编码为几何变换。例如,相同的向量使我们能够从猫到老虎,从狗到狼:这个向量可以解释为“从宠物到野生动物”的向量。同样,另一个向量使我们能够从狗到猫,从狼到老虎,这可以解释为“从犬科到猫科”的向量。

图片

图 14.4:词嵌入空间的玩具示例

在现实世界的词嵌入空间中,典型的有意义的几何变换示例是“性别”向量和“复数”向量。例如,通过向“国王”向量添加一个“女性”向量,我们得到“王后”向量。通过添加一个“复数”向量,我们得到“国王们”。词嵌入空间通常具有数千个这样的可解释且可能有用的向量。

让我们看看如何在实践中使用这样的嵌入空间。

使用词嵌入

是否存在一个理想的词嵌入空间,可以完美映射人类语言,并且可以用于任何自然语言处理任务?可能存在,但我们还没有计算出这样的东西。此外,没有单一的人类语言我们可以尝试去映射——存在许多不同的语言,它们之间不是同构的,因为一种语言是特定文化和特定背景的反映。更实际地说,一个好的词嵌入空间取决于你的任务:对于英语电影评论情感分析模型来说,完美的词嵌入空间可能与英语法律文档分类模型的理想嵌入空间不同,因为某些语义关系的重要性因任务而异。

因此,在每次新任务中学习一个新的嵌入空间是合理的。幸运的是,反向传播使得这一点变得容易,而 Keras 则使得这一点更加容易。这涉及到学习 Keras Embedding层的权重。

Embedding层最好理解为将整数索引(代表特定单词)映射到密集向量的字典。它接受整数作为输入,在内部字典中查找它们,并返回相关的向量。它实际上是一个字典查找(见图 14.5)。

图片

图 14.5:Embedding 层充当一个将整数映射到浮点向量的字典。

Embedding 层接受一个形状为 (batch_size, sequence_length) 的二维张量作为输入,其中每个条目是一个整数的序列。该层返回一个形状为 (batch_size, sequence_length, embedding_size) 的浮点张量。

当你实例化一个 Embedding 层时,其权重(其内部标记向量的字典)最初是随机的,就像任何其他层一样。在训练过程中,这些词向量通过反向传播逐渐调整,将空间结构化,以便下游模型可以利用。一旦完全训练,嵌入空间将显示出很多结构——一种专门针对你训练模型的具体问题的结构。

让我们构建一个带有 Embedding 层的模型,并在我们的任务上对其进行基准测试。

hidden_dim = 64
inputs = keras.Input(shape=(max_length,), dtype="int32")
x = keras.layers.Embedding(
    input_dim=max_tokens,
    output_dim=hidden_dim,
    mask_zero=True,
)(inputs)
x = keras.layers.Bidirectional(keras.layers.LSTM(hidden_dim))(x)
x = keras.layers.Dropout(0.5)(x)
outputs = keras.layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs, name="lstm_with_embedding")
model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
) 

列表 14.25:使用 Embedding 层构建 LSTM 序列模型

Embedding 层的前两个参数相当直接。input_dim 设置层中整数输入的可能值的总范围——即我们的字典查找中有多少可能的键。output_dim 设置我们查找的输出向量的维度——即我们单词的结构化向量空间的维度。

第三个参数 mask_zero=True 稍微有点微妙。此参数告诉 Keras 我们序列中的哪些输入是 "[PAD]" 标记,这样我们就可以在模型中稍后对这些条目进行掩码。

记住,在预处理我们的序列输入时,我们可能会向原始输入添加很多填充标记,以便一个标记序列可能看起来像:

["the", "movie", "was", "awful", "[PAD]", "[PAD]", "[PAD]", "[PAD]"] 

所有这些填充标记都将首先嵌入,然后输入到 LSTM 层。这意味着我们从 LSTM 单元接收的最后表示可能包含反复处理 "[PAD]" 标记表示的结果。我们对上一个序列中最后一个 "[PAD]" 标记的 LSTM 学习到的表示并不感兴趣。相反,我们感兴趣的是 "awful" 的表示,即最后一个非填充标记。或者,等价地,我们想要掩码所有的 "[PAD]" 标记,这样它们就不会影响我们的最终输出预测。

mask_zero=True 只是一个简写,用于在 Keras 中通过 Embedding 层轻松地进行此类掩码。Keras 将标记我们序列中最初包含零值的所有元素,其中零被认为是 "[PAD]" 标记的标记 ID。这个掩码将由 LSTM 层内部使用。它不会输出整个序列的最后学习到的表示,而是输出最后非掩码的表示。

这种掩码形式是隐式的且易于使用,但如果需要,你总是可以明确指出序列中你想掩码哪些项目。LSTM 层接受一个可选的 mask 调用参数,用于显式或自定义掩码。

在我们训练这个新模型之前,让我们先看看模型摘要:

>>> model.summary()
Model: "lstm_with_embedding"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)          ┃ Output Shape      ┃     Param # ┃ Connected to       ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ input_layer_3         │ (None, 600)       │           0 │ -                  │
│ (InputLayer)          │                   │             │                    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embedding (Embedding) │ (None, 600, 64)   │   1,920,000 │ input_layer_6[0][… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ not_equal (NotEqual)  │ (None, 600)       │           0 │ input_layer_6[0][… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ bidirectional_1       │ (None, 128)       │      66,048 │ embedding[0][0],   │
│ (Bidirectional)       │                   │             │ not_equal[0][0]    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dropout_1 (Dropout)   │ (None, 128)       │           0 │ bidirectional_2[0… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense_3 (Dense)       │ (None, 1)         │         129 │ dropout_2[0][0]    │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
 Total params: 1,986,177 (7.58 MB)
 Trainable params: 1,986,177 (7.58 MB)
 Non-trainable params: 0 (0.00 B)

我们将我们的 one-hot 编码的 LSTM 模型参数数量从 1500 万减少到 200 万。让我们训练和评估这个模型。

>>> model.fit(
...     sequence_train_ds,
...     validation_data=sequence_val_ds,
...     epochs=10,
...     callbacks=[early_stopping],
... )
>>> test_loss, test_acc = model.evaluate(sequence_test_ds)
>>> test_acc
0.8443599939346313

列表 14.26:使用Embedding层训练和评估 LSTM

通过嵌入,我们减少了我们的训练时间和模型大小一个数量级。学习到的嵌入显然比输入的一热编码更有效率。

然而,LSTM 的整体性能并没有改变。准确率固执地徘徊在 84%左右,仍然与词袋模型和二元模型相去甚远。这意味着“结构化嵌入空间”对于输入标记来说在实践上并不那么有用吗?或者它对于文本分类任务来说没有用?

恰恰相反,一个训练良好的标记嵌入空间可以显著提高此类模型的实际性能上限。在这个特定案例中,问题在于我们的训练设置。在我们的 20,000 条评论示例中,我们缺乏足够的数据来有效地训练一个好的词嵌入。到我们 10 个训练周期结束时,我们的训练集准确率已经突破了 99%。我们的模型已经开始过拟合并记住我们的输入,结果发现它在我们还没有学习到针对当前任务的优化词嵌入集之前就已经表现得很好。

对于这类情况,我们可以转向预训练。与其将我们的词嵌入与分类任务联合训练,我们可以在更多数据上单独训练它,无需正负样本标签。让我们看看。

预训练词嵌入

在过去十年 NLP 的快速发展中,预训练作为文本建模问题的主要方法也随之兴起。一旦我们超越了简单的基于集合的回归模型,转向具有数百万甚至数十亿参数的序列模型,文本模型对数据的需求变得极其巨大。我们通常受限于在文本领域中找到特定问题的标记示例的能力。

策略是设计一个无监督任务来训练模型参数,这些参数不需要标记数据。预训练数据可以是与我们最终任务类似领域的文本,或者甚至是我们在感兴趣工作的语言中的任意文本。预训练使我们能够学习语言中的通用模式,有效地在我们将模型专门化到感兴趣的最终任务之前对其进行初始化。

词嵌入是文本预训练的第一个重大成功之一,我们将在本节中展示如何预训练词嵌入。还记得我们在 IMDb 数据集准备中忽略的unsup/目录吗?它包含另外 25,000 条评论——与我们的训练数据大小相同。我们将结合所有我们的训练数据,并展示如何通过无监督任务预训练Embedding层的参数。

训练词嵌入的最直接设置之一被称为连续词袋(CBOW)模型^([2])。其思路是在数据集的所有文本上滑动一个窗口,我们持续尝试根据出现在其直接左右两侧的词来猜测一个缺失的词(图 14.6)。例如,如果我们的“词袋”周围包含“sail”、“wave”和“mast”这些词,我们可能会猜测中间的词是“boat”或“ocean”。

图 14.6:连续词袋模型使用浅层神经网络根据其周围上下文预测一个词。

在我们特定的 IMDb 分类问题中,我们感兴趣的是“初始化”我们刚刚训练的 LSTM 模型的词嵌入。我们可以重用之前计算出的TextVectorization词汇表。我们在这里试图做的只是为这个词汇表中的每个词学习一个良好的 64 维向量。

我们可以创建一个新的TextVectorization层,它具有相同的词汇表,但不截断或填充输入。我们将通过在文本上滑动上下文窗口来预处理这个层的输出标记。

imdb_vocabulary = text_vectorization.get_vocabulary()
tokenize_no_padding = keras.layers.TextVectorization(
    vocabulary=imdb_vocabulary,
    split="whitespace",
    output_mode="int",
) 

代码列表 14.27:从我们的TextVectorization预处理层中移除填充

为了预处理我们的数据,我们将在训练数据上滑动一个窗口,创建包含九个连续标记的“词袋”。然后,我们使用中间词作为我们的标签,并将剩余的八个词作为无序上下文来预测我们的标签。

为了做到这一点,我们再次使用tf.data来预处理我们的输入,尽管这个选择并不限制我们实际模型训练时使用的后端。

import tensorflow as tf

# Words to the left or right of label
context_size = 4
# Total window size
window_size = 9

def window_data(token_ids):
    num_windows = tf.maximum(tf.size(token_ids) - context_size * 2, 0)
    windows = tf.range(window_size)[None, :]
    windows = windows + tf.range(num_windows)[:, None]
    windowed_tokens = tf.gather(token_ids, windows)
    return tf.data.Dataset.from_tensor_slices(windowed_tokens)

def split_label(window):
    left = window[:context_size]
    right = window[context_size + 1 :]
    bag = tf.concat((left, right), axis=0)
    label = window[4]
    return bag, label

# Uses all training data, including the unsup/ directory
dataset = keras.utils.text_dataset_from_directory(
    imdb_extract_dir / "train", batch_size=None
)
# Drops label
dataset = dataset.map(lambda x, y: x, num_parallel_calls=8)
# Tokenizes
dataset = dataset.map(tokenize_no_padding, num_parallel_calls=8)
# Creates context windows
dataset = dataset.interleave(window_data, cycle_length=8, num_parallel_calls=8)
# Splits middle wonder into a label
dataset = dataset.map(split_label, num_parallel_calls=8) 

代码列表 14.28:预处理我们的 IMDb 数据以预训练 CBOW 模型

预处理之后,我们可以看到我们有八个整数标记 ID 作为上下文与一个单独的标记 ID 标签配对。

我们使用这些数据训练的模型极其简单。我们将使用一个Embedding层来嵌入所有上下文标记,并使用GlobalAveragePooling1D来计算上下文“词袋”的平均嵌入。然后,我们使用这个平均嵌入来预测中间标签标记的值。

就这样!通过反复细化我们的嵌入空间,以便我们擅长根据附近的词嵌入来预测一个词,我们学会了电影评论中使用的标记的丰富嵌入。

hidden_dim = 64
inputs = keras.Input(shape=(2 * context_size,))
cbow_embedding = layers.Embedding(
    max_tokens,
    hidden_dim,
)
x = cbow_embedding(inputs)
x = layers.GlobalAveragePooling1D()(x)
outputs = layers.Dense(max_tokens, activation="sigmoid")(x)
cbow_model = keras.Model(inputs, outputs)
cbow_model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["sparse_categorical_accuracy"],
) 

代码列表 14.29:构建 CBOW 模型

>>> cbow_model.summary()
Model: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_4 (InputLayer)        │ (None, 8)                │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ embedding_1 (Embedding)           │ (None, 8, 64)            │     1,920,000 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ global_average_pooling1d_2        │ (None, 64)               │             0 │
│ (GlobalAveragePooling1D)          │                          │               │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_4 (Dense)                   │ (None, 30000)            │     1,950,000 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 3,870,000 (14.76 MB)
 Trainable params: 3,870,000 (14.76 MB)
 Non-trainable params: 0 (0.00 B)

由于我们的模型非常简单,我们可以使用较大的批处理大小来加快训练速度,而不用担心内存限制。

我们还将对这个批处理数据集调用cache(),以便我们将整个预处理的整个数据集存储在内存中,而不是在每个 epoch 重新计算它。这是因为对于这个非常简单的模型,我们的瓶颈在于预处理而不是训练。也就是说,在 CPU 上对文本进行标记和计算滑动窗口比在 GPU 上更新我们的模型参数要慢。

在这种情况下,将您的预处理输出保存在内存中或磁盘上通常是一个好主意。您会注意到我们的后续 epoch 比第一次快三倍以上。这要归功于预处理的训练数据缓存。

dataset = dataset.batch(1024).cache()
cbow_model.fit(dataset, epochs=4) 

列表 14.30:训练 CBOW 模型

训练结束时,我们能够大约 12%的时间仅根据邻近的八个单词来猜测中间的单词。这可能听起来不是一个很好的结果,但考虑到每次我们有 30,000 个单词可供猜测,这实际上是一个合理的准确率。

让我们使用这个词嵌入来提高我们 LSTM 模型的表现。

使用预训练的嵌入进行分类

现在我们已经训练了一个新的词嵌入,将其应用于我们的 LSTM 模型很简单。首先,我们创建的模型与之前完全相同。

inputs = keras.Input(shape=(max_length,))
lstm_embedding = layers.Embedding(
    input_dim=max_tokens,
    output_dim=hidden_dim,
    mask_zero=True,
)
x = lstm_embedding(inputs)
x = layers.Bidirectional(layers.LSTM(hidden_dim))(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs, name="lstm_with_cbow") 

列表 14.31:使用Embedding层构建另一个 LSTM 序列模型

然后,我们将 CBOW 嵌入层的嵌入权重应用到 LSTM 嵌入层。这实际上为 LSTM 模型中大约 200 万个嵌入参数提供了一个新的、更好的初始化器。

lstm_embedding.embeddings.assign(cbow_embedding.embeddings) 

列表 14.32:重用 CBOW 嵌入以初始化 LSTM 模型

这样,我们可以像平常一样编译和训练我们的 LSTM 模型。

model.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=["accuracy"],
)
model.fit(
    sequence_train_ds,
    validation_data=sequence_val_ds,
    epochs=10,
    callbacks=[early_stopping],
) 

列表 14.33:使用预训练嵌入训练 LSTM 模型。

让我们评估我们的 LSTM 模型。

>>> test_loss, test_acc = model.evaluate(sequence_test_ds)
>>> test_acc
0.89139

列表 14.34:使用预训练嵌入评估 LSTM 模型

使用预训练的嵌入权重,我们将 LSTM 的性能提升到了与我们的集合模型大致相同。我们略好于单语模型,略差于双语模型。

在投入了所有这些工作之后,这可能会让人有些失望。在整个序列上,带有顺序信息进行学习,难道真是一个糟糕的想法吗?问题是,我们的最终 LSTM 模型仍然受到数据限制。模型的表达能力和强大程度足以在拥有足够的电影评论的情况下,我们很容易超越基于集合的方法,但我们需要在有序数据上进行更多的训练,才能达到我们模型性能的上限。

这是一个在足够的计算资源下可以轻松解决的问题。在下一章中,我们将介绍 transformer 模型。该模型在跨较长的标记序列学习依赖关系方面略胜一筹,但最重要的是,这些模型通常在大量的英文文本上训练,包括所有单词顺序信息。这允许模型学习,粗略地说,一种统计形式的语法模式,该模式控制着语言。这些关于单词顺序的统计模式正是我们当前的 LSTM 模型过于数据受限而无法有效学习的原因。

然而,当我们转向更大、更先进的模型,这些模型将推动文本分类性能的极限时,值得注意的是,像我们的二元模型这样的简单基于集合的回归方法可以给你带来很多“物有所值”。基于集合的模型速度极快,并且可以只包含几千个参数,这与今天新闻中占据主导地位的数十亿参数的大型语言模型相去甚远。

如果你在一个计算资源有限的环境中工作,并且可以牺牲一些准确性,那么基于集合的模型通常是最具成本效益的方法。

摘要

  • 所有文本建模问题都涉及一个预处理步骤,其中文本被拆分并转换为整数数据,称为标记化

  • 标记化可以分为三个步骤:标准化分割索引。标准化使文本标准化,分割将文本拆分成标记,索引为每个标记分配一个唯一的整数 ID。

  • 标记化主要有三种类型:字符单词子词标记化。在有足够表达力和训练数据的情况下,子词标记化通常是效果最好的。

  • NLP 模型在处理输入标记的顺序上存在主要差异:

    • 集合模型丢弃了大部分顺序信息,并仅基于输入中标记的存在或不存在来学习简单且快速的模型。二元三元模型考虑两个或三个连续标记的存在或不存在。集合模型训练和部署速度极快。

    • 序列模型试图通过输入数据中标记的有序序列来学习。序列模型需要大量的数据才能有效地学习。

  • 嵌入是将标记 ID 转换为学习到的潜在空间的一种有效方式。嵌入可以通过梯度下降正常训练。

  • 预训练对于序列模型至关重要,因为它可以克服这些模型对数据的贪婪需求。在预训练期间,一个无监督任务允许模型从大量未标记的文本数据中学习。然后可以将学习到的参数转移到下游任务。

脚注

  1. Phillip Gage,“一种新的数据压缩算法”,C 用户杂志档案(1994 年),dl.acm.org/doi/10.5555/177910.177914[↩]

  2. Mikolov 等人,“在向量空间中高效估计词表示”,国际学习表示会议(2013 年),arxiv.org/abs/1301.3781[↩]

posted @ 2025-12-08 20:09  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报