JavaScript 深度学习(三)

原文:zh.annas-archive.org/md5/ea99677736c22d68b5818a18b5a9213a

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:数据和模型的可视化

本章内容

  • 如何使用 tfjs-vis 执行自定义数据可视化

  • 如何在模型训练后查看内部工作并获得有用的见解

可视化对于机器学习从业者来说是一项重要的技能,因为它涉及到机器学习工作流的每个阶段。在我们构建模型之前,我们通过可视化来检查数据;在模型工程和训练期间,我们通过可视化来监测训练过程;模型训练完毕后,我们使用可视化来了解其工作原理。

在第六章中,你了解到在应用机器学习之前,可视化和了解数据的好处。我们介绍了如何使用 Facets,这是一个基于浏览器的工具,可以帮助你快速、交互式地查看数据。在本章中,我们将介绍一个新工具 tfjs-vis,它可以帮助你以自定义、程序化的方式可视化数据。这样做的好处,相较于只看数据的原始格式或使用 Facets 等现成工具,是更灵活、多样的可视化范式以及更深入理解数据的可能性。

除了数据可视化外,我们还会展示如何在深度学习模型训练后使用可视化。我们将使用深入的例子,通过可视化内部激活和计算卷积神经网络层最大程度“激发”的模式,来窥视神经网络“黑盒”的潜力。这将完整展现可视化如何在每个阶段与深度学习相辅相成的故事。

完成本章后,你应该知道为什么可视化是任何机器学习工作流不可或缺的一部分。你还应该熟悉在 TensorFlow.js 框架中可视化数据和模型的标准方式,并能够将它们应用到自己的机器学习问题中。

7.1 数据可视化

让我们从数据可视化开始,因为这是机器学习实践者在解决新问题时首先做的事情。我们假设可视化任务比 Facets 能够覆盖的更高级(例如,数据不在一个小的 CSV 文件中)。因此,我们首先会介绍一个基本的图表 API,它可以帮助你在浏览器中创建简单且广泛使用的绘图类型,包括折线图、散点图、条形图和直方图。在完成使用手工编写的数据的基本示例后,我们将通过一个涉及可视化有趣真实数据集的示例将事物整合起来。

7.1.1 使用 tfjs-vis 可视化数据

tfjs-vis 是一个与 TensorFlow.js 紧密集成的可视化库。本章将介绍其许多功能之一,即其 tfvis.render.* 命名空间下的轻量级图表 API。这个简单直观的 API 允许你在浏览器中制作图表,重点关注机器学习中最常用的图表类型。为了帮助你开始使用 tfvis.render,我们将给你介绍一个 CodePen,地址为 codepen.io/tfjs-book/pen/BvzMZr,该 CodePen 展示了如何使用 tfvis.render 创建各种基本数据图。

¹

此绘图 API 是建立在 Vega 可视化库之上的:vega.github.io/vega/

tfjs-vis 的基础知识

首先,注意 tfjs-vis 是独立于主要的 TensorFlow.js 库的。你可以从 CodePen 如何用 <script> 标签导入 tfjs-vis 来看出这一点:

<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-vis@latest">
</script>

这与导入主要的 TensorFlow.js 库的方式不同:

<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest">
</script>

tfjs-vis 和 TensorFlow.js 的 npm 包有所不同(分别是 @tensorflow/tfjs-vis@tensorflow/tfjs)。在一个依赖于 TensorFlow.js 和 tfjs-vis 的网页或 JavaScript 程序中,这两个依赖都必须被导入。

线图

最常用的图表类型可能是 线图(一个曲线,将一个数量绘制成有序数量)。线图有一个水平轴和一个垂直轴,通常分别称为 x 轴y 轴。这种类型的可视化在生活中随处可见。例如,我们可以通过线图将一天中温度的变化情况绘制出来,其中水平轴是一天中的时间,垂直轴是温度计的读数。线图的水平轴也可以是其他东西。例如,我们可以使用线图来显示高血压药物的治疗效应(它降低了多少血压)与剂量(每天使用多少药物)之间的关系。这样的绘图被称为 剂量-反应曲线。另一个非时间线图的很好的例子是我们在第三章中讨论的 ROC 曲线。那里,x 轴和 y 轴都与时间无关(它们是二元分类器的假阳性和真阳性率)。

要使用 tfvis.render 创建线图,可以使用 linechart() 函数。正如 CodePen 的第一个示例(也是清单 7.1)所示,该函数需要三个参数:

  1. 第一个参数是用于绘制图表的 HTML 元素。可以使用空的 <div> 元素。

  2. 第二个参数是图表中数据点的值。这是一个包含value字段并指向一个数组的普通 JavaScript 对象(POJO)。数组由多个 x-y 值对组成,每个值对由一个包含名为xy字段的 POJO 表示。xy值分别是数据点的 x 和 y 坐标。

  3. 第三个参数(可选)包含线图的其他配置字段。在这个例子中,我们使用width字段来指定结果图的宽度(以像素为单位)。在后面的例子中您将看到更多的配置字段。^([2])

    ²

    js.tensorflow.org/api_vis/latest/ 包含 tfjs-vis API 的完整文档,在这里您可以找到关于此函数的其他配置字段的信息。

清单 7.1. 使用tfvis.render.linechart()创建一个简单的折线图
let values = [{x: 1, y: 20}, {x: 2, y: 30},
              {x: 3, y: 5}, {x: 4, y: 12}];                ***1***
tfvis.render.linechart(document.getElementById('plot1'),   ***2***
                      {values},                            ***3***
                      {width: 400});                       ***4***
  • 1 数据系列是一个包含 x-y 对的数组。

  • 2 第一个参数是将绘制图表的 HTML 元素。这里的’plot1’是一个空的 div 的 ID。

  • 3 第二个参数是一个包含键“值”的对象。

  • 4 自定义配置作为第三个参数传递。在这种情况下,我们只配置了图的宽度。

由清单 7.1 中的代码创建的折线图显示在图 7.1 的左侧面板中。这是一个只有四个数据点的简单曲线。但是,linechart()函数可以支持更多数据点的曲线(例如,数千个)。然而,如果你尝试一次绘制太多数据点,你最终会遇到浏览器的资源限制。限制与浏览器和平台相关,应当通过实证方法来确定。一般来说,为了使用户界面流畅响应,限制图表中可呈现的数据大小是一个好习惯。

图 7.1. 使用tfvis.render.linechart()创建的折线图。左侧:使用清单 7.1 中的代码创建的单个系列。右侧:使用清单 7.2 中的代码在同一个坐标轴上创建的两个系列。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

有时您想在同一张图中绘制两条曲线,以显示它们之间的关系(例如相互比较)。您可以使用tfvis.render.linechart()制作这些类型的图表。示例显示在图 7.1 的右侧面板中,代码在清单 7.2 中。

这些被称为多系列图表,每条线称为系列。要创建多系列图表,必须在传递给linechart()的第一个参数中包括一个附加字段series。该字段的值是一个字符串数组。这些字符串是系列的名称,并将作为图表中的图例呈现。在示例代码中,我们将系列称为'My series 1''My series 2'

对于多系列图表,第一个参数的value字段也需要恰当地指定。对于我们的第一个示例,我们提供了一个点数组,但是对于多系列图表,我们必须提供一个数组的数组。嵌套数组的每个元素都是一个系列的数据点,并且具有与我们在清单 7.1 中绘制单系列图表时看到的值数组相同的格式。因此,嵌套数组的长度必须与series数组的长度匹配,否则将出现错误。

由清单 7.2 创建的图表显示在图 7.1 的右侧面板中。如您在本书的电子版本中图表中所见,tfjs-vis 选择两种不同的颜色(蓝色和橙色)来渲染两条曲线。这种默认的着色方案通常很有效,因为蓝色和橙色很容易区分。如果有更多的系列需要呈现,其他新颜色将自动选择。

此示例图表中的两个系列在 x 坐标的值(1、2、3 和 4)完全相同。但是,在多系列图表中,不同系列的 x 坐标值不一定相同。您可以尝试在本章末尾的练习 1 中尝试这种情况。但是,需要注意的是,将两条曲线绘制在同一个线条图表中并不总是明智的做法。例如,如果两条曲线具有非常不同并且不重叠的 y 值范围,则将它们绘制在同一个线条图表中会使每个曲线的变化更难以看到。在这种情况下,最好将它们绘制在单独的线条图表中。

在清单 7.2 中还值得指出的是轴的自定义标签。 我们使用配置对象中的xLabelyLabel字段(传递给linechart()的第三个参数)来标记我们选择的自定义字符串的 x 和 y 轴。 通常,标记轴是一种良好的实践,因为它使图表更易于理解。 如果您没有指定xLabelyLabel,tfjs-vis 将始终将您的轴标记为xy,这就是清单 7.1 和图 7.1 的左面板所发生的。

清单 7.2。使用 tfvis.render.linechart()制作带有两个系列的线条图表
  values = [                                                       ***1***
    [{x: 1, y: 20}, {x: 2, y: 30}, {x: 3, y: 5}, {x: 4, y: 12}],   ***1***
    [{x: 1, y: 40}, {x: 2, y: 0}, {x: 3, y: 50}, {x: 4, y: -5}]    ***1***
  ];                                                               ***1***
  let series = ['My series 1', 'My series 2'];                     ***2***
  tfvis.render.linechart(
           document.getElementById('plot2'), {values, series}, {
    width: 400,
    xLabel: 'My x-axis label',                                     ***3***
    yLabel: 'My y-axis label'                                      ***3***
  });
  • 1 为了在相同的轴上显示多个系列,使值成为由多个 x-y 对数组组成的数组。

  • 2 在绘制多个系列时,必须提供系列名称。

  • 3 覆盖默认的 x 和 y 坐标轴标签。

散点图

散点图 是您可以使用 tfvis.render 创建的另一种图表类型。散点图与折线图最显著的区别在于,散点图不使用线段连接数据点。这使得散点图适用于数据点间顺序不重要的情况。例如,散点图可以将几个国家的人口与人均国内生产总值进行绘制。在这样的图中,主要信息是 x 值和 y 值之间的关系,而不是数据点之间的顺序。

tfvis.render 中,让您创建散点图的函数是 scatterplot()。正如 清单 7.3 中的示例所示,scatterplot() 可以呈现多个系列,就像 linechart() 一样。事实上,scatterplot()linechart() 的 API 实际上是相同的,您可以通过比较 清单 7.2 和 清单 7.3 来了解。清单 7.3 创建的散点图显示在 图 7.2 中。

图 7.2. 包含两个系列的散点图,使用 清单 7.3 中的代码制作。

清单 7.3. 使用 tfvis.render.scatterplot() 制作散点图
  values = [                                                         ***1***
    [{x: 20, y: 40}, {x: 32, y: 0}, {x: 5, y: 52}, {x: 12, y: -6}],  ***1***
    [{x: 15, y: 35}, {x: 0, y: 9}, {x: 7, y: 28}, {x: 16, y: 8}]     ***1***
  ];                                                                 ***1***
  series = ['My scatter series 1', 'My scatter series 2'];
  tfvis.render.scatterplot(
      document.getElementById('plot4'),
    {values, series},
     {
      width: 400,
      xLabel: 'My x-values',                                         ***2***
      yLabel: 'My y-values'                                          ***2***
    });
  • 1 与 linechart() 一样,使用 x-y 对数组的数组来在散点图中显示多个系列

  • 2 记得始终标记你的轴。

柱状图

如其名称所示,柱状图 使用柱形显示数量的大小。这些柱通常从底部的零开始,以便可以从柱形的相对高度读取数量之间的比率。因此,当数量之间的比率很重要时,柱状图是一个不错的选择。例如,自然而然地使用柱状图来显示公司几年来的年收入。在这种情况下,柱形的相对高度使观众对收入在季度之间的变化情况有直观的感觉。这使得柱状图与折线图和散点图有所不同,因为这些值不一定“锚定”在零点上。

要使用tfvis.render创建条形图,请使用barchart()。您可以在代码清单 7.4 中找到一个示例。代码创建的条形图显示在图 7.3 中。barchart()的 API 类似于linechart()scatterplot()的 API。但是,应该注意一个重要的区别。传递给barchart()的第一个参数不是由value字段组成的对象。相反,它是一个简单的索引-值对数组。水平值不是用一个叫做x的字段指定的,而是用一个叫做index的字段指定的。同样,垂直值不是用一个叫做y的字段指定的,而是与一个叫做value的字段关联的。为什么有这种区别?这是因为条形图中条形的水平值不一定是一个数字。相反,它们可以是字符串或数字,正如我们在图 7.3 的示例中所示。

图 7.3. 由代码清单 7.4 生成的包含字符串和数字命名条的条形图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

代码清单 7.4. 使用tfvis.render.barchart()创建条形图
const data = [
    {index: 'foo', value: 1},{index: 'bar', value: 7},       ***1***
    {index: 3, value: 3},                                    ***1***
    {index: 5, value: 6}];                                   ***1***
  tfvis.render.barchart(document.getElementById('plot5'), data, {
    yLabel: 'My value',
    width: 400
  });
  • 1 请注意条形图的索引可以是数字或字符串。请注意元素的顺序很重要。
直方图

先前描述的三种图表类型允许您绘制某个数量的值。有时,详细的定量值并不像值的分布那样重要。例如,考虑一位经济学家查看国家普查结果中的年度家庭收入数据。对于经济学家来说,详细的收入数值并不是最有趣的信息。它们包含了太多信息(是的,有时候太多信息可能是一件坏事!)。相反,经济学家想要更简洁的收入数值摘要。他们对这些值是如何分布感兴趣——即有多少个值低于 2 万美元,有多少个值介于 2 万美元和 4 万美元之间,或者介于 4 万美元和 6 万美元之间,等等。直方图是一种适合这种可视化任务的图表类型。

直方图将值分配到区间中。每个区间只是一个值的连续范围,有一个下界和一个上界。区间被选择为相邻的,以覆盖所有可能的值。在前面的例子中,经济学家可能使用诸如 0 ~ 20k、20k ~ 40k、40k ~ 60k 等的区间。一旦选择了这样一组N个区间,您就可以编写一个程序来计算落入每个区间的单个数据点的数量。执行此程序将给您N个数字(每个区间一个)。然后,您可以使用垂直条形图绘制这些数字。这就给您一个直方图。

tfvis.render.histogram() 会为您执行所有这些步骤。这样可以省去您确定箱界限并按箱计数示例的麻烦。要调用 histogram(),只需传递一个数字数组,如下面的列表所示。这些数字不需要按任何顺序排序。

第 7.5 节。使用 tfvis.render.histogram() 可视化值分布。
  const data = [1, 5, 5, 5, 5, 10, -3, -3];
  tfvis.render.histogram(document.getElementById('plot6'), data, {  ***1***
    width: 400                                                      ***1***
  });                                                               ***1***

  // Histogram: with custom number of bins.
  // Note that the data is the same as above.
  tfvis.render.histogram(document.getElementById('plot7'), data, {
    maxBins: 3,                                                     ***2***
    width: 400
  });
  • 1 使用自动生成的箱。

  • 2 指定了明确的箱数。

在 列表 7.5 中,有两个略有不同的 histogram() 调用。第一个调用除了绘图宽度之外没有指定任何自定义选项。在这种情况下,histogram() 使用其内置的启发式方法来计算箱。结果是七个箱:–4 ~ –2,–2 ~ 0,0 ~ 2,…,8 ~ 10,如图 7.4 的左面板所示。在这七个箱中,直方图显示在 4 ~ 6 箱中具有最高值,其中包含 4 个计数,因为数据数组中的四个值为 5。直方图的三个箱(–2 ~ 0,2 ~ 4 和 6 ~ 8)的值为零,因为数据点的元素都没有落入这三个箱中。

图 7.4。相同数据的直方图,使用自动计算的箱(左)和明确指定的箱数(右)绘制。生成这些直方图的代码在 列表 7.5 中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,我们可以认为默认的启发式方法对于我们特定的数据点来说生成了太多的箱。如果箱数较少,那么不太可能会有任何箱是空的。您可以使用配置字段 maxBins 来覆盖默认的箱子启发式方法并限制箱子数量。这就是列表 7.5 中第二个 histogram() 调用所做的,其结果在图 7.4 中右侧显示。您可以看到通过将箱数限制为三个,所有箱都变得非空。

热图

热图 将数字的 2D 数组显示为彩色单元格的网格。每个单元格的颜色反映了 2D 数组元素的相对大小。传统上,“较冷”的颜色(如蓝色和绿色)用于表示较低的值,而“较暖”的颜色(如橙色和红色)则用于表示较高的值。这就是为什么这些图被称为热图。在深度学习中最常见的热图例子可能是混淆矩阵(参见第三章中的鸢尾花示例)和注意力矩阵(参见第九章中的日期转换示例)。tfjs-vis 提供了函数 tfvis.render.heatmap() 来支持此类可视化的渲染。

列表 7.6 展示了如何制作一个热图来可视化涉及三个类别的虚构混淆矩阵。混淆矩阵的值在第二个输入参数的 values 字段中指定。类别的名称,用于标记热图的列和行,是作为 xTickLabelsyTickLabels 指定的。不要将这些刻度标签与第三个参数中的 xLabelyLabel 混淆,后者用于标记整个 x 和 y 轴。图 7.5 展示了生成的热图绘图。

图 7.5. 由 列表 7.6 中的代码渲染的热图。它展示了一个涉及三个类别的虚构混淆矩阵。

列表 7.6. 使用 tfvis.render.heatmap() 可视化 2D 张量
  tfvis.render.heatmap(document.getElementById('plot8'), {
    values: [[1, 0, 0], [0, 0.3, 0.7], [0, 0.7, 0.3]],      ***1***
    xTickLabels: ['Apple', 'Orange', 'Tangerine'],          ***2***
    yTickLabels: ['Apple', 'Orange', 'Tangerine']           ***2***
  }, {
    width: 500,
    height: 300,
    xLabel: 'Actual Fruit',                                 ***3***
    yLabel: 'Recognized Fruit',                             ***3***
    colorMap: 'blues'                                       ***4***
  });
  • 1 传递给 heatmap() 的值可以是嵌套的 JavaScript 数组(如此处所示)或 2D tf.Tensor。

  • 2 xTickLabels 用于标记沿 x 轴的单个列。不要与 xLabel 混淆。同样,yTickLabels 用于标记沿 y 轴的单个行。

  • 3 xLabel 和 yLabel 用于标记整个坐标轴,不同于 xTickLabel 和 yTickLabel。

  • 4 除了这里展示的“蓝色”色图外,还有“灰度”和“翠绿”。

这就是我们对 tfvis.render 支持的四种主要图表类型的快速介绍。如果你未来的工作涉及使用 tfjs-vis 进行数据可视化,很有可能会经常使用这些图表。表 7.1 提供了图表类型的简要摘要,以帮助您决定在给定的可视化任务中使用哪种图表。

表 7.1. tfjs-vis 在 tfvis.render 命名空间下支持的五种主要图表类型的摘要
图表名称tfjs-vis 中对应的函数适合的可视化任务和机器学习示例
折线图tfvis.render.linechart()一个标量(y 值)随另一个具有固有顺序(时间、剂量等)的标量(x 值)变化。多个系列可以在同一坐标轴上绘制:例如,来自训练集和验证集的指标,每个指标都根据训练轮次数量绘制。
散点图tfvis.render.scatterplot()x-y 标量值对,没有固有的顺序,例如 CSV 数据集的两个数值列之间的关系。多个系列可以在同一坐标轴上绘制。
条形图tfvis.render.barchart()一组属于少数类别的值,例如几个模型在相同分类问题上实现的准确率(以百分比数字表示)。
直方图tfvis.render.histogram()分布的主要兴趣是一组值,例如密集层内核中参数值的分布。
热力图tfvis.render.heathmap()一种二维数字数组,以 2D 网格单元格的形式进行可视化,每个元素的颜色用于反映对应值的大小:例如,多类别分类器的混淆矩阵(3.3 节);序列到序列模型的注意力矩阵(9.3 节)。
7.1.2. 一个综合案例研究:使用 tfjs-vis 可视化天气数据

上一节的 CodePen 示例使用的是小型的手动编码数据。在本节中,我们将展示如何在更大更有趣的真实数据集上使用 tfjs-vis 的图表功能。这将展示出 API 的真正强大之处,并且为在浏览器中进行数据可视化的价值提供论据。这个示例还将突出一些在解决实际问题时可能遇到的微妙之处和陷阱。

我们将使用的数据是 Jena-weather-archive 数据集。它包括在德国耶拿(Jena)地区的一个位置上使用各种气象仪器收集的数据,涵盖了八年的时间(2009 年至 2017 年)。可以从 Kaggle 页面上下载该数据集(参见www.kaggle.com/pankrzysiu/weather-archive-jena),它以一个 42MB 的 CSV 文件的形式提供。它包含 15 列,第一列是时间戳,其余列是气象数据,如温度(T deg(C))、气压(p (mbar))、相对湿度(rh (%s))、风速(wv (m/s))等。如果你检查时间戳,你会发现它们之间有 10 分钟的间隔,反映出测量是每隔 10 分钟进行一次。这是一个丰富的数据集,可以进行可视化、探索和尝试机器学习。在接下来的章节中,我们将尝试使用不同的机器学习模型进行天气预报。特别是,我们将使用前 10 天的天气数据来预测第二天的温度。但在我们开始这个令人兴奋的天气预测任务之前,让我们遵循“在尝试机器学习之前,始终查看数据”的原则,看看 tfjs-vis 如何以清晰直观的方式绘制数据。

要下载和运行 Jena-weather 示例,请使用以下命令:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/jena-weather
yarn
yarn watch
限制数据量以进行高效有效的可视化

Jena-weather 数据集相当大。文件大小为 42MB,比迄今为止本书中看到的所有 CSV 或表格数据集都要大。这导致了两个挑战:

  • 第一个挑战是对计算机而言:如果一次绘制八年的所有数据,浏览器选项卡将耗尽资源,变得无响应,并可能崩溃。即使你仅限制在 14 列中的 1 列,仍然有大约 42 万个数据点需要显示。这比 tfjs-vis(或任何 JavaScript 绘图库)能够安全渲染的量要多。

  • 第二个挑战是对用户而言:一次查看大量数据并从中提取有用信息是困难的。例如,有人应该如何查看所有 420,000 个数据点并从中提取有用信息?就像计算机一样,人类大脑的信息处理带宽是有限的。可视化设计师的工作是以高效的方式呈现数据的最相关和最有信息量的方面。

我们使用三种技巧来解决这些挑战:

  • 我们不是一次性绘制整个八年的数据,而是让用户使用交互式用户界面选择要绘制的时间范围。这就是用户界面中时间跨度下拉菜单的目的(请参见 图 7.6 和 7.7 中的截屏)。时间跨度选项包括 Day、Week、10 Days、Month、Year 和 Full。最后一个对应于整个八年。对于任何其他时间跨度,用户界面允许用户在时间上前后移动。这就是左箭头和右箭头按钮的作用。

    图 7.6. 展示了 Jena 气象档案数据集中温度(T(degC))和气压(p(mbar))的折线图,分别以两种不同的时间尺度绘制。顶部:10 天时间跨度。注意温度曲线中的日常周期。底部:1 年时间跨度。注意温度曲线中的年度周期以及春季和夏季期间气压相对其他季节更稳定的轻微倾向。

    图 7.7. Jena 气象演示的散点图示例。该图显示了空气密度(rho,纵轴)和温度(T,横轴)之间的关系,时间跨度为 10 天,可以看到负相关性。

  • 对于任何超过一周的时间跨度,我们在将时间序列绘制到屏幕上之前进行降采样。例如,考虑时间跨度为一个月(30 天)。这个时间跨度的完整数据包含约 30 * 24 * 6 = 4.32k 个数据点。在 清单 7.7 中的代码中,您可以看到我们在显示一个月的数据时仅绘制每六个数据点。这将绘制的数据点数量减少到 0.72k,大大降低了渲染成本。但对于人眼来说,数据点数量的六倍减少几乎没有什么差别。

  • 与我们在时间跨度下拉菜单中所做的类似,我们在用户界面中包含一个下拉菜单,以便用户可以选择在任何给定时间绘制什么天气数据。注意标有 Data Series 1 和 Data Series 2 的下拉菜单。通过使用它们,用户可以在同一坐标轴上将任何 1 或任何 2 个 14 列中的数据作为折线图绘制到屏幕上。

7.7 节的示例展示了负责制作与图 7.6 类似的图表的代码。尽管代码调用了tfvis.render.linechart(),与前一节中的 CodePen 示例相似,但与前面列表中的代码相比,它要抽象得多。这是因为在我们的网页中,我们需要根据 UI 状态延迟决定要绘制的数量。

7.7 节。Jena 天气数据作为多系列折线图(在 jena-weather/index.js 中)
function makeTimeSerieChart(
    series1, series2, timeSpan, normalize, chartContainer) {
  const values = [];
  const series = [];
  const includeTime = true;
  if (series1 !== 'None') {
    values.push(jenaWeatherData.getColumnData(                    ***1***
        series1, includeTime, normalize, currBeginIndex,
        TIME_SPAN_RANGE_MAP[timeSpan],                            ***2***
        TIME_SPAN_STRIDE_MAP[timeSpan]));                         ***3***
    series.push(normalize ? `${series1} (normalized)` : series1);
  }
  if (series2 !== 'None') {                                       ***4***
    values.push(jenaWeatherData.getColumnData(
        series2, includeTime, normalize, currBeginIndex,
        TIME_SPAN_RANGE_MAP[timeSpan],
        TIME_SPAN_STRIDE_MAP[timeSpan]));
    series.push(normalize ? `${series2} (normalized)` : series2);
  }
  tfvis.render.linechart({values, series: series}, chartContainer, {
    width: chartContainer.offsetWidth * 0.95,
    height: chartContainer.offsetWidth * 0.3,
    xLabel: 'Time',                                               ***5***
    yLabel: series.length === 1 ? series[0] : ''
  });
}
  • 1 jenaWeatherData 是一个帮助我们组织和检索来自 CSV 文件的天气数据的对象。请参阅 jena-weather/data.js。

  • 2 指定可视化的时间跨度

  • 3 选择适当的步幅(降采样因子)

  • 4 利用了 tfjs-vis 的折线图支持多系列的特性。

  • 5 总是标记轴。

鼓励您探索数据可视化界面。它包含许多有趣的天气模式,您可以发现。例如,图 7.6 的顶部面板显示了在 10 天内温度(T (degC))和标准化气压(p (mbar))是如何变化的。在温度曲线中,您可以看到一个明显的日循环:温度倾向于在中午左右达到峰值,并在午夜后不久达到最低点。在日循环之上,您还可以看到在这 10 天期间的一个更全局的趋势(逐渐增加)。相比之下,气压曲线在这个时间尺度上没有显示出明显的模式。同一图的底部面板显示了一年时间跨度内的相同测量值。在那里,您可以看到温度的年循环:它在八月左右达到峰值,并在一月左右达到最低点。气压再次显示出一个不太清晰的模式,比起温度,在这个时间尺度上。压力在整个年份内可能以一种略微混沌的方式变化,尽管在夏季周围,似乎有一个较少变化的倾向,而在冬季则相反。通过在不同的时间尺度上查看相同的测量值,我们可以注意到各种有趣的模式。如果我们只看数字 CSV 格式的原始数据,所有这些模式几乎是不可能注意到的。

在图 7.6 中的图表中,你可能已经注意到它们显示的是温度和气压的归一化值,而不是它们的绝对值,这是因为我们在生成这些图表时勾选了 UI 中的“Normalize Data”复选框。我们在第二章中讨论波士顿房价模型时简单提到了归一化。那里的归一化涉及将平均值减去,然后除以标准差的结果。我们这里进行的归一化完全相同。然而,这不仅仅是为了我们机器学习模型的准确性(下一节将介绍),还是为了可视化。为什么呢?如果你尝试在图表显示温度和气压时取消勾选“Normalize Data”复选框,你会立即看到原因。温度测量值的范围在-10 到 40 之间(摄氏度),而气压的范围在 980 到 1,000 之间。在没有归一化的情况下,具有非常不同范围的两个变量会导致 y 轴扩展到非常大的范围,使得两条曲线看起来基本上是平的,并且只有微小的变化。通过归一化,可以避免这个问题,将所有测量值映射到零平均值和单位标准差的分布。

图 7.7 展示了一个将两个气象测量值绘制为散点图的示例,你可以通过勾选“Plot Against Each Other”复选框并确保两个“Data Series”下拉菜单都不是“None”来激活此模式。制作这样的散点图的代码与清单 7.7 中的makeTimeSerieChart()函数相似,因此这里为了简洁起见省略了。如果你对细节感兴趣,可以在相同的文件(jena-weather/index.js)中进行研究。

这个示例散点图展示了归一化空气密度(y 轴)和归一化温度(x 轴)之间的关系。在这里,你可以发现两个变量之间存在较强的负相关性:随着温度的升高,空气密度将降低。这个示例图表使用了 10 天的时间跨度,但你可以验证这种趋势在其他时间跨度下也基本保持不变。这种变量之间的相关性可以通过散点图轻松地可视化,但只通过文本格式的数据很难发现。这再次展示了数据可视化的强大价值。

7.2. 训练后的模型可视化

在之前的章节中,我们展示了可视化对数据的有用之处。在本节中,我们将展示如何在模型训练后可视化模型的各个方面,以获得有用的洞察力。为此,我们将主要关注以图像为输入的卷积神经网络(convnet),因为它们被广泛使用且产生有趣的可视化结果。

你可能听说过深度神经网络是“黑盒子”。不要让这个说法让你误以为在推理或训练神经网络时很难从内部获取任何信息。相反,查看 TensorFlow.js 中编写的模型的每个层在内部做了什么是相当容易的。此外,就卷积神经网络而言,它们学习的内部表示非常适合可视化,主要是因为它们是视觉概念的表示。自 2013 年以来,已经开发了各种各样的技术来可视化和解释这些表示。由于涵盖所有有趣的技术是不切实际的,我们将介绍三种最基本和最有用的技术:

³

这个说法实际上意味着,深度神经网络中发生的大量数学运算,即使可以访问,也比起某些其他类型的机器学习算法,如决策树和逻辑回归,更难以用 layperson 的术语描述。例如,对于决策树,你可以逐个沿着分支点走下去,并解释为什么选择了某个分支,通过用一句简单的句子如“因为因子 X 大于 0.35”来用语言化的方式解释原因。这个问题被称为模型可解释性,与我们在本节中涵盖的内容不同。

  • 可视化 convnet 中间层(中间激活)的输出 —— 这有助于理解连续 convnet 层如何转换其输入,并且可以初步了解单个 convnet 滤波器学习的视觉特征。

  • 通过找到最大化激活它们的输入图像来可视化 convnet 滤波器 —— 这有助于理解每个滤波器对哪种视觉模式或概念敏感。

  • 可视化输入图像中类激活的热图 —— 这有助于理解输入图像的哪些部分在导致 convnet 生成最终分类结果时起着最重要的作用,这也可以有助于解释 convnet 如何达到其输出和“调试”不正确的输出。

我们将使用的代码来展示这些技术是来自 tfjs-examples 仓库的 visualize-convnet 示例。要运行示例,请使用以下命令:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/visualize-convnet
yarn && yarn visualize

yarn visualize命令与您在先前示例中看到的yarn watch命令不同。除了构建和启动网页之外,它还在浏览器外执行一些额外的步骤。首先,它安装一些所需的 Python 库,然后下载并转换 VGG16 模型(一个知名且广泛使用的深度卷积网络)为 TensorFlow.js 格式。VGG16 模型已经在大规模的 ImageNet 数据集上进行了预训练,并作为 Keras 应用程序提供。一旦模型转换完成,yarn visualize在 tfjs-node 中对转换后的模型进行一系列分析。为什么这些步骤在 Node.js 中而不是浏览器中执行?因为 VGG16 是一个相对较大的卷积网络。^([4]) 因此,其中的一些步骤计算量很大,在 Node.js 中的资源限制较少的环境中运行得更快。如果您使用 tfjs-node-gpu 而不是默认的 tfjs-node,计算速度可以进一步加快(这需要具有所需驱动程序和库的 CUDA 启用 GPU;请参阅附录 A):

要了解 VGG16 有多大的概念,请意识到其总重量大小为 528 MB,而 MobileNet 的重量大小小于 10MB。

yarn visualize --gpu

一旦在 Node.js 中完成了计算密集的步骤,它们将生成一组图像文件在 dist/folder 中。作为最后一步,yarn visualize将编译并启动一个 Web 服务器,用于一组静态 Web 文件,包括那些图像,除了在浏览器中打开索引页。

yarn visualize命令包含一些额外可配置的标志。例如,默认情况下,它对感兴趣的每个卷积层执行八个过滤器的计算和可视化。您可以使用--filters标志更改过滤器的数量:例如,yarn visualize --filters 32。此外,yarn visualize使用的默认输入图像是随源代码提供的 cat.jpg 图像。您可以使用--image标志使用其他图像文件。^([5]) 现在让我们基于 cat.jpg 图像和 32 个过滤器查看可视化结果。

最常见的图像格式,包括 JPEG 和 PNG,都受支持。

7.2.1. 可视化卷积神经网络内部激活

在这里,我们计算并显示了给定输入图像的 VGG16 模型的各种卷积层生成的特征图。这些特征图被称为内部激活,因为它们不是模型的最终输出(模型的最终输出是一个长度为 1,000 的向量,表示 1,000 个 ImageNet 类别的概率分数)。相反,它们是模型计算的中间步骤。这些内部激活使我们能够了解输入是如何被网络学习的不同特征分解的。

回顾第四章,卷积层的输出具有 NHWC 形状[numExamples, height, width, channels]。在这里,我们正在处理单个输入图像,因此numExamples为 1。我们想要可视化每个卷积层输出的剩余三个维度:高度、宽度和通道。卷积层输出的高度和宽度由其滤波器大小、填充、步长以及图层输入的高度和宽度确定。一般来说,随着深入到卷积神经网络中,它们会变得越来越小。另一方面,随着深入,channels的值通常会变得越来越大,因为卷积神经网络通过一系列层的表示转换逐渐提取越来越多的特征。卷积层的这些通道不能解释为不同的颜色分量。相反,它们是学习到的特征维度。这就是为什么我们的可视化将它们分成单独的面板并以灰度绘制的原因。图 7.8 展示了给定 cat.jpg 输入图像的 VGG16 的五个卷积层的激活。

图 7.8。VGG16 对 cat.jpg 图像执行推理的几个卷积层的内部激活。左侧显示原始输入图像,以及模型输出的前三个类别和它们关联的概率分数。可视化的五个层分别是命名为block1_conv1block2_conv1block3_conv2block4_conv2block5_conv3的层。它们按照在 VGG16 模型中的深度从顶部到底部的顺序排序。也就是说,block1_conv1是最靠近输入层的,而block5_conv1是最靠近输出层的。请注意,出于可视化目的,所有内部激活图像都缩放到相同的大小,尽管由于连续的卷积和池化,后续层的激活具有较小的尺寸(较低的分辨率)。这可以从后续层中的粗略像素模式中看出。

在内部激活中你可能注意到的第一件事是随着网络的深入,它们与原始输入的差异越来越大。较早的层(例如block1_conv1)似乎编码相对简单的视觉特征,例如边缘和颜色。例如,标记为“A”的箭头指向一个似乎响应黄色和粉色的内部激活。标记为“B”的箭头指向一个似乎与输入图像中某些方向的边缘有关的内部激活。

但是,后面的层(比如block4_conv2block5_conv3)显示出越来越多地与输入图像中简单的像素级特征不相关的激活模式。例如,图 7.8 中标记为“C”的箭头指向block4_ conv2中的一个滤波器,它似乎对猫的面部特征进行编码,包括耳朵、眼睛和鼻子。这是我们在第四章的图 4.6 中用示意图展示的逐渐特征提取的具体示例。但请注意,并非所有后续层中的滤波器都能用简单的方式用语言解释清楚。另一个有趣的观察是,激活图的“稀疏性”也随着层的深度增加而增加:在图 7.8 中显示的第一层中,所有滤波器都被输入图像激活(显示出非常量像素模式);然而,在最后一层中,一些滤波器变为空白(常量像素模式;例如,参见图 7.8 右面板的最后一行)。这意味着由那些空白滤波器编码的特征在这个特定的输入图像中是不存在的。

您刚刚目睹了深度卷积神经网络学习到的表示的一个重要的普遍特征:通过层提取的特征随着层的深度越来越抽象。深层的激活承载着越来越少关于输入细节的信息,越来越多关于目标的信息(在本例中是图像属于 1,000 个 ImageNet 类别中的哪一个)。因此,深度神经网络有效地充当着一个 信息蒸馏管道,原始数据进入并被重复地转换,以便过滤掉任务无关的方面,并逐渐放大和精炼对任务有用的方面。即使我们通过一个卷积神经网络的例子展示了这一点,但这个特征对其他深度神经网络(如 MLPs)也是成立的。

卷积神经网络发现有用的输入图像方面可能与人类视觉系统发现的有用方面不同。卷积神经网络的训练受到数据驱动,因此容易受到训练数据的偏见影响。例如,在本章末尾“进一步阅读和探索材料”部分列出的 Marco Ribeiro 和同事的论文指出了一个案例,在这个案例中,由于背景中有雪的存在,一张狗的图像被误分类为狼,这可能是因为训练图像中包含了狼在雪地背景下的实例,但没有包含类似背景下的狗的实例。

通过可视化深度卷积神经网络的内部激活模式,我们获得了这些有用的见解。下一小节描述了如何在 TensorFlow.js 中编写代码来提取这些内部激活。

深入了解如何提取内部激活

提取内部激活的步骤封装在 writeInternalActivationAndGetOutput() 函数中(清单 7.8)。它以已经构建或加载的 TensorFlow.js 模型对象和相关层的名称(layerNames)作为输入。关键步骤是创建一个新的模型对象(compositeModel),其中包括指定层的输出和原始模型的输出。 compositeModel 使用 tf.model() API 构建,就像你在 第五章 的 Pac-Man 和简单物体检测示例中看到的一样。关于 compositeModel 的好处在于它的 predict() 方法返回所有层的激活,以及模型的最终预测(参见名为 outputsconst)。清单 7.8 中的其余代码(来自 visualize-convnet/main.js)是关于将层的输出拆分为单独的滤波器并将它们写入磁盘文件的更加平凡的任务。

清单 7.8. 在 Node.js 中计算卷积神经网络的内部激活
async function writeInternalActivationAndGetOutput(
    model, layerNames, inputImage, numFilters, outputDir) {
  const layerName2FilePaths = {};
  const layerOutputs =
      layerNames.map(layerName => model.getLayer(layerName).output);
  const compositeModel = tf.model(                                    ***1***
      {
        inputs: model.input,
       outputs: layerOutputs.concat(model.outputs[0])
      });

  const outputs = compositeModel.predict(inputImage);                 ***2***
  for (let i = 0; i < outputs.length - 1; ++i) {
    const layerName = layerNames[i];
    const activationTensors =                                         ***3***
        tf.split(outputs[i],
                outputs[i].shape[outputs[i].shape.length – 1],
                -1);
    const actualNumFilters = filters <= activationTensors.length ?
        numFilters :
        activationTensors.length;
    const filePaths = [];
    for (let j = 0; j < actualNumFilters; ++j) {
      const imageTensor = tf.tidy(                                    ***4***
          () => deprocessImage(tf.tile(activationTensors[j],
                              [1, 1, 1, 3])));
      const outputFilePath = path.join(
          outputDir, `${layerName}_${j + 1}.png`);
      filePaths.push(outputFilePath);
      await utils.writeImageTensorToFile(imageTensor, outputFilePath);
    }
    layerName2FilePaths[layerName] = filePaths;
    tf.dispose(activationTensors);
  }
  tf.dispose(outputs.slice(0, outputs.length - 1));
  return {modelOutput: outputs[outputs.length - 1], layerName2FilePaths};
}
  • 1 构建一个模型,返回所有期望的内部激活,以及原始模型的最终输出

  • 2 输出是包含内部激活和最终输出的 tf.Tensor 数组。

  • 3 将卷积层的激活按滤波器进行拆分

  • 4 格式化激活张量并将其写入磁盘

7.2.2. 可视化卷积层对哪些内容敏感:最大激活图像

另一种说明卷积网络学习内容的方式是找到其各种内部层对哪些输入图像敏感。我们所说的对某个输入图像敏感是指在输入图像下,滤波器输出的最大激活(在其输出高度和宽度维度上取平均)。

我们找到最大激活图像的方式是通过一种将“正常”的神经网络训练过程颠倒过来的技巧。图 7.9 的面板 A 简要显示了当我们使用 tf.Model.fit() 训练神经网络时会发生什么。我们冻结输入数据,并允许模型的权重(例如所有可训练层的核和偏差)通过反向传播从损失函数更新。但是,我们完全可以交换输入和权重的角色:我们可以冻结权重,并允许输入通过反向传播进行更新。同时,我们调整损失函数,使其导致反向传播以一种方式来微调输入,该方式最大化了某个卷积滤波器的输出,当在其高度和宽度维度上平均时。该过程在图 7.9 的面板 B 中示意,被称为输入空间中的梯度上升,与 typica 模型训练的基于权重空间中的梯度下降相对应。实现输入空间中的梯度下降的代码将在下一小节中展示,并可以供感兴趣的读者研究。

这个图可以看作是图 2.9 的简化版本,我们在第二章中用它来介绍反向传播。

图 7.9. 示意图显示了通过输入空间中的梯度上升找到卷积滤波器的最大激活图像的基本思想(面板 B)以及与基于权重空间中的梯度下降的正常神经网络训练过程(面板 A)不同的地方。请注意,该图与先前显示的某些模型图有所不同,因为它将权重从模型中分离出来。这是为了突出两组可以通过反向传播更新的量:权重和输入。

图 7.10 展示了在 VGG16 模型的四个卷积层上执行梯度上升输入空间过程的结果(与我们用来展示内部激活的相同模型)。与先前的插图一样,图层的深度从图的顶部到底部逐渐增加。从这些最大激活输入图像中可以得到一些有趣的模式:

  • 首先,这些是彩色图像,而不是前面部分的灰度内部激活图像。这是因为它们的格式是卷积网络的实际输入:由三个(RGB)通道组成的图像。因此,它们可以显示为彩色。

  • 最浅的层(block1_conv1)对全局颜色值和带有特定方向的边缘等简单模式敏感。

  • 中间深度层(如block2_conv1)对由不同边缘模式组合而成的简单纹理做出最大响应。

  • 在较深层的滤波器开始响应更复杂的模式,这些模式在某种程度上与自然图像中的视觉特征(当然是来自 ImageNet 训练数据)相似,例如颗粒、孔洞、彩色条纹、羽毛、波纹等。

图 7.10. VGG16 深度卷积网络四个层的最大激活输入图像。这些图像是通过在输入空间中进行 80 次梯度上升计算得出来的。

一般来说,随着层级的加深,模式从像素级逐渐变得更加复杂和大规模。这反映了深度卷积网络逐层对特征进行提炼,组合出各种模式。在分析同一层的滤波器时,尽管它们具有类似的抽象级别,但在详细模式上存在相当大的变化。这突显了每一层以互补的方式提出了同一输入的多种表示,以捕获尽可能多的有用信息,从而解决网络训练的任务。

深入了解输入空间中的梯度上升

在可视化卷积网络的例子中,在 main.js 中的 inputGradientAscent() 函数中实现了输入空间中的梯度上升的核心逻辑,并且在 列表 7.9 中进行了展示。由于其耗时和占用内存,该代码运行在 Node.js 中。^([7]) 注意,尽管梯度上升在输入空间中的基本思想类似于基于权重空间的梯度下降的模型训练(参见 图 7.10),但我们不能直接重用 tf.Model.fit(),因为该函数专门冻结输入并更新权重。相反,我们需要定义一个自定义函数,该函数计算给定输入图像的“损失”。这就是该行定义的函数

对于小于 VGG16 的卷积网络(如 MobileNet 和 MobileNetV2),可以在合理的时间内在 Web 浏览器中运行该算法。

const lossFunction = (input) =>
        auxModel.apply(input, {training: true}).gather([filterIndex], 3);

这里,auxModel是一个使用熟悉的tf.model()函数创建的辅助模型对象。它具有与原始模型相同的输入,但输出给定卷积层的激活。我们调用辅助模型的apply()方法,以获得层激活的值。apply()类似于predict(),因为它执行模型的前向路径。但是,apply()提供了更细粒度的控制,例如将training选项设置为true,就像代码中前一行所做的那样。如果不将training设置为true,则不可能进行反向传播,因为默认情况下,前向传播会为内存效率而处置中间层激活。training标志中的true值使apply()调用保留这些内部激活,从而启用反向传播。gather()调用提取特定滤波器的激活。这是必要的,因为最大激活输入是根据每个过滤器逐个过滤器计算的,并且即使是相同层的过滤器之间的结果也会有所不同(请参见图 7.10 中的示例结果)。

一旦我们有了自定义损失函数,我们就将其传递给tf.grad(),以便获得一个给出损失相对于输入的梯度的函数:

const gradFunction = tf.grad(lossFunction);

这里要注意的重要事情是,tf.grad()不直接给出梯度值;相反,它会在调用时返回一个函数(在前一行中称为gradFunction),该函数在调用时会返回梯度值。

一旦我们有了这个梯度函数,我们就在一个循环中调用它。在每次迭代中,我们使用它返回的梯度值来更新输入图像。这里的一个重要的不明显的技巧是在将梯度值加到输入图像之前对其进行归一化,这确保了每次迭代中的更新具有一致的大小:

const norm = tf.sqrt(tf.mean(tf.square(grads))).add(EPSILON);
return grads.div(norm);

这个迭代更新输入图像的过程重复执行了 80 次,得到了我们在图 7.10 中展示的结果。

列表 7.9. 输入空间中的梯度上升(在 Node.js 中,来自 visualize-convnet/main.js)
function inputGradientAscent(
    model, layerName, filterIndex, iterations = 80) {
  return tf.tidy(() => {
    const imageH = model.inputs[0].shape[1];
    const imageW = model.inputs[0].shape[2];
    const imageDepth = model.inputs[0].shape[3];

    const layerOutput = model.getLayer(layerName).output;

    const auxModel = tf.model({                                            ***1***
      inputs: model.inputs,                                                ***1***
     outputs: layerOutput                                                  ***1***
    });

    const lossFunction = (input) =>                                        ***2***
        auxModel.apply(input, {training: true}).gather([filterIndex], 3);  ***2***

    const gradFunction = tf.grad(lossFunction);                            ***3***

    let image = tf.randomUniform([1, imageH, imageW, imageDepth], 0, 1)    ***4***
                    .mul(20).add(128);                                     ***4***

    for (let i = 0; i < iterations; ++i) {
      const scaledGrads = tf.tidy(() => {
        const grads = gradFunction(image);
        const norm = tf.sqrt(tf.mean(tf.square(grads))).add(EPSILON);
        return grads.div(norm);                                            ***5***
      });
      image = tf.clipByValue(
               image.add(scaledGrads), 0, 255);                            ***6***
    }
    return deprocessImage(image);
  });
}
  • 1 为原始模型创建一个辅助模型,其输入与原模型相同,但输出为感兴趣的卷积层

  • 2 这个函数计算指定过滤器索引处的卷积层输出的值。

  • 3 这个函数计算卷积滤波器输出相对于输入图像的梯度。

  • 4 生成一个随机图像作为梯度上升的起始点

  • 5 重要技巧:将梯度与梯度的大小(范数)相乘

  • 6 执行一步梯度上升:沿着梯度方向更新图像

7.2.3. 卷积神经网络分类结果的视觉解释

我们将介绍的最后一个后训练卷积神经网络可视化技术是类激活映射(CAM)算法。CAM 旨在回答的问题是“输入图像的哪些部分对于导致卷积神经网络输出其顶部分类决策起到最重要的作用?”例如,当将 cat.jpg 图像传递给 VGG16 网络时,我们得到了一个“埃及猫”的顶级类别,概率分数为 0.89。但仅凭图像输入和分类输出,我们无法确定图像的哪些部分对于这个决定是重要的。肯定图像的某些部分(如猫的头部)必须比其他部分(例如白色背景)起到更重要的作用。但是否有一种客观的方法来量化任何输入图像的这一点?

答案是肯定的!有多种方法可以做到这一点,CAM 就是其中之一。^([8])给定一个输入图像和一个卷积神经网络的分类结果,CAM 会给出一个热图,为图像的不同部分分配重要性分数。图 7.11 展示了这样的 CAM 生成的热图叠加在三个输入图像上:一只猫,一只猫头鹰和两只大象。在猫的结果中,我们看到猫头的轮廓在热图中具有最高的值。我们可以事后观察到,这是因为轮廓揭示了动物头部的形状,这是猫的一个独特特征。猫头鹰图像的热图也符合我们的预期,因为它突出显示了动物的头部和翅膀。具有两只大象的图像的结果很有趣,因为该图像与其他两个图像不同,它包含了两只个体动物而不是一只。CAM 生成的热图为图像中的两只大象的头部区域分配了高重要性分数。热图明显倾向于聚焦于动物的鼻子和耳朵,这可能反映了长鼻子的长度和耳朵的大小对于区分非洲象(网络的顶级类别)和印度象(网络的第三类别)的重要性。

CAM 算法首次描述于 Bolei Zhou 等人的“为判别定位学习深度特征”,2016 年,cnnlocalization.csail.mit.edu/。另一个知名的方法是局部可解释的模型无关解释(LIME)。见mng.bz/yzpq

图 7.11。VGG16 深度卷积神经网络的三个输入图像的类激活映射(CAMs)。CAM 热图叠加在原始输入图像上。

CAM 算法的技术方面

CAM 算法虽然强大,但其背后的思想实际上并不复杂。简而言之,CAM 图中的每个像素显示了如果增加该像素值一单位量,获胜类别的概率分数将发生多大变化。下面稍微详细介绍了 CAM 中涉及的步骤:

  1. 找到卷积神经网络中最后一个(即最深的)卷积层。在 VGG16 中,这一层的名称为 block5_conv3

  2. 计算网络输出概率对于获胜类别相对于卷积层输出的梯度。

  3. 梯度的形状为[1, h, w, numFilters],其中hwnumFilters分别是该层的输出高度、宽度和过滤器数量。然后,我们在示例、高度和宽度维度上对梯度进行平均,得到一个形状为[numFilters]的张量。这是一个重要性分数的数组,每个卷积层的过滤器都有一个。

  4. 将重要性分数张量(形状为[numFilters])与卷积层的实际输出值(形状为[1, h, w, numFilters])进行乘法运算,并使用广播(参见 附录 B,第 B.2.2 节)。这给我们一个新的张量,形状为[1, h, w, numFilters],是层输出的“重要性缩放”版本。

  5. 最后,平均重要性缩放的层输出沿最后一维(过滤器)进行,并挤压掉第一维(示例),从而得到一个形状为[h, w]的灰度图像。该图像中的值是图像中每个部分对于获胜分类结果的重要程度的度量。然而,该图像包含负值,并且比原始输入图像的尺寸要小(例如,在我们的 VGG16 示例中为 14 × 14,而原始输入图像为 224 × 224)。因此,我们将负值归零,并在覆盖输入图像之前对图像进行上采样。

详细代码位于 visualize-convnet/main.js 中名为 gradClassActivationMap() 的函数中。尽管该函数默认在 Node.js 中运行,但它所涉及的计算量明显少于前一节中我们看到的在输入空间中进行梯度上升的算法。因此,您应该能够在浏览器中使用相同的代码运行 CAM 算法,并且速度可接受。

在本章中,我们讨论了两个问题:在训练机器学习模型之前如何可视化数据,以及在训练完成后如何可视化模型。我们有意地跳过了其中一个重要步骤——也就是在模型训练过程中对模型进行可视化。这将成为下一章的重点。我们之所以单独提出训练过程,是因为它与欠拟合和过拟合的概念和现象有关,对于任何监督学习任务来说,这些概念和现象都是至关重要的,因此值得特别对待。通过可视化,我们可以更容易地发现和纠正欠拟合和过拟合问题。在下一章中,我们将重新讨论在本章第一部分介绍的 tfjs-vis 库,并了解到它不仅可以用于数据可视化,还可以显示模型训练的进展情况。

进一步阅读和探索材料

  • Marco Tulio Ribeiro, Sameer Singh, and Carlos Guestrin,“为什么我应该相信你?解释任何分类器的预测”,2016 年,arxiv.org/pdf/1602.04938.pdf

  • TensorSpace (tensorspace.org) 使用动画 3D 图形在浏览器中可视化卷积神经网络的拓扑和内部激活。它构建在 TensorFlow.js、three.js 和 tween.js 之上。

  • TensorFlow.js tSNE 库 (github.com/tensorflow/tfjs-tsne) 是基于 WebGL 的 t-distributed Stochastic Neighbor Embedding (tSNE) 算法的高效实现。它可以帮助您将高维数据集投影到 2D 空间中,同时保留数据中的重要结构。

练习

  1. 尝试使用tfjs.vis.linechart()的以下功能:

    1. 修改 列表 7.2 中的代码,看看当要绘制的两个系列具有不同的 x 坐标值集合时会发生什么。例如,尝试将第一个系列的 x 坐标值设置为 1、3、5 和 7,将第二个系列的 x 坐标值设置为 2、4、6 和 8。您可以从 codepen.io/tfjs-book/pen/BvzMZr 上分叉并修改 CodePen。

    2. 在示例 CodePen 中的线图中,所有的数据系列都是由没有重复 x 坐标值的数据点组成的。了解一下 linechart() 函数如何处理具有相同 x 坐标值的数据点。例如,在数据系列中,包括两个具有相同 x 值(例如-5 和 5)的数据点。

  2. 在 “visualize-convnet” 的例子中,使用 yarn visualize 命令的 --image 标志来指定自己的输入图片。由于我们在第 7.2 节中仅使用了动物图片,请尝试探索其他类型的图片内容,例如人物、车辆、家居物品和自然风景。看看你能从内部激活和 CAM 中获得什么有用的见解。

  3. 在我们计算 VGG16 的 CAM 的示例中,我们计算了相对于最后一个卷积层输出的 胜利 类别的概率分数的梯度。如果我们计算 非胜利 类别(例如较低概率的类别)的梯度会怎样?我们应该期望生成的 CAM 图像 强调属于图像实际主题的关键部分。通过修改 visualize-convnet 示例的代码并重新运行确认这一点。具体来说,梯度将计算的类索引作为参数传递给 gradClassActivationMap() 函数在 visualize-convnet/cam.js 中。该函数在 visualize-convnet/main.js 中调用。

摘要

  • 我们学习了 tfjs-vis 的基本用法,这是一个与 TensorFlow.js 紧密集成的可视化库。它可以用于在浏览器中呈现基本类型的图表。

  • 数据可视化是机器学习不可或缺的一部分。对数据进行高效有效的呈现可以揭示模式并提供否则难以获得的见解,正如我们通过使用 Jena-weather-archive 数据所展示的那样。

  • 丰富的模式和见解可以从训练好的神经网络中提取出来。我们展示了

    • 可视化深度卷积网络的内部层激活。

    • 计算哪些层对最大程度响应。

    • 确定输入图像的哪些部分与 convnet 的分类决策最相关。这些帮助我们了解 convnet 学到了什么以及在推断过程中它是如何运作的。

第八章:欠拟合、过拟合和机器学习的通用工作流程

本章内容

  • 为什么可视化模型训练过程很重要,以及要注意的重要事项

  • 如何可视化和理解欠拟合和过拟合

  • 处理过拟合的主要方式:正则化,以及如何可视化其效果

  • 机器学习的通用工作流程是什么,包括哪些步骤,以及为什么它是指导所有监督式机器学习任务的重要配方

在上一章中,您学习了如何使用 tfjs-vis 在开始设计和训练机器学习模型之前可视化数据。本章将从那一章结束的地方开始,并描述 tfjs-vis 如何用于在模型训练过程中可视化模型的结构和指标。这样做的最重要目标是发现 欠拟合过拟合 这两个至关重要的现象。一旦我们能够发现它们,我们将深入研究如何解决它们以及如何使用可视化验证我们的解决方法是否有效。

8.1。温度预测问题的制定

为了演示欠拟合和过拟合,我们需要一个具体的机器学习问题。我们将使用的问题是根据您在上一章中刚刚看到的 Jena-weather 数据集来预测温度。第 7.1 节展示了在浏览器中可视化数据的威力以及使用 Jena-weather 数据集进行此操作的好处。希望您通过在前一节中玩弄可视化 UI 来形成对数据集的直觉。我们现在准备好开始对数据集应用一些机器学习了。但首先,我们需要定义问题。

预测任务可以被看作是一个玩具天气预报问题。我们试图预测的是在某一时刻之后 24 小时的温度。我们试图使用在此前 10 天内进行的 14 种天气测量来进行此预测。

虽然问题定义很简单,但我们从 CSV 文件生成训练数据的方式需要进行一些仔细的解释,因为它与此前在本书中看到的问题的数据生成过程有所不同。在那些问题中,原始数据文件中的每一行都对应一个训练样例。这就是鸢尾花、波士顿房价和钓鱼检测示例的工作方式(见第二章和第三章)。然而,在这个问题中,每个示例是通过从 CSV 文件中对多行进行采样和组合而形成的。这是因为温度预测不仅仅是通过查看某一时刻的数据来进行的,而是通过查看一段时间内的数据来进行的。请参见图 8.1 以了解示例生成过程的示意图。

图 8.1. 示意图显示了如何从表格数据中生成单个训练样本。为了生成示例的特征张量,从 CSV 文件中每隔step行采样一次(例如,step = 6),直到采样到timeSteps行为止(例如,timeSteps = 240)。这形成了一个形状为[timeSteps, numFeatures]的张量,其中numFeatures(默认为 14)是 CSV 文件中特征列的数量。为了生成目标,从进入特征张量的最后一行后延迟(例如,144)步采样温度(T)值。可以通过从 CSV 文件的不同行开始来生成其他示例,但它们遵循相同的规则。这构成了温度预测问题:给定某一段时间(例如,10 天)内的 14 个天气测量值,预测从现在开始的一定延迟(例如,24 小时)内的温度。在jena-weather/data.js中的getNextBatchFunction()函数中实现了此图中所示的代码。

为了生成训练示例的特征,我们在 10 天的时间跨度内对一组行进行采样。我们不使用这 10 天内的所有数据行,而是每隔六行进行一次采样。为什么?有两个原因。首先,对所有行进行采样会给我们带来六倍的数据,并导致更大的模型大小和更长的训练时间。其次,以 1 小时为时间尺度的数据存在很多冗余性(6 小时前的气压通常接近于 6 小时零 10 分钟前的气压)。通过丢弃五分之一的数据,我们可以获得一个更轻量级和性能更好的模型,而不会牺牲太多的预测能力。采样的行被合并成了一个 2D 特征张量,形状为[timeSteps, numFeatures],用于我们的训练示例(参见图 8.1)。默认情况下,timeSteps的值为 240,对应于在 10 天期间均匀分布的 240 个采样时间。numFeatures为 14,对应于 CSV 数据集中可用的 14 个气象仪读数。

获取训练示例的目标更容易:我们只需从进入特征张量的最后一行向前移动一定的时间延迟,并从温度列中提取值。图 8.1 显示了仅生成单个训练示例的方式。要生成多个训练示例,我们只需从 CSV 文件的不同行开始。

您可能已经注意到我们温度预测问题的特征张量(参见图 8.1)有些奇怪:在所有以前的问题中,单个示例的特征张量是 1D 的,当多个示例被批处理时,会得到 2D 张量。然而,在这个问题中,单个示例的特征张量已经是 2D 的,这意味着当我们将多个示例组合成批处理时,我们将获得一个 3D 张量(形状为[batchSize, timeSteps, numFeatures])。这是一个敏锐的观察!2D 特征张量形状源于特征来自一系列事件的事实。特别是,它们是在 240 个时间点上采集的天气测量值。这将此问题与到目前为止您所看到的所有其他问题区分开来,其中给定示例的输入特征不涵盖多个时间点,无论是鸢尾花问题中的花大小测量还是 MNIST 图像中的 28×28 像素值。[¹]

¹

在第四章的语音命令识别问题实际上涉及到一系列事件:即形成频谱图的连续音频帧。然而,我们的方法论将整个频谱图视为图像,从而通过将其视为空间维度来忽略了问题的时间维度。

这是本书中你第一次遇到顺序输入数据。在下一章中,我们将深入探讨如何在 TensorFlow.js 中构建专业化和更强大的模型(RNNs)来处理顺序数据。但在这里,我们将使用我们已经了解的两种模型来解决问题:线性回归器和 MLPs。这为我们学习 RNNs 铺平了道路,并为我们提供了可以与更高级模型进行比较的基线。

在 jena-weather/data.js 中实现了图 8.1 所示数据生成过程的实际代码,在函数getNextBatchFunction()下。这是一个有趣的函数,因为它不是返回一个具体的值,而是返回一个包含名为next()的函数的对象。当调用next()函数时,它会返回实际的数据值。具有next()函数的对象称为迭代器。为什么我们使用这种间接方式而不是直接编写迭代器呢?首先,这符合 JavaScript 的生成器/迭代器规范。[²]我们将很快将其传递给tf.data.generator()API,以便为模型训练创建数据集对象。API 需要此函数签名。其次,我们的迭代器需要可配置;返回迭代器的函数是启用配置的一种好方法。

²

请参阅“迭代器和生成器”,MDN web 文档,mng.bz/RPWK

您可以从getNextBatchFunction()的签名中看到可能的配置选项:

getNextBatchFunction(
      shuffle, lookBack, delay, batchSize, step, minIndex, maxIndex,
          normalize,
      includeDateTime)

有相当多的可配置参数。例如,您可以使用 lookBack 参数来指定在进行温度预测时要向后查看多长时间段。您还可以使用 delay 参数来指定温度预测将来要做出的时间。minIndexmaxIndex 参数允许您指定要从中提取数据的行范围等。

我们通过将 getNextBatchFunction() 函数传递给 tf.data.generator() 函数,将其转换为 tf.data.Dataset 对象。正如我们在第六章中所描述的,当与 tf.Model 对象的 fitDataset() 方法一起使用时,tf.data.Dataset 对象能够使我们即使数据过大而无法一次性装入 WebGL 内存(或任何适用的后备内存类型)也能训练模型。Dataset 对象将仅当即将进入训练时才在 GPU 上创建批量训练数据。这正是我们在这里为温度预测问题所做的。实际上,由于示例的数量和大小过大,我们无法使用普通的 fit() 方法来训练模型。fitDataset() 调用可以在 jena-weather/models.js 中找到,看起来像以下列表。

列表 8.1。使用 tfjs-vis 对基于 fitDataset 的模型进行可视化训练
    const trainShuffle = true;
    const trainDataset = tf.data.generator(               ***1***
        () => jenaWeatherData.getNextBatchFunction(
          trainShuffle, lookBack, delay, batchSize, step, TRAIN_MIN_ROW,
          TRAIN_MAX_ROW, normalize, includeDateTime)).prefetch(8);
    const evalShuffle = false;
    const valDataset = tf.data.generator(                 ***2***
      () => jenaWeatherData.getNextBatchFunction(
          evalShuffle, lookBack, delay, batchSize, step, VAL_MIN_ROW,
          VAL_MAX_ROW, normalize, includeDateTime));

      await model.fitDataset(trainDataset, {
      batchesPerEpoch: 500,
      epochs,
      callbacks: customCallback,
      validationData: valDataset                          ***3***
    });
  • 1 第一个 Dataset 对象将生成训练数据。

  • 2 第二个 Dataset 对象将生成验证数据。

  • 3 用于 fitDataset() 的 validationData 配置可以接受 Dataset 对象或一组张量。这里使用了第一个选项。

fitDataset()的配置对象的前两个字段指定了模型训练的时期数量和每个时期抽取的批次数量。正如您在第六章中学到的那样,它们是 fitDataset() 调用的标准配置字段。然而,第三个字段 (callbacks: customCallback) 是新内容。这是我们可视化训练过程的方式。我们的 customCallback 根据模型训练是在浏览器中进行还是(正如我们将在下一章中看到的)在 Node.js 中进行,而取不同的值。

在浏览器中,tfvis.show.fitCallbacks() 函数提供 customCallback 的值。该函数帮助我们通过只需一行 JavaScript 代码在网页中可视化模型训练。它不仅省去了我们访问并跟踪逐批次和逐时期的损失和指标值的所有工作,而且也消除了手动创建和维护将呈现图表的 HTML 元素的需要:

  const trainingSurface =
      tfvis.visor().surface({tab: modelType, name: 'Model Training'});
   const customCallback = tfvis.show.fitCallbacks(trainingSurface,
      ['loss', 'val_loss'], {
     callbacks: ['onBatchEnd', 'onEpochEnd']
   }));

fitCallbacks()的第一个参数指定了一个由tfvis.visor().surface()方法创建的渲染区域,这在 tfjs-vis 的术语中被称为visor surface。Visor 是一个容器,可以帮助你方便地组织所有与浏览器机器学习任务相关的可视化内容。在结构上,Visor 有两个层次的层次结构。在较高的层次上,用户可以使用点击来导航一个或多个选项卡。在较低的级别上,每个选项卡都包含一个或多个surfacestfvis.visor().surface()方法通过其tabname配置字段,允许你在指定的 Visor 选项卡上以指定的名称创建一个表面。Visor surface 不仅限于渲染损失和度量曲线。实际上,我们在第 7.1 节的 CodePen 示例中展示的所有基本图表都可以渲染在 visor surfaces 上。我们将在本章末尾留下这个问题作为练习。

fitCallbacks()的第二个参数指定了在 visor surface 上渲染的损失和度量。在这种情况下,我们绘制了训练和验证数据集的损失。第三个参数包含一个字段,控制绘图更新的频率。通过同时使用onBatchEndonEpochEnd,我们将在每个批次和每个 epoch 结束时获得更新。在下一节中,我们将检查fitCallbacks()创建的损失曲线,并使用它们来发现欠拟合和过拟合。

8.2. 欠拟合、过拟合和对策

在训练机器学习模型期间,我们希望监控我们的模型在训练数据中捕捉到的模式。一个无法很好地捕捉模式的模型被称为欠拟合;一个捕捉模式过于完美,以至于它学到的内容在新数据上泛化能力较差的模型被称为过拟合。可以通过正则化等对策来使过拟合的模型恢复正常。在本节中,我们将展示可视化如何帮助我们发现这些模型行为以及对策的影响。

8.2.1. 欠拟合

要解决温度预测问题,让我们首先尝试最简单的机器学习模型:线性回归器。清单 8.2(来自 jena-weather/index.js)中的代码创建了这样一个模型。它使用一个具有单个单位和默认线性激活的密集层来生成预测。然而,与我们在第二章中为下载时间预测问题构建的线性回归器相比,此模型多了一个展平层。这是因为这个问题中特征张量的形状是 2D 的,必须被展平为 1D,以满足用于线性回归的密集层的要求。这个展平过程在图 8.2 中有所说明。重要的是要注意,这个展平操作丢弃了关于数据顺序(时间顺序)的信息。

图 8.2. 将形状为[timeSteps, numFeatures]的 2D 特征张量展平为形状为[timeSteps × numFeatures]的 1D 张量,正如清单 8.2 中的线性回归器和清单 8.3 中的 MLP 模型所做的那样

清单 8.2. 为温度预测问题创建一个线性回归模型
function buildLinearRegressionModel(inputShape) {
  const model = tf.sequential();
  model.add(tf.layers.flatten({inputShape}));        ***1***
  model.add(tf.layers.dense({units: 1}));            ***2***
  return model;
}
  • 1 将[batchSize, timeSteps, numFeatures]输入形状压平为[batchSize, timeSteps * numFeatures],以应用密集层

  • 2 带有默认(线性)激活的单单元密集层是一个线性回归器。

一旦模型构建完成,我们就为训练编译它

model.compile({loss: 'meanAbsoluteError', optimizer: 'rmsprop'});

这里,我们使用损失函数meanAbsoluteError,因为我们的问题是预测一个连续值(标准化温度)。与之前的一些问题不同,没有定义单独的度量标准,因为 MAE 损失函数本身就是人可解释的度量标准。但是,请注意,由于我们正在预测标准化温度,MAE 损失必须乘以温度列的标准差(8.476 摄氏度),以将其转换为绝对误差的预测。例如,如果我们得到的 MAE 为 0.5,那么它就相当于 8.476 * 0.5 = 4.238 摄氏度的预测误差。

在演示界面中,选择模型类型下拉菜单中的线性回归,并单击“训练模型”以开始训练线性回归器。训练开始后,您将立即在页面右侧弹出的“卡片”中看到模型的表格摘要(请参阅图 8.3 中的屏幕截图)。这个模型摘要表在某种程度上类似于model.summary()调用的文本输出,但在 HTML 中以图形方式呈现。创建表的代码如下:

    const surface = tfvis.visor().surface({name: 'Model Summary', tab});
    tfvis.show.modelSummary(surface, model);
图 8.3. tfjs-vis 可视化线性回归模型的训练。上图:模型的摘要表。下图:20 次训练时的损失曲线。此图是使用tfvis.show .fitCallbacks()创建的(请参阅 jena-weather/index.js)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

创建了表面后,我们通过将表面传递给tfvis.show.modelSummary()来在其中绘制一个模型摘要表,就像前一个代码片段的第二行那样。

在线性回归选项卡的模型摘要部分下,有一个显示模型训练的损失曲线的图表(图 8.3)。它是由我们在上一节中描述的 fitCallbacks() 调用创建的。从图中,我们可以看到线性回归器在温度预测问题上的表现如何。训练损失和验证损失最终都在 0.9 左右波动,这对应于绝对值为 8.476 * 0.9 = 7.6 摄氏度(请记住,8.476 是 CSV 文件中温度列的标准偏差)。这意味着在训练后,我们的线性回归器平均预测误差为 7.6 摄氏度(或 13.7 华氏度)。这些预测相当糟糕。没有人会想要依靠这个模型进行天气预报!这是一个欠拟合的例子。

欠拟合通常是由于使用不足的表示能力(功率)来建模特征-目标关系而导致的。在这个例子中,我们的线性回归器结构太简单,因此无法捕获前 10 天的天气数据与第二天温度之间的关系。为了克服欠拟合,我们通常通过使模型更强大来增加模型的功率。典型的方法包括向模型添加更多的层(具有非线性激活)和增加层的大小(例如,在密集层中的单位数)。所以,让我们向线性回归器添加一个隐藏层,看看我们能从结果 MLP 中获得多少改进。

8.2.2. 过拟合

创建 MLP 模型的函数位于列表 8.3(来自 jena-weather/index.js)。它创建的 MLP 包括两个密集层,一个作为隐藏层,一个作为输出层,另外还有一个扁平层,其作用与线性回归模型中的相同。您可以看到,与 列表 8.2 中的 buildLinearRegressionModel() 相比,该函数有两个额外的参数。特别是,kernelRegularizerdropoutRate 参数是我们稍后将用来对抗过拟合的方法。现在,让我们看看一个不使用 kernelRegularizerdropoutRate 的 MLP 能够达到什么样的预测准确度。

列表 8.3. 为温度预测问题创建 MLP
function buildMLPModel(inputShape, kernelRegularizer, dropoutRate) {
  const model = tf.sequential();
  model.add(tf.layers.flatten({inputShape}));
  model.add(tf.layers.dense({
    units: 32,
    kernelRegularizer                            ***1***
    activation: 'relu',
  }));
  if (dropoutRate > 0) {
    model.add(tf.layers.dropout({rate: dropoutRate}));
  }
  model.add(tf.layers.dense({units: 1}));       ***2***
  return model;
}
  • 1 如果由调用者指定,则向隐藏的密集层的内核添加正则化。

  • 2 如果由调用者指定,则在隐藏的密集层和输出密集层之间添加一个 dropout 层。

图 8.4 的面板 A 显示了 MLP 的损失曲线。与线性回归器的损失曲线相比,我们可以看到一些重要的区别:

  • 训练和验证损失曲线呈现出发散的模式。这与 图 8.3 中的模式不同,其中两个损失曲线呈现出基本一致的趋势。

  • 训练损失收敛到比之前低得多的错误。经过 20 个周期的训练,训练损失约为 0.2,对应于误差为 8.476 * 0.2 = 1.7 摄氏度——比线性回归的结果要好得多。

  • 然而,验证损失在前两个周期内短暂下降,然后开始缓慢上升。到第 20 个周期结束时,它的值明显高于训练损失(0.35,约为 3 摄氏度)。

图 8.4. 两种不同 MLP 模型在温度预测问题上的损失曲线。面板 A:没有任何正则化的 MLP 模型。面板 B:与面板 A 中模型相同层大小和数量的 MLP 模型,但是具有密集层核的 L2 正则化。请注意,两个面板之间的 y 轴范围略有不同。

相对于之前的结果,训练损失的四倍以上的减少是由于我们的 MLP 比线性回归模型具有更高的能力,这得益于一个更多的层和几倍于线性回归模型的可训练权重参数。然而,增加的模型能力带来了一个副作用:它导致模型在训练数据上拟合得比验证数据(模型在训练过程中没有看到的数据)显着好。这是过拟合的一个例子。这是一种情况,其中模型对训练数据中的不相关细节“过于关注”,以至于模型的预测开始对未见数据的泛化能力变差。

8.2.3. 使用权重正则化减少过拟合并可视化其工作

在 第四章 中,我们通过向模型添加 dropout 层来减少卷积神经网络的过拟合。在这里,让我们看另一种经常使用的减少过拟合的方法:向权重添加正则化。在 Jena-weather 演示 UI 中,如果选择具有 L2 正则化的 MLP 模型,底层代码将通过以下方式调用 buildMLPModel() 来创建 MLP(列表 8.3):

model = buildMLPModel(inputShape, tf.regularizers.l2());

第二个参数——tf.regularizers.l2() 的返回值——是一个 L2 正则化器。通过将上述代码插入 列表 8.3 中的 buildMLPModel() 函数中,您可以看到 L2 正则化器进入隐藏的密集层配置的 kernelRegularizer。这将 L2 正则化器附加到密集层的内核上。当一个权重(例如密集层的内核)有一个附加的正则化器时,我们称该权重是正则化的。同样,当模型的一些或全部权重被正则化时,我们称该模型为正则化的。

正则化器对于稠密层的kernel和它所属的 MLP 有什么作用呢?它会在损失函数中添加一个额外的项。来看看未经正则化的 MLP 的损失如何计算:它简单地定义为目标和模型预测之间的 MAE。伪代码如下:

loss = meanAbsoluteError(targets, predictions)

在加入正则化后,模型的损失函数会包含一个额外的项。伪代码如下:

loss = meanAbsoluteError(targets, prediciton) + 12Rate * 12(kernel)

在这里,l2Rate * l2(kernel)是损失函数中额外的 L2 正则化项。与 MAE 不同,这个项不依赖于模型的预测结果,而是仅与被正则化的kernel(一层的权重)有关。给定kernel的值,它输出一个只与kernel的值相关的数值。可以将这个数值看作是当前kernel值的不理想程度的度量。

现在让我们来看一下 L2 正则化函数l2(kernel)的详细定义:它计算所有权重值的平方和。举个例子,假设为了简单起见,我们的kernel的形状很小,为[2, 2],其值为[[0.1, 0.2], [-0.3, -0.4]],那么,

l2(kernel) = 0.1² + 0.2² + (-0.3)² + (-0.4)² = 0.3

因此,l2(kernel)始终返回一个正数,对kernel中的大权重值进行惩罚。在总的损失函数中加入这个项,会鼓励kernel的所有元素在绝对值上都变得更小,其他条件保持不变。

现在总的损失函数包含两个不同的项:目标预测不匹配项和与kernel大小有关的项。因此,训练过程不仅会尽量减少目标预测不匹配项,还会减少kernel元素平方和。通常情况下,这两个目标会相互冲突。例如,减小kernel元素大小可能会减小第二个项,但会增加第一个项(均方误差损失)。总的损失函数是如何平衡这两个相互冲突的项的相对重要性的?这就是l2Rate乘子发挥作用的地方。它量化了 L2 项相对于目标预测误差项的重要性。l2Rate的值越大,训练过程就越倾向于减少 L2 正则化项,但会增加目标预测误差。这个参数的默认值是1e-3,可以通过超参数优化进行调整。

L2 正则化如何帮助我们? 图 8.4B 展示了经过正则化的 MLPs 的损失曲线。通过与未经正则化的 MLPs 的曲线(图 8.4A)进行比较,您可以看到使用正则化的模型产生了较少的训练和验证损失曲线。这意味着模型不再“过度关注”训练数据集中的偶发模式,而是从训练集中学到的模式可以很好地推广到验证集中看不见的例子。在我们的经过正则化的 MLPs 中,只有第一个密集层加入了正则化,而第二个密集层没有。但这足以克服这种过拟合情况。在下一节中,我们将更深入地探讨为什么较小的卷积核值会降低过拟合。

可视化正则化对权值的影响

由于 L2 正则化通过鼓励隐藏的 dense 层中的卷积核具有更小的值来起作用,因此我们应该看到经过训练后的 MLPs 中,使用正则化的模型的卷积核的值更小。在 TensorFlow.js 中,我们可以使用 tfjs-vis 库的 tfvis.show.layer() 函数实现这一点。代码清单 8.4 展示了如何使用该函数可视化 TensorFlow.js 模型的权重。该代码在 MLP 模型训练结束时执行。tfvis.show.layer() 函数接受两个参数:可视化器上的渲染和要渲染的层。

代码清单 8.4。展示层权值分布的可视化代码(来自 jena-weather/index.js)
function visualizeModelLayers(tab, layers, layerNames) {
  layers.forEach((layer, i) => {
    const surface = tfvis.visor().surface({name: layerNames[i], tab});
    tfvis.show.layer(surface, layer);
  });
}

代码生成的可视化结果见图 8.5。A 和 B 两图分别展示了使用未经正则化和经过正则化的 MLPs 的结果。每个图中,tfvis.show.layer() 函数展示了该层的权值表格,其中包括权值的名称、形状和参数数量、参数值的最小/最大值,以及零值和 NaN 值的数量(最后一个参数可以用于诊断训练过程中出现的问题)。此外,该层的可视化界面还包含了每个权值的值分布展示按钮。当点击此按钮时,它将创建权值的值的直方图。

图 8.5。正则化和未正则化情况下卷积核的值的分布。A 和 B 两幅图分别展示了经过/未经过 L2 正则化的 MLPs 的结果。该可视化结果基于 tfvis.show.layer() 函数生成。请注意两个直方图的 x 轴比例不同。

通过比较两个 MLPs 的绘图,可以看到明显差异:使用 L2 正则化的情况下,卷积核的值分布范围要比未经正则化的情况窄得多。这也反映在最小值和最大值(第一行)以及值的直方图中。这就是正则化的作用!

为什么较小的核值会导致减少过拟合和改善泛化呢?理解这一点的直观方法是 L2 正则化强制执行奥卡姆剃刀原则。一般来说,权重参数中的较大幅度倾向于导致模型拟合到它看到的训练特征中的细微细节,而较小幅度则倾向于让模型忽略这些细节。在极端情况下,核值为零意味着模型根本不关注其对应的输入特征。L2 正则化鼓励模型通过避免大幅度的权重值来更“经济地”运行,并且仅在值得成本的情况下保留这些值(当减少目标预测不匹配项的损失超过正则化损失时)。

L2 正则化只是机器学习从业者工具库中针对过拟合的其中一种武器。在第四章中,我们展示了辍学层的强大威力。辍学是一种对抗过拟合的强大措施。它同样帮助我们减少了这个温度预测问题中的过拟合。你可以通过在演示 UI 中选择带有辍学的 MLP 模型类型来自己看到这一点。辍学启用的 MLP 所获得的训练质量与 L2 正则化的 MLP 相媲美。当我们将其应用于 MNIST 卷积网络时,我们在第 4.3.2 节讨论了辍学是如何以及为什么起作用的,因此我们在这里不再赘述。然而,表 8.1 提供了对抗过拟合最常用的快速概述。它包括了每种方法如何工作的直观描述以及 TensorFlow.js 中对应的 API。对于特定问题使用哪种对抗过拟合的方法的问题通常通过以下两种方式回答:1)遵循解决类似问题的成熟模型;2)将对抗过拟合方法视为一个超参数,并通过超参数优化来搜索它(第 3.1.2 节)。此外,每种减少过拟合的方法本身都包含可调参数,这些参数也可以通过超参数优化确定(参见表 8.1 的最后一列)。

表 8.1. TensorFlow.js 中常用的减少过拟合方法概览
方法名称方法如何工作TensorFlow.js 中的对应 API主要自由参数
L2 正则化器通过计算权重的参数值的平方和来对权重分配正的损失(惩罚)。它鼓励权重具有较小的参数值。tf.regularizers.l2() 例如,见“使用权重正则化减少过拟合”部分。L2-正则化率
L1 正则化器类似于 L2 正则化器,鼓励权重参数更小。但是,它对权重的损失基于参数的绝对值之和,而不是平方和。这种正则化损失的定义导致更多的权重参数变为零(即“更稀疏的权重”)。tf.regularizers.l1()L1 正则化率
组合 L1-L2 正则化器L1 和 L2 正则化损失的加权和。tf.regularizers.l1l2()L1 正则化率 L2 正则化率
丢弃在训练过程中随机将一部分输入设为零(但在推断过程中不设为零),以打破在训练过程中出现的权重参数之间的虚假相关性(或“阴谋”)。tf.layers.dropout() 例如,请参阅 4.3.2 节。丢弃率
批量归一化在训练过程中学习其输入值的均值和标准差,并使用所学统计数据将输入归一化为零均值和单位标准差。tf.layers.batchNormalization()各种(参见 js.tensorflow.org/api/latest/#layers.batchNormalization
基于验证集损失的早期停止训练当验证集上的每个周期结束时损失值不再减少时,停止模型训练。tf.callbacks.earlyStopping()minDelta:忽略更改的阈值 patience:最多容忍连续几个周期的无改善

在本节关于可视化欠拟合和过拟合的总结中,我们提供了一个简略图表,以快速判断这些状态(图 8.6)。如面板 A 所示,欠拟合是指模型达到次优(高)损失值的状态,无论是在训练集还是验证集上。在面板 B 中,我们看到了典型的过拟合模式,其中训练损失看起来相当令人满意(低),但是验证损失较差(更高)。即使训练集损失继续下降,验证损失也可能趋于平稳甚至上升。面板 C 是我们想要达到的状态,即损失值在训练集和验证集之间没有太大差异,以便最终验证损失较低。请注意,术语“足够低”可以是相对的,特别是对于现有模型无法完美解决的问题。未来可能会推出新模型,并降低相对于面板 C 的可达损失。在那时,面板 C 中的模式将变为欠拟合的情况,我们将需要采用新的模型类型来解决它,可能需要再次经历过拟合和正则化的周期。

图 8.6. 示意图显示了模型训练中欠拟合(面板 A)、过拟合(面板 B)和适度拟合(面板 C)的损失曲线。

最后,请注意,对训练的可视化不仅限于损失。其他指标通常也被可视化以帮助监视训练过程。本书中随处可见此类示例。例如,在第三章中,我们在训练二元分类器以识别网络钓鱼网站时绘制了 ROC 曲线。我们还在训练 iris 花分类器时绘制了混淆矩阵。在第九章中,我们将展示一个在训练文本生成器时显示机器生成文本的示例。该示例不涉及 GUI,但仍会提供关于模型训练状态的有用和直观的实时信息。具体来说,通过查看模型生成的文本,你可以直观地了解当前模型生成的文本质量如何。

8.3. 机器学习的通用工作流程

到目前为止,你已经了解了设计和训练机器学习模型的所有重要步骤,包括获取、格式化、可视化和摄取数据;为数据集选择适当的模型拓扑和损失函数;以及训练模型。你还看到了在训练过程中可能出现的一些最重要的失败模式:欠拟合和过拟合。因此,现在是我们回顾一下迄今为止学到的东西,并思考不同数据集的机器学习模型过程中的共同之处的好时机。结果抽象化就是我们所说的机器学习的通用工作流程。我们将逐步列出工作流程,并扩展每个步骤中的关键考虑因素:

  1. 确定机器学习是否是正确的方法。首先,考虑一下机器学习是否是解决你的问题的正确方法,只有当答案是肯定的时候才继续下一步。在某些情况下,非机器学习方法同样有效,甚至可能成本更低。例如,通过足够的模型调整工作,你可以训练一个神经网络来“预测”两个整数的和,将整数作为文本输入数据(例如,在 tfjs-examples 仓库中的 addition-rnn 示例)。但这远非是这个问题的最有效或最可靠的解决方案:在这种情况下,CPU 上的传统加法运算就足够了。

  2. 定义机器学习问题及你尝试使用数据预测什么。在这一步中,你需要回答两个问题:

    • 有哪些数据可用? 在监督学习中,只有当有标记的训练数据可用时,你才能学习预测某些东西。例如,我们在本章前面看到的天气预测模型之所以可能,仅仅是因为有了 Jena-weather 数据集。数据的可用性通常是这一阶段的限制因素。如果可用数据不足,你可能需要收集更多数据或者雇人手动标记未标记的数据集。

    • 你面临的是什么类型的问题? 是二元分类、多类分类、回归还是其他?识别问题类型将指导你选择模型架构、损失函数等。在你知道输入和输出以及将使用的数据之前,你不能进入下一步。在这个阶段,要注意你隐含假设的假设:

    • 你假设在给定输入的情况下可以预测输出(仅凭输入就包含了足够的信息,使模型能够预测该问题中所有可能的示例的输出)。

    • 你假设可用的数据足以让模型学习这种输入输出关系。在你有一个可用的模型之前,这些只是等待验证或无效化的假设。并非所有问题都是可解的:仅仅因为你组装了一个大型标记数据集,从 X 到 Y 的映射并不意味着 X 包含足够的信息来推断 Y 的值。例如,如果你试图根据股票的历史价格来预测股票的未来价格,你可能会失败,因为价格历史并不包含足够的有关未来价格的预测信息。你应该意识到一个不可解问题类别是 非平稳 问题,即输入输出关系随时间变化。假设你正在尝试构建一个服装推荐引擎(根据用户的服装购买历史),并且你正在使用一年的数据来训练你的模型。这里的主要问题是人们对服装的品味随时间而改变。在去年验证数据上准确工作的模型不一定今年同样准确。请记住,机器学习只能用于学习训练数据中存在的模式。在这种情况下,获取最新的数据并持续训练新模型将是一个可行的解决方案。

  3. 确定一种可靠地衡量训练模型在目标上成功的方法。对于简单的任务,这可能仅仅是预测准确性、精确率和召回率,或者 ROC 曲线和 AUC 值(参见第三章)。但在许多情况下,它将需要更复杂的领域特定指标,如客户保留率和销售额,这些指标与更高级别的目标(如业务的成功)更加一致。

  4. 准备评估过程。设计您将用于评估模型的验证过程。特别是,您应将数据分为三组同质但不重叠的集合:训练集、验证集和测试集。验证集和测试集的标签不应泄漏到训练数据中。例如,对于时间预测,验证和测试数据应来自训练数据之后的时间间隔。您的数据预处理代码应该由测试覆盖以防止错误。

  5. 将数据向量化。将数据转换为张量,也称为n维数组,这是机器学习模型在诸如 TensorFlow.js 和 TensorFlow 等框架中的通用语言。注意以下有关数据向量化的准则:

    • 张量取值通常应缩放为小而居中的值:例如,在[-1, 1][0, 1]区间内。

    • 如果不同特征(例如温度和风速)具有不同范围的值(异构数据),那么数据应该被归一化,通常是针对每个特征进行零均值和单位标准差的 z 归一化。一旦您的输入数据张量和目标(输出)数据准备好了,您就可以开始开发模型。

  6. 开发一个能击败常识基准线的模型。开发一个能击败非机器学习基准线的模型(例如对于回归问题,预测人口平均值,或者对于时间序列预测问题,预测最后一个数据点),从而证明机器学习确实可以为您的解决方案增加价值。这可能并不总是事实(参见步骤 1)。假设事情进展顺利,您需要做出三个关键选择来构建您的第一个击败基准线的机器学习模型:

    • 最后一层激活——这为模型的输出建立了有用的约束条件。该激活应适合您正在解决的问题类型。例如,本书的第三章中的网络钓鱼网站分类器使用了 Sigmoid 激活作为其最后(输出)层,因为该问题具有二分类的性质;而本章的温度预测模型使用了线性激活作为层的激活,因为该问题是回归问题。

    • 损失函数——与最后一层激活类似,损失函数应与您正在解决的问题相匹配。例如,对于二分类问题使用binaryCrossentropy,对于多类分类问题使用categoricalCrossentropy,对于回归问题使用meanSquaredError

    • 优化器配置——优化器是推动神经网络权重更新的驱动器。应该使用什么类型的优化器?其学习率应该是多少?这些通常是由超参数调整回答的问题。但在大多数情况下,您可以安全地从rmsprop优化器及其默认学习率开始。

  7. 开发一个具有足够容量且过拟合训练数据的模型。通过手动更改超参数逐渐扩展您的模型架构。您希望达到一个过拟合训练集的模型。请记住,监督机器学习中的通用和核心紧张关系在于优化(适合训练期间看到的数据)和泛化(能够为未看到的数据进行准确预测)。理想的模型是一个恰好位于欠拟合和过拟合之间的模型:即,在容量不足和容量过大之间。要弄清楚这个边界在哪里,您必须首先越过它。为了越过它,您必须开发一个过拟合的模型。这通常相当容易。你可能

    • 添加更多层

    • 使每一层更大

    • 为模型训练更多的 epochs。始终使用可视化来监视训练和验证损失,以及您关心的任何其他指标(例如 AUC)在训练和验证集上。当您看到验证集上模型的准确性开始下降(图 8.6,面板 B)时,您已经达到了过拟合。

  8. 为模型添加正则化并调整超参数。下一步是为模型添加正则化,并进一步调整其超参数(通常以自动方式),以尽可能接近既不欠拟合也不过拟合的理想模型。这一步将花费最多的时间,即使它可以自动化。您将反复修改模型,训练它,在验证集上评估它(此时不是测试集),再次修改它,然后重复,直到模型尽可能好。在正则化方面应尝试以下事项:

    • 添加具有不同 dropout 率的 dropout 层。

    • 尝试 L1 和/或 L2 正则化。

    • 尝试不同的架构:增加或减少少量层。

    • 更改其他超参数(例如,密集层的单位数)。在调整超参数时要注意验证集的过拟合。因为超参数是根据验证集的性能确定的,它们的值将对验证集过于专门化,因此可能不会很好地推广到其他数据。测试集的目的是在超参数调整后获得模型准确性的无偏估计。因此,在调整超参数时不应使用测试集。

这是机器学习的通用工作流程!在第十二章中,我们将为其添加两个更具实践性的步骤(评估步骤和部署步骤)。但是现在,这是一个从模糊定义的机器学习想法到训练完毕并准备好进行一些有用预测的模型的配方。

有了这些基础知识,我们将开始在本书的后续部分探索更高级的神经网络类型。我们将从第九章中设计用于序列数据的模型开始。

练习

  1. 在温度预测问题中,我们发现线性回归器明显欠拟合了数据,并在训练集和验证集上产生了较差的预测结果。将 L2 正则化添加到线性回归器是否有助于提高这种欠拟合模型的准确性?你可以通过修改文件 jena-weather/models.js 中的buildLinearRegressionModel()函数自行尝试。

  2. 在 Jena-weather 示例中预测第二天的温度时,我们使用了 10 天的回溯期来生成输入特征。一个自然的问题是,如果我们使用更长的回溯期会怎样?包含更多数据是否会帮助我们获得更准确的预测?你可以通过修改 jena-weather/index.js 中的const lookBack并在浏览器中运行训练(例如,使用具有 L2 正则化的 MLP)来找出答案。当然,更长的回溯期会增加输入特征的大小并导致更长的训练时间。因此,问题的另一面是,我们是否可以使用更短的回溯期而不明显牺牲预测准确性?也试试这个。

摘要

  • tfjs-vis 可以在浏览器中辅助可视化机器学习模型的训练过程。具体来说,我们展示了 tfjs-vis 如何用于

    • 可视化 TensorFlow.js 模型的拓扑结构。

    • 绘制训练过程中的损失和指标曲线。

    • 在训练后总结权重分布。我们展示了这些可视化工作流程的具体示例。

  • 欠拟合和过拟合是机器学习模型的基本行为,应该在每一个机器学习问题中进行监控和理解。它们都可以通过比较训练和验证集的损失曲线来观察。内置的tfvis.show.fitCallbacks()方法可以帮助你轻松在浏览器中可视化这些曲线。

  • 机器学习的通用工作流程是不同类型的监督学习任务的一系列常见步骤和最佳实践。它从确定问题的性质和对数据的需求开始,到找到一个恰到好处的模型,位于欠拟合和过拟合之间的边界上。

第十章:序列和文本的深度学习

本章包括

  • 顺序数据与非顺序数据有何不同

  • 哪些深度学习技术适用于涉及序列数据的问题

  • 如何在深度学习中表示文本数据,包括独热编码,多热编码和词嵌入

  • 什么是循环神经网络,以及为什么它们适合于顺序问题

  • 什么是一维卷积,以及为什么它是循环神经网络的一个有吸引力的替代品

  • 序列到序列任务的独特特性以及如何使用注意力机制来解决它们

本章重点介绍涉及序列数据的问题。序列数据的本质是其元素的排序。您可能已经意识到,我们之前已经处理过序列数据。具体来说,我们在第七章介绍的 Jena-weather 数据是序列数据。数据可以表示为数字数组的数组。外部数组的顺序当然很重要,因为测量是随着时间的推移而进行的。如果您改变外部数组的顺序——例如,上升的气压趋势变成下降的气压趋势——如果您尝试预测未来的天气,它就具有完全不同的含义。序列数据无处不在:股票价格,心电图(ECG)读数,软件代码中的字符串,视频的连续帧以及机器人采取的行动序列。将这些与非序列数据相对比,比如第三章中的鸢尾花:如果您改变这四个数字特征(萼片和花瓣的长度和宽度)的顺序并不重要。^([1])

¹

说服自己这确实是事实,练习本章末尾的练习 1。

本章的第一部分将介绍我们在第一章中提到的一种引人入胜的模型——循环神经网络(RNNs),它们专门设计用于从序列数据中学习。我们将理解循环神经网络的特殊特性,以及这些模型敏感于元素的排序和相关信息的直觉。

本章的第二部分将讨论一种特殊的序列数据:文本,这可能是最常见的序列数据(尤其是在网络环境中!)。我们将首先研究深度学习中如何表示文本以及如何在这些表示上应用循环神经网络。然后我们将转向一维卷积神经网络,并讨论它们为何也在处理文本时非常强大,以及它们如何对某些类型的问题是循环神经网络的有吸引力的替代品。

在本章的最后一部分,我们将进一步探讨比预测数字或类别稍微复杂一点的基于序列的任务。特别是,我们将涉及序列到序列的任务,这涉及从输入序列预测输出序列。我们将用一个例子来说明如何使用一种新的模型架构——注意机制来解决基本的序列到序列任务,这在基于深度学习的自然语言处理领域变得越来越重要。

通过本章结束时,您应该熟悉深度学习中常见类型的顺序数据,它们如何呈现为张量,以及如何使用 TensorFlow.js 编写基本的 RNN、1D 卷积网络和注意网络来解决涉及顺序数据的机器学习任务。

本章中您将看到的层和模型是本书中最复杂的。这是它们为顺序学习任务增强容量所付出的代价。即使我们努力以尽可能直观的方式呈现它们,配以图表和伪代码的帮助,您第一次阅读时可能会觉得其中一些很难理解。如果是这样,请尝试运行示例代码并完成章末提供的练习。根据我们的经验,实践经验使得内化复杂概念和架构变得更加容易,就像本章中出现的那些一样。

9.1. 天气预测的第二次尝试:引入 RNN

我们在第八章中为 Jena 天气问题构建的模型丢弃了顺序信息。在本节中,我们将告诉您为什么会这样,并且我们如何通过使用 RNN 将顺序信息带回来。这将使我们能够在温度预测任务中实现更准确的预测。

9.1.1. 为什么密集层无法建模顺序

由于我们在上一章节中已经详细描述了 Jena 天气数据集,所以在这里我们将仅简要讨论数据集和相关的机器学习任务。该任务涉及使用过去 10 天内一段时间内的 14 个天气仪器(如温度、气压和风速)的读数来预测从某一时刻开始的 24 小时后的温度。仪器读数以 10 分钟的固定间隔进行,但我们将其降采样 6 倍,以每小时一次,以便使模型大小和训练时间可管理。因此,每个训练示例都带有一个形状为[240, 14]的特征张量,其中 240 是 10 天内的时间步数,14 是不同天气仪器读数的数量。

在前一章的任务中,当我们尝试了一个线性回归模型和一个 MLP 时,我们使用了tf.layers.flatten层将 2D 输入特征展平为 1D(参见清单 8.2 和图 8.2)。展平步骤是必要的,因为线性回归器和 MLP 都使用了密集层来处理输入数据,而密集层要求每个输入示例的输入数据为 1D。这意味着所有时间步的信息以一种方式混合在一起,使得哪个时间步首先出现,接下来是哪个时间步,一个时间步距离另一个时间步有多远等等的重要性被抹去了。换句话说,当我们将形状为[240, 14]的 2D 张量展平为形状为[3360]的 1D 张量时,我们如何对 240 个时间步进行排序并不重要,只要我们在训练和推断之间保持一致即可。您可以在本章末尾的练习 1 中通过实验证实这一点。但从理论上讲,这种对数据元素顺序缺乏敏感性的缺点可以用以下方式理解。在密集层的核心是一组线性方程,每个方程都将每个输入特征值[x[1],x[2],…,x[n]]与来自核[k[1],k[2],…,k[n]]的可调系数相乘:

方程式 9.1.

图 9.1 提供了密集层的工作原理的可视化表示:从输入元素到层输出的路径在图形上对称,反映了方程式 9.1 中的数学对称性。当我们处理序列数据时,这种对称性是不可取的,因为它使模型对元素之间的顺序视而不见。

图 9.1. 密集层的内部架构。密集层执行的乘法和加法与其输入对称。与简单 RNN 层(图 9.2)相比,它通过引入逐步计算来打破对称性。请注意,我们假设输入只有四个元素,出于简单起见,省略了偏置项。此外,我们仅显示了密集层的一个输出单元的操作。其余的单元被表示为背景中的一堆模糊的框。

实际上,有一个简单的方法可以显示,我们基于密集层的方法(即 MLP,即使加入正则化)并没有很好地解决温度预测问题:将其准确性与我们从常识、非机器学习方法中获得的准确性进行比较。

我们所说的常识方法是什么?将温度预测为输入特征中的最后一个温度读数。简单地说,就假装从现在起 24 小时后的温度会与当前温度相同!这种方法是“直觉上合理”的,因为我们从日常经验中知道,明天的温度往往接近于今天的温度(也就是说,在同一天的同一时间)。这是一个非常简单的算法,并提供了一个合理的猜测,应该能击败所有其他类似简单的算法(例如,将温度预测为 48 小时前的温度)。

我们在 第八章 中使用的 tfjs-examples 的 jena-weather 目录提供了一个命令,用于评估这种常识方法的准确性:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/jena-weather
yarn
yarn train-rnn --modelType baseline

yarn train-rnn 命令调用了 train-rnn.js 脚本,并在基于 Node.js 的后端环境中执行计算。^([2]) 我们将在不久的将来回到这种操作模式,当我们探索 RNN 时。该命令应该会给出以下屏幕输出:

²

实现这种常识、非机器学习方法的代码位于 jena-weather/models.js 中名为 getBaselineMeanAbsoluteError() 的函数中。它使用 Dataset 对象的 forEachAsync() 方法来遍历验证子集的所有批次,计算每个批次的 MAE 损失,并累积所有损失以获得最终损失。

Commonsense baseline mean absolute error: 0.290331

因此,简单的非机器学习方法产生了约为 0.29(以归一化术语表示)的平均绝对预测误差,这与我们从 第八章 中 MLP 获得的最佳验证误差相当(见 图 8.4)。换句话说,MLP,无论是否进行正则化,都无法可靠地击败来自常识基线方法的准确性!

这样的观察在机器学习中并不少见:机器学习并不总是能够击败常识方法。为了击败它,机器学习模型有时需要通过超参数优化进行精心设计或调整。我们的观察还强调了在处理机器学习问题时创建非机器学习基准进行比较的重要性。当然,我们肯定要避免将所有的精力都浪费在构建一个甚至连一个简单且计算成本更低的基线都无法击败的机器学习算法上!我们能够在温度预测问题中击败基线吗?答案是肯定的,我们将依靠 RNN 来做到这一点。现在让我们来看看 RNN 如何捕捉和处理序列顺序。

9.1.2. RNNs 如何建模序列顺序

图 9.2 的 A 面通过使用一个简短的四项序列显示了 RNN 层的内部结构。有几种 RNN 层的变体,图表显示了最简单的变体,称为 SimpleRNN,并且在 TensorFlow.js 中可用作tf.layers.simpleRNN()工厂函数。我们稍后将在本章中讨论更复杂的 RNN 变体,但现在我们将专注于 SimpleRNN。

图 9.2. SimpleRNN 内部结构的“展开”(A 面)和“卷曲”(B 面)表示。卷曲视图(B 面)以更简洁的形式表示与展开视图相同的算法。它以更简洁的方式说明了 SimpleRNN 对输入数据的顺序处理。在面板 B 中的卷曲表示中,从输出(y)返回到模型本身的连接是这些层被称为循环的原因。与图 9.1 中一样,我们仅显示了四个输入元素,并简化了偏差项。

图表显示了输入的时间片段(x[1],x[2],x[3],…)是如何逐步处理的。在每一步中,x[i] 通过一个函数(f())进行处理,该函数表示为图表中心的矩形框。这产生了一个输出(y[i]),它与下一个输入片段(x[i][+1])结合,作为下一步 f() 的输入。重要的是要注意,即使图表显示了四个具有函数定义的单独框,它们实际上表示相同的函数。这个函数(f())称为 RNN 层的cell。在调用 RNN 层期间,它以迭代的方式使用。因此,可以将 RNN 层视为“在for循环中包装的 RNN 单元。”^([3])

³

引述于 Eugene Brevdo。

比较 SimpleRNN 的结构和密集层的结构(图 9.1),我们可以看到两个主要区别:

  • SimpleRNN 逐步处理输入元素(时间步)。这反映了输入的顺序性,这是密集层无法做到的。

  • 在 SimpleRNN 中,每个输入时间步的处理都会生成一个输出(y[i])。前一个时间步的输出(例如,y[1])在处理下一个时间步(例如 x[2])时由层使用。这就是 RNN 名称中“循环”部分的原因:来自先前时间步的输出会流回并成为后续时间步的输入。在诸如 dense、conv2d 和 maxPooling2d 之类的层类型中不会发生递归。这些层不涉及输出信息的回流,因此被称为前馈层。

由于这些独特的特征,SimpleRNN 打破了输入元素之间的对称性。它对输入元素的顺序敏感。如果重新排列顺序输入的元素,则输出将随之而改变。这使 SimpleRNN 与密集层有所区别。

图 9.2 的 B 面板是对简单循环神经网络的更抽象的表示。它被称为 rolled RNN 图表,与 A 面板中的 unrolled 图表相对应,因为它将所有时间步骤“卷”成一个循环。滚动图表很好地对应于编程语言中的 for 循环,这实际上是 TensorFlow.js 中实现 simpleRNN 和其他类型的 RNN 的方式。但是,与其显示真实的代码,不如看一下下面的简单 RNN 的伪代码,您可以将其视为图 9.2 中所示的 simpleRNN 结构的实现。这将帮助您专注于 RNN 层的工作原理的本质。

列表 9.1. simpleRNN 的内部计算的伪代码
y = 0                             ***1***
for x in input_sequence:          ***2***
  y = f(dot(W, x) + dot(U, y))    ***3***
  • 1 y 对应于图 9.2 中的 y。状态在开始时被初始化为零。

  • 2 x 对应于图 9.2 中的 x。for 循环遍历输入序列的所有时间步。

  • 3 W 和 U 分别是输入和状态的权重矩阵(即,回路回传并成为重复输入的输出)。这也是时间步骤 i 的输出成为时间步骤 i + 1 的状态(重复输入)的地方。

在列表 9.1 中,您可以看到时间步 i 的输出成为下一个时间步(下一个迭代)的“状态”。State 是 RNN 的一个重要概念。这是 RNN“记住”已经看过的输入序列步骤的方式。在 for 循环中,这个记忆状态与未来的输入步骤结合起来,并成为新的记忆状态。这使得 simpleRNN 能够根据之前序列中出现的元素来不同地处理相同的输入元素。这种基于记忆的敏感性是顺序处理的核心。作为一个简单的例子,如果您试图解码莫尔斯电码(由点和短划组成),则短划的含义取决于先前(以及之后)的点和短划的序列。另一个例子,在英语中,单词 last 可以根据之前的单词有完全不同的含义。

SimpleRNN 的命名适当,因为其输出和状态是相同的东西。稍后,我们将探索更复杂和更强大的 RNN 体系结构。其中一些具有输出和状态作为两个单独的东西;其他甚至具有多个状态。

关于 RNN 的另一件值得注意的事情是 for 循环使它们能够处理由任意数量的输入步骤组成的输入序列。这是通过将序列输入展平并将其馈送到密集层中无法完成的,因为密集层只能接受固定的输入形状。

此外,for 循环反映了 RNN 的另一个重要属性:参数共享。我们所说的是,相同的权重参数(WU)在所有时间步中都被使用。另一种选择是对每个时间步使用唯一的 W(和 U)值。这是不可取的,因为 1)它限制了 RNN 可以处理的时间步数,2)它导致可调参数数量的显著增加,这将增加计算量并增加训练期间过拟合的可能性。因此,RNN 层类似于 convnets 中的 conv2d 层,它们使用参数共享来实现高效计算并防止过拟合——尽管循环和 conv2d 层以不同的方式实现参数共享。虽然 conv2d 层利用了沿空间维度的平移不变性,但 RNN 层利用了沿时间维度的平移不变性。

图 9.2 显示了在推断时间(前向传播)中简单 RNN 中发生的情况。它并不显示在训练期间(后向传播)如何更新权重参数(WU)。然而,RNN 的训练遵循我们在 2.2.2 节(图 2.8)中介绍的相同反向传播规则——即从损失开始,回溯操作列表,取其导数,并通过它们累积梯度值。数学上,递归网络上的后向传播基本上与前向传播相同。唯一的区别是 RNN 层的反向传播沿时间倒退,在像 图 9.2 面板 A 中的展开图中。这就是为什么有时将训练 RNN 的过程称为时间反向传播(BPTT)。

SimpleRNN 的实现

关于 simpleRNN 和 RNN 总体的抽象思考已经足够了。现在让我们看看如何创建一个 simpleRNN 层并将其包含在模型对象中,这样我们就可以比以前更准确地预测温度了。清单 9.2 中的代码(从 jena-weather/train-rnn.js 中摘录)就是这样做的。尽管 simpleRNN 层的内部复杂性很高,但模型本身相当简单。它只有两层。第一层是 simpleRNN,配置为具有 32 个单元。第二个是使用默认线性激活生成温度的连续数值预测的密集层。请注意,因为模型以一个 RNN 开始,所以不再需要展平序列输入(与前一章中为同一问题创建 MLPs 时进行比较时)。实际上,如果我们在 simpleRNN 层之前放置一个 flatten 层,将会抛出错误,因为 TensorFlow.js 中的 RNN 层期望它们的输入至少是 3D(包括批处理维度)。

代码清单 9.2 创建用于温度预测问题的基于 simpleRNN 的模型
function buildSimpleRNNModel(inputShape) {
  const model = tf.sequential();
  const rnnUnits = 32;                       ***1***
  model.add(tf.layers.simpleRNN({            ***2***
    units: rnnUnits,
    inputShape
  }));
  model.add(tf.layers.dense({units: 1}));    ***3***
  return model;
}
  • 1 simpleRNN 层的硬编码单元数是通过超参数的手工调整得到的一个很好的值。

  • 2 模型的第一层是一个 simpleRNN 层。不需要对顺序输入进行展平,其形状为 [null, 240, 14]。

  • 3 我们用一个具有单个单元且默认线性激活函数的密集层来结束模型,这适用于回归问题。

要查看 simpleRNN 模型的运行情况,请使用以下命令:

yarn train-rnn --modelType simpleRNN --logDir /tmp/
  jean-weather-simpleRNN-logs

RNN 模型在后端环境中使用 tfjs-node 进行训练。由于基于 BPTT 的 RNN 训练涉及到大量计算,如果在资源受限的浏览器环境中训练相同的模型将会更加困难和缓慢,甚至不可能完成。如果您已经正确设置了 CUDA 环境,您可以在命令中添加 --gpu 标志来进一步提高训练速度。

前一个命令中的 --logDir 标志使得模型训练过程将损失值记录到指定的目录中。可以使用一个名为 TensorBoard 的工具在浏览器中加载并绘制损失曲线。图 9.3 是 TensorBoard 的一个截图。在 JavaScript 代码级别,通过使用指向日志目录的特殊回调函数来配置 tf.LayersModel.fit() 调用来实现这个功能。信息框 9.1 中包含了关于如何实现这一功能的进一步信息。

图 9.3 Jena-temperature-prediction 问题的 simpleRNN 模型的 MAE 损失曲线。该图是 TensorBoard 的一个截图,显示了基于 Node.js 进行的 simpleRNN 模型训练的日志。

使用 TensorBoard 回调函数在 Node.js 中监控长时间的模型训练

在 第八章 中,我们介绍了来自 tfjs-vis 库的回调函数,以帮助您在浏览器中监控 tf.LayersModel.fit() 的调用。然而,tfjs-vis 是仅适用于浏览器的库,不适用于 Node.js。在 tfjs-node(或 tfjs-node-gpu)中,默认情况下,tf.LayersModel.fit() 在终端中以进度条形式呈现,并显示损失和时间指标。虽然这种方式简洁明了而且信息量大,但文字和数字往往不如图形界面直观和吸引人。例如,对于模型训练后期我们经常寻找的损失值的微小变化,使用图表(具有适当的刻度和网格线)要比使用一段文本更容易发现。

幸运的是,一个名为 TensorBoard 的工具可以帮助我们在后端环境中完成这项工作。TensorBoard 最初是为 TensorFlow(Python)设计的,但 tfjs-node 和 tfjs-node-gpu 可以以兼容格式写入数据,这些数据可以被 TensorBoard 处理。要将损失和指标值记录到 TensorBoard 以用于 tf.LayersModel.fit()tf.LayersModel.fitDataset() 的调用中,请按照下列模式操作:

 import * as tf from '@tensorflow/tfjs-node';
// Or '@tensorflow/tfjs-node-gpu'

     // ...
 await model.fit(xs, ys, {
   epochs,
   callbacks: tf.node.tensorBoard('/path/to/my/logdir')
 });

      // Or for fitDataset():
 await model.fitDataset(dataset, {
   epochs,
   batchesPerEpoch,
   callbacks: tf.node.tensorBoard('/path/to/my/logdir')
 });

这些调用会将损失值和在compile()调用期间配置的任何指标写入目录/path/to/my/logdir。要在浏览器中查看日志,

  1. 打开一个单独的终端。

  2. 使用以下命令安装 TensorBoard(如果尚未安装):pip install tensorboard

  3. 启动 TensorBoard 的后端服务器,并指向在回调创建过程中指定的日志目录:tensorboard --logdir /path/to/my/logdir

  4. 在 Web 浏览器中,导航至 TensorBoard 进程显示的 http:// URL。然后,类似于 figures 9.3 和 9.5 中显示的损失和指标图表将出现在 TensorBoard 的美观 Web UI 中。

listing 9.2 创建的 simpleRNN 模型的文本摘要如下:

Layer (type)                 Output shape              Param #
     =================================================================
     simple_rnn_SimpleRNN1 (Simpl [null,32]                 1504
     _________________________________________________________________
     dense_Dense1 (Dense)         [null,1]                  33
     =================================================================
Total params: 1537
     Trainable params: 1537
     Non-trainable params: 0
     _________________________________________________________________

它的权重参数明显少于我们之前使用的 MLP(1,537 与 107,585 相比,减少了 70 倍),但在训练过程中实现了更低的验证 MAE 损失(即更准确的预测)(0.271 与 0.289)。这种对温度预测误差的小但明显的减少突显了基于时间不变性的参数共享的强大力量以及 RNN 在学习诸如我们处理的天气数据之类的序列数据方面的优势。

您可能已经注意到,即使 simpleRNN 涉及相对少量的权重参数,与 MLP 等前馈模型相比,其训练和推断时间要长得多。这是 RNN 的一个主要缺点,即无法在时间步长上并行化操作。这种并行化是不可实现的,因为后续步骤依赖于先前步骤中计算的状态值(参见 figure 9.2 和 listing 9.1 中的伪代码)。如果使用大 O 符号表示,RNN 的前向传递需要 O(n)时间,其中n是输入时间步的数量。后向传递(BPTT)需要另外 O(n)时间。耶拿天气问题的输入未来包含大量(240)时间步,这导致了之前看到的较慢的训练时间。这也是为什么我们在 tfjs-node 而不是在浏览器中训练模型的主要原因。

RNN 的情况与 dense 和 conv2d 等前馈层形成鲜明对比。在这些层中,计算可以在输入元素之间并行化,因为对一个元素的操作不依赖于另一个输入元素的结果。这使得这些前馈层在执行它们的正向和反向传播时可以在 O(n)时间内花费较少的时间(在某些情况下接近 O(1)),借助 GPU 加速。在 section 9.2 中,我们将探索一些更多可并行化的序列建模方法,比如 1D 卷积。然而,熟悉 RNN 仍然是重要的,因为它们对于序列位置是敏感的,而 1D 卷积不是(稍后讨论)。

门控循环单元(GRU):一种更复杂的 RNN 类型

SimpleRNN 并不是 TensorFlow.js 中唯一的循环层。还有两个循环层可用:门控循环单元 (GRU^([4])) 和 LSTM(Long Short-Term Memory 的缩写^([5]))。在大多数实际应用中,你可能会想要使用这两种模型中的一种。SimpleRNN 对于大多数真实问题而言过于简单,尽管其计算成本更低并且其内部机制比 GRU 和 LSTM 更容易理解。但是,简单 RNN 存在一个主要问题:尽管理论上来说,simpleRNN 能够在时间 t 保留对于多个时间步长前的输入信息,但是在实践中,学习这种长期依赖关系非常困难。

Kyunghyun Cho 等人在 2014

Sepp Hochreiter 和 Jürgen Schmidhuber 在 1997 年发表的论文《Long Short-Term Memory》中提出了 LSTM 模型,这篇论文发表在《Neural Computation》杂志的第 9 卷第 8 期上,页码从 1735 至 1780。

这是由于梯度消失问题,这是一种类似于前馈网络深度很深时观察到的效应的影响:随着你向网络中添加越来越多的层,从损失函数向早期层反向传播的梯度大小会越来越小。因此,权重的更新也越来越小,直到网络最终变得无法训练。对于 RNN,大量的时间步骤在此问题中扮演了许多层的角色。GRU 和 LSTM 是为解决梯度消失问题而设计的 RNN,GRU 是两者中更简单的一种。让我们看看 GRU 是如何解决这个问题的。

与 simpleRNN 相比,GRU 具有更复杂的内部结构。图 9.4 显示了 GRU 的内部结构的滚动表示。与 simpleRNN 的相同滚动表示进行比较(图 9.2 的面板 B),它包含了更多的细节。输入 (x) 和输出 / 状态(按照 RNN 文献中的约定称为 h)通过 四个 等式生成新的输出 / 状态。相比之下,simpleRNN 仅涉及 一个 等式。这种复杂性也体现在 清单 9.3 中的伪代码中,可以将其视为 图 9.4 中机制的一种实现。为简单起见,我们省略了伪代码中的偏置项。

图 9.4 门控循环单元(GRU)的滚动表示,一种比 simpleRNN 更复杂、更强大的 RNN 层类型。这是一个滚动表示,与 图 9.2 中的面板 B 相似。请注意,我们为了简单起见,在等式中省略了偏置项。虚线表示了从 GRU 单元的输出 (h) 到下一个时间步的同一单元的反馈连接。

代码清单 9.3 Pseudo-code for a GRU layer
h = 0                                             ***1***
for x_i in input_sequence:                        ***2***
  z = sigmoid(dot(W_z, x) + dot(U_z, h))          ***3***
  r = sigmoid(dot(W_r, x) + dot(W_r, h))          ***4***
  h_prime = tanh(dot(W, x) + dot(r, dot(U, h)))   ***5***
  h = dot(1 - z, h) + dot(z, h_prime)             ***6***
  • 1 这是 图 9.4 中的 h。和 simpleRNN 一样,在最开始状态被初始化为零。

  • 2 这个 for 循环遍历输入序列的所有时间步。

  • 3 z 被称为更新门。

  • 4 r 被称为重置门。

  • 5 h_prime 是当前状态的临时状态。

  • 6 h_prime (当前临时状态) 和 h (上一个状态) 以加权方式结合(z 为权值)形成新状态。

在 GRU 的所有内部细节中,我们要强调两个最重要的方面:

  1. GRU 可以轻松地在许多时间步之间传递信息。这是通过中间量 z 实现的,它被称为更新门。由于更新门的存在,GRU 可以学习以最小的变化在许多时间步内传递相同的状态。特别地,在等式 (1 - z) · h + z · *h’ 中,如果 z 的值为 0,则状态 h 将简单地从当前时间步复制到下一个时间步。这种整体传递的能力对于 GRU 如何解决消失梯度问题至关重要。重置门 z 被计算为输入 x 和当前状态 h 的线性组合,然后经过一个 sigmoid 非线性函数。

  2. 除了更新门 z,GRU 中的另一个“门”被称为所谓的重置门r。像更新门 z 一样,r 被计算为对输入和当前状态 h 的线性组合进行 sigmoid 非线性函数操作。重置门控制有多少当前状态需要“遗忘”。特别地,在等式 tanh(W · x + r · U · h) 中,如果 r 的值变为 0,则当前状态 h 的影响被抹除;如果下游方程中的 (1 - z) 接近零,那么当前状态 h 对下一个状态的影响将被最小化。因此,rz 协同工作,使得 GRU 能够在适当条件下学习忘记历史或其一部分。例如,假设我们试图对电影评论进行正面或负面的分类。评论可能开始说“这部电影相当令人满意”,但评论过了一半后,又写到“然而,这部电影并不像其他基于类似观点的电影那么出色。” 在这一点上,应该大部分地忘记关于初始赞美的记忆,因为应该更多地权衡评论后部分对该评论最终情感分析结果的影响。

所以,这是 GRU 如何工作的一个非常粗糙和高层次的概述。要记住的重要事情是,GRU 的内部结构允许 RNN 学习何时保留旧状态,何时使用来自输入的信息更新状态。这种学习通过可调权重 W[z]U[z]W[r]W[r]WU 的更新体现出来(除了省略的偏置项)。

如果你一开始不明白所有细节,不要担心。归根结底,我们在最后几段中对 GRU 的直观解释并不那么重要。理解 GRU 如何以非常详细的层面处理序列数据并不是人类工程师的工作,就像理解卷积神经网络如何将图像输入转换为输出类别概率的细节并不是人类工程师的工作一样。细节是由神经网络在 RNN 结构数据所描述的假设空间中通过数据驱动的训练过程找到的。

要将 GRU 应用于我们的温度预测问题,我们构建一个包含 GRU 层的 TensorFlow.js 模型。我们用于此的代码(摘自 jena-weather/train-rnn.js)几乎与我们用于简单 RNN 模型的代码(代码清单 9.2)完全相同。唯一的区别是模型的第一层的类型(GRU 对比于简单 RNN)。

代码清单 9.4. 为 Jena 温度预测问题创建一个 GRU 模型
function buildGRUModel(inputShape) {
  const model = tf.sequential();
  const rnnUnits = 32;                      ***1***
  model.add(tf.layers.gru({                 ***2***
    units: rnnUnits,
    inputShape
  }));
  model.add(tf.layers.dense({units: 1}));   ***3***
  return model;
}
  • 1 硬编码的单元数是一个通过超参数手动调整而发现效果良好的数字。

  • 2 模型的第一层是一个 GRU 层。

  • 3 模型以具有单个单元和默认线性激活的密集层结束,用于回归问题。

要开始在 Jena 天气数据集上训练 GRU 模型,请使用

yarn train-rnn --modelType gru

图 9.5 显示了使用 GRU 模型获得的训练和验证损失曲线。它获得了约为 0.266 的最佳验证错误,这超过了我们在上一节中从简单 RNN 模型中获得的结果(0.271)。这反映了相较于简单 RNN,GRU 在学习序列模式方面具有更大的容量。在气象仪器读数中确实隐藏着一些序列模式,这些模式有助于提高温度的预测精度;这些信息被 GRU 捕捉到,但简单 RNN 没有。但这是以更长的训练时间为代价的。例如,在我们的一台机器上,GRU 模型的训练速度为每批 3,000 毫秒,而简单 RNN 的训练速度为每批 950 毫秒^([6])。但如果目标是尽可能准确地预测温度,那么这个代价很可能是值得的。

这些性能数字是从在 CPU 后端运行的 tfjs-node 获得的。如果你使用 tfjs-node-gpu 和 CUDA GPU 后端,你将获得两种模型类型的近似比例的加速。

图 9.5. 在温度预测问题上训练 GRU 模型的损失曲线。将其与简单 RNN 模型的损失曲线进行比较(图 9.3),注意 GRU 模型取得的最佳验证损失的小幅但真实的降低。

9.2. 为文本构建深度学习模型

我们刚刚研究的天气预测问题涉及顺序数值数据。但是最普遍的序列数据可能是文本而不是数字。在像英语这样以字母为基础的语言中,文本可以被视为字符序列或单词序列。这两种方法适用于不同的问题,并且在本节中我们将针对不同的任务使用它们。我们将在接下来的几节中介绍的文本数据的深度学习模型可以执行与文本相关的任务,例如

  • 给一段文本分配情感分数(例如,一个产品评论是积极的还是消极的)

  • 将一段文本按主题分类(例如,一篇新闻文章是关于政治、金融、体育、健康、天气还是其他)

  • 将文本输入转换为文本输出(例如,用于格式标准化或机器翻译)

  • 预测文本的即将出现的部分(例如,移动输入方法的智能建议功能)

此列表只是涉及文本的一小部分有趣的机器学习问题,这些问题在自然语言处理领域进行系统研究。尽管我们在本章中只是浅尝神经网络的自然语言处理技术,但这里介绍的概念和示例应该为你进一步探索提供了一个良好的起点(请参阅本章末尾的“进一步阅读资料”部分)。

请记住,本章中的深度神经网络都不真正理解文本或语言的人类意义。相反,这些模型可以将文本的统计结构映射到特定的目标空间,无论是连续情感分数、多类别分类结果还是新序列。这证明对于解决许多实际的、与文本相关的任务来说,这是足够的。自然语言处理的深度学习只是对字符和单词进行的模式识别,方式与基于深度学习的计算机视觉(第四章)对像素进行的模式识别类似。

在我们深入探讨为文本设计的深度神经网络之前,我们首先需要了解机器学习中的文本是如何表示的。

9.2.1. 机器学习中的文本表示:单热编码和多热编码

到目前为止,在本书中我们遇到的大部分输入数据都是连续的。例如,鸢尾花的花瓣长度在一定范围内连续变化;耶拿气象数据集中的天气仪读数都是实数。这些值可以直接表示为浮点型张量(浮点数)。但是,文本不同。文本数据以字符或单词的字符串形式出现,而不是实数。字符和单词是离散的。例如,在“j”和“k”之间没有类似于在 0.13 和 0.14 之间存在数字的东西。在这个意义上,字符和单词类似于多类分类中的类别(例如三种鸢尾花物种或 MobileNet 的 1,000 个输出类别)。文本数据在被馈送到深度学习模型之前需要被转换为向量(数字数组)。这个转换过程称为文本向量化

有多种文本向量化的方式。独热编码(如我们在第三章中介绍的)是其中之一。在英语中,根据划分标准,大约有 10,000 个最常用的单词。我们可以收集这 10,000 个单词并形成一个词汇表。词汇表中的唯一单词可以按照某种顺序排列(例如,按频率降序排列),以便为任何给定的单词分配一个整数索引。^([7]) 然后,每个英文单词都可以表示为一个长度为 10,000 的向量,其中只有对应索引的元素为 1,所有其余元素为 0。这就是该单词的独热向量化。图 9.6 的 A 面以图形方式展示了这一点。

一个显而易见的问题是:如果我们遇到一个落在这 10,000 词汇表之外的罕见单词怎么办?这是任何文本导向的深度学习算法所面临的实际问题。在实践中,我们通过向词汇表添加一个名为OOV的特殊项来解决这个问题。OOV 代表词汇表之外。因此,所有不属于词汇表的罕见单词都被归类为该特殊项,并将具有相同独热编码或嵌入向量。更复杂的技术有多个 OOV 桶,并使用哈希函数将罕见单词分配到这些桶中。

图 9.6. 一个单词的独热编码(向量化)(A 面)和一个句子作为一系列单词的独热编码(B 面)。C 面展示了与 B 面中相同句子的简化的多热编码。它是一种更简洁和可扩展的序列表示,但它丢弃了顺序信息。为了可视化,我们假设词汇表的大小只有 14。实际上,在深度学习中使用的英语单词的词汇量要大得多(数量级为数千或数万,例如,10,000)。

如果我们有一个句子而不是单个单词呢?我们可以为构成句子的所有单词获得独热向量,并将它们放在一起形成句子单词的二维表示(参见图 9.6 的面板 B)。这种方法简单而明确。它完美地保留了句子中出现的单词及其顺序的信息。^([8]) 然而,当文本变得很长时,向量的大小可能会变得非常大,以至于无法管理。例如,英语句子平均包含约 18 个单词。考虑到我们的词汇量为 10,000,仅表示一个句子就需要 180,000 个数字,这已经比句子本身占用的空间大得多了。更不用说一些与文本相关的问题涉及段落或整篇文章,其中包含更多的单词,会导致表示的大小和计算量急剧增加。

这假设没有 OOV(Out of Vocabulary)词。

解决这个问题的一种方法是将所有单词都包含在一个单一向量中,以便向量中的每个元素表示对应的单词是否出现在文本中。图 9.6 的面板 C 进行了说明。在这种表示中,向量的多个元素可以具有值 1。这就是为什么人们有时将其称为多热编码。多热编码具有固定长度(词汇量的大小),不管文本有多长,因此它解决了大小爆炸的问题。但这是以失去顺序信息为代价的:我们无法从多热向量中得知哪些单词先出现,哪些单词后出现。对于一些问题,这可能是可以接受的;对于其他问题,这是不可接受的。有更复杂的表示方法来解决大小爆炸问题,同时保留顺序信息,我们将在本章后面探讨。但首先,让我们看一个具体的与文本相关的机器学习问题,可以使用多热方法以合理的准确率解决。

9.2.2. 情感分析问题的首次尝试

我们将在第一个例子中使用互联网电影数据库(IMDb)数据集来应用机器学习到文本上。该数据集是 imdb.com 上大约 25,000 条电影评论的集合,每个评论都被标记为积极或消极。机器学习任务是二元分类:即给定的电影评论是积极的还是消极的。数据集是平衡的(50% 积极评论和 50% 消极评论)。正如你从在线评论中所期望的那样,示例的单词长度各不相同。有些评论只有 10 个单词,而另一些则可以长达 2,000 个单词。以下是一个典型评论的例子。此示例被标记为消极。数据集中省略了标点符号。

这部电影中的母亲对她的孩子太粗心了,以至于忽视了,我希望我对她和她的行为不要那么生气,因为否则我会享受这部电影的,她太过分了,我建议你快进到你看到她做的事情结束,还有,有没有人厌倦看到拍得这么黑暗的电影了,观众几乎看不到正在拍摄的东西,所以我们为什么看不到夜视了呢

数据被分为训练集和评估集,当您发出类似于模型训练命令时,它们会自动从网络下载并写入您的 tmp 目录

    git clone https://github.com/tensorflow/tfjs-examples.git
    cd tfjs-examples/sentiment
    yarn
    yarn train multihot

如果您仔细检查 sentiment/data.js,您会发现它下载和读取的数据文件不包含实际的单词作为字符字符串。相反,这些文件中的单词以 32 位整数表示。虽然我们不会详细介绍该文件中的数据加载代码,但值得一提的是它执行了句子的多热向量化的部分,如下一列表所示。

列表 9.5. 从 loadFeatures() 函数对句子进行多热向量化
const buffer = tf.buffer([sequences.length, numWords]);   ***1***
     sequences.forEach((seq, i) => {                      ***2***
  seq.forEach(wordIndex => {                              ***3***
    if (wordIndex !== OOV_INDEX) {                        ***4***
      buffer.set(1, i, wordIndex);                        ***5***
    }
  });
});
  • 1 创建一个 TensorBuffer 而不是一个张量,因为我们将设置其元素值。缓冲区从全零开始。

  • 2 遍历所有例子,每个例子都是一个句子

  • 3 每个序列(句子)都是一个整数数组。

  • 4 跳过多热编码中的词汇表外(OOV)单词

  • 5 将缓冲区中的相应索引设置为 1。请注意,每个索引 i 可能有多个 wordIndex 值设置为 1,因此是多热编码。

多热编码的特征被表示为一个形状为 [numExamples, numWords] 的 2D 张量,其中 numWords 是词汇表的大小(在本例中为 10,000)。这种形状不受各个句子长度的影响,这使得这成为一个简单的向量化范例。从数据文件加载的目标的形状为 [numExamples, 1],包含负面和正面标签,分别表示为 0 和 1。

我们应用于多热数据的模型是一个 MLP。实际上,即使我们想要,由于多热编码丢失了顺序信息,也无法对数据应用 RNN 模型。我们将在下一节讨论基于 RNN 的方法。创建 MLP 模型的代码来自 sentiment/train.js 中的 buildModel() 函数,简化后的代码如下列表所示。

列表 9.6. 为多热编码的 IMDb 电影评论构建 MLP 模型
const model = tf.sequential();
model.add(tf.layers.dense({             ***1***
  units: 16,
  activation: 'relu',
  inputShape: [vocabularySize]          ***2***
}));
model.add(tf.layers.dense({
  units: 16,
  activation: 'relu'
}));
model.add(tf.layers.dense({
  units: 1,
  activation: 'sigmoid'                 ***3***
}));
  • 1 添加两个带有 relu 激活的隐藏密集层以增强表示能力

  • 2 输入形状是词汇表的大小,因为我们在这里处理多热向量化。

  • 3 为输出层使用 sigmoid 激活以适应二元分类任务

通过运行yarn train multihot --maxLen 500命令,可以看到模型达到大约 0.89 的最佳验证准确率。这个准确率还可以,明显高于机会的准确率(0.5)。这表明通过仅仅查看评论中出现的单词,可以在这个情感分析问题上获得一个相当合理的准确度。例如,像令人愉快崇高这样的单词与积极的评论相关联,而像糟糕乏味这样的单词与消极的评论相关联,并且具有相对较高的可靠性。当然,在许多情况下,仅仅看单词并不一定能得到正确的结论。举一个人为的例子,理解句子“别误会,我并不完全不同意这是一部优秀的电影”的真实含义需要考虑顺序信息——不仅是单词是什么,还有它们出现的顺序。在接下来的章节中,我们将展示通过使用一个不丢失顺序信息的文本向量化和一个能够利用顺序信息的模型,我们可以超越这个基准准确度。现在让我们看看词嵌入和一维卷积如何工作。

9.2.3. 文本的更高效表示:词嵌入

什么是词嵌入?就像一位热编码(图 9.6)一样,词嵌入是将一个单词表示为一个向量(在 TensorFlow.js 中是一个一维张量)的一种方式。然而,词嵌入允许向量的元素值被训练,而不是依据一个严格的规则进行硬编码,比如一热编码中的单词到索引映射。换句话说,当一个面向文本的神经网络使用词嵌入时,嵌入向量成为模型的可训练的权重参数。它们通过与模型的其他权重参数一样的反向传播规则进行更新。

这种情况在图 9.7 中示意。在 TensorFlow.js 中,可以使用tf.layer.embedding()层类型来执行词嵌入。它包含一个可训练的形状为[vocabularySize, embeddingDims]的权重矩阵,其中vocabularySize是词汇表中唯一单词的数量,embeddingDims是用户选择的嵌入向量的维度。每当给出一个单词,比如the,你可以使用一个单词到索引的查找表在嵌入矩阵中找到对应的行,该行就是你的单词的嵌入向量。请注意,单词到索引的查找表不是嵌入层的一部分;它是模型以外的一个单独的实体(例如,参见示例 9.9)

图 9.7. 描述嵌入矩阵工作原理的示意图。嵌入矩阵的每一行对应词汇表中的一个单词,每一列是一个嵌入维度。嵌入矩阵的元素值在图中以灰度表示,并随机选择。

如果你有一系列单词,就像图 9.7 中显示的句子一样,你需要按照正确的顺序重复这个查找过程,并将得到的嵌入向量堆叠成一个形状为[sequenceLength, embeddingDims]的二维张量,其中sequenceLength是句子中的单词数量。^([9]) 如果句子中有重复的单词(比如在图 9.7 中的例子中的the),这并不重要:只需让相同的嵌入向量在结果的二维张量中重复出现。

这种多词嵌入查找过程可以有效地使用tf.gather()方法进行,这就是 TensorFlow.js 中嵌入层在底层实现的方式。

单词嵌入为我们带来以下好处:

  • 它解决了使用独热编码的大小问题。embeddingDims通常比vocabularySize要小得多。例如,在我们即将在 IMDb 数据集上使用的一维卷积网络中,vocabularySize为 10,000,embeddingDims为 128。因此,在来自 IMDb 数据集的 500 字评论中,表示这个例子只需要 500 * 128 = 64k 个浮点数,而不是 500 * 10,000 = 5M 个数字,就像独热编码一样——这样的向量化更经济。

  • 通过不在乎词汇中单词的排序方式,并允许嵌入矩阵通过反向传播来进行训练,就像所有其他神经网络权重一样,单词嵌入可以学习单词之间的语义关系。意思相近的单词应该在嵌入空间中距离更近。例如,意思相近的单词,比如verytruly,它们的向量应该比那些意思更不同的单词的向量更接近,比如verybarely。为什么会这样?一个直观理解它的方式是意识到以下:假设你用意思相近的单词替换电影评论输入中的一些单词;一个训练良好的网络应该输出相同的分类结果。这只有当每一对单词的嵌入向量,它们是模型后续部分的输入,彼此之间非常接近时才会发生。

  • 也就是说,嵌入空间具有多个维度(例如,128)的事实应该允许嵌入向量捕获单词的不同方面。例如,可能会有一个表示词性的维度,其中形容词fast与另一个形容词(如warm)比与一个名词(如house)更接近。可能还有另一个维度编码单词的性别方面,其中像actress这样的词比一个男性意义的词(如actor)更接近另一个女性意义的词(如queen)。在下一节(见 info box 9.2),我们将向您展示一种可视化单词嵌入并探索它们在对 IMDb 数据集进行嵌入式神经网络训练后出现的有趣结构的方法。

Table 9.1 提供了一个更简洁的总结,概述了一热/多热编码和词嵌入这两种最常用的词向量化范式之间的差异。

Table 9.1. 比较两种词向量化范式:one-hot/multi-hot 编码和词嵌入
One-hot 或 multi-hot 编码词嵌入
硬编码还是学习?硬编码。学习:嵌入矩阵是一个可训练的权重参数;这些值通常在训练后反映出词汇的语义结构。
稀疏还是密集?稀疏:大多数元素为零;一些为一。密集:元素取连续变化的值。
可扩展性不可扩展到大词汇量:向量的大小与词汇量的大小成正比。可扩展到大词汇量:嵌入大小(嵌入维度数)不必随词汇量的增加而增加。
9.2.4. 1D 卷积网络

在 chapter 4,我们展示了 2D 卷积层在深度神经网络中对图像输入的关键作用。conv2d 层学习在图像中的小 2D 补丁中表示局部特征的方法。卷积的思想可以扩展到序列中。由此产生的算法称为1D 卷积,在 TensorFlow.js 中通过tf.layers.conv1d()函数提供。conv1d 和 conv2d 的基本思想是相同的:它们都是可训练的提取平移不变局部特征的工具。例如,一个 conv2d 层在图像任务训练后可能变得对某个方向的特定角落模式和颜色变化敏感,而一个 conv1d 层可能在文本相关任务训练后变得对“一个否定动词后跟一个赞美形容词”的模式敏感。^([10])

¹⁰

正如你可能已经猜到的那样,确实存在 3D 卷积,并且它对涉及 3D(体积)数据的深度学习任务非常有用,例如某些类型的医学图像和地质数据。

图 9.8 详细说明了 conv1d 层的工作原理。回想一下,第四章中的 图 4.3 表明,conv2d 层涉及将一个核在输入图像的所有可能位置上滑动。1D 卷积算法也涉及滑动一个核,但更简单,因为滑动仅在一个维度上发生。在每个滑动位置,都会提取输入张量的一个片段。该片段的长度为 kernelSize(conv1d 层的配置字段),在此示例中,它具有与嵌入维度数量相等的第二个维度。然后,在输入片段和 conv1d 层的核之间执行 (乘法和加法)操作,得到一个输出序列的单个片段。这个操作会在所有有效的滑动位置上重复,直到生成完整的输出。与 conv1d 层的输入张量一样,完整的输出是一个序列,尽管它具有不同的长度(由输入序列长度、kernelSize 和 conv1d 层的其他配置确定)和不同数量的特征维度(由 conv1d 层的 filters 配置确定)。这使得可以堆叠多个 conv1d 层以形成深度的 1D convnet,就像堆叠多个 conv2d 层一样,是 2D convnet 中经常使用的技巧之一。

图 9.8. 示意图说明了 1D 卷积 (tf.layers.conv1d()) 的工作原理。为简单起见,仅显示一个输入示例(图像左侧)。假设输入序列的长度为 12,conv1d 层的核大小为 5。在每个滑动窗口位置,都会提取输入序列的长度为 5 的片段。该片段与 conv1d 层的核进行点乘,生成一个输出序列的滑动。这一过程对所有可能的滑动窗口位置重复进行,从而产生输出序列(图像右侧)。

序列截断和填充

现在我们在文本导向的机器学习中使用 conv1d,准备好在 IMDb 数据上训练 1D convnet 了吗?还不太行。还有一件事要解释:序列的截断和填充。为什么我们需要截断和填充?TensorFlow.js 模型要求 fit() 的输入是一个张量,而张量必须具有具体的形状。因此,尽管我们的电影评论长度不固定(回想一下,它们在 10 到 2,400 个单词之间变化),但我们必须选择一个特定的长度作为输入特征张量的第二个维度(maxLen),这样输入张量的完整形状就是 [numExamples, maxLen]。在前一节使用多热编码时不存在这样的问题,因为来自多热编码的张量具有不受序列长度影响的第二个张量维度。

选择 maxLen 值的考虑如下:

  • 应该足够长以捕获大多数评论的有用部分。如果我们选择 maxLen 为 20,可能会太短,以至于会剪掉大多数评论的有用部分。

  • 它不应该太大,以至于大多数评论远远短于该长度,因为那将导致内存和计算时间的浪费。

两者的权衡使我们选择了每个评论的最大词数为 500(最大值)作为示例。这在用于训练 1D convnet 的命令中通过 --maxLen 标志指定:

yarn train --maxLen 500 cnn

一旦选择了 maxLen,所有的评论示例都必须被调整为这个特定的长度。特别是,比较长的评论被截断;比较短的评论被填充。这就是函数 padSequences() 做的事情(列表 9.7)。截断长序列有两种方式:切掉开头部分(列表 9.7 中的 'pre' 选项)或结尾部分。这里,我们选择了前一种方法,理由是电影评论的结尾部分更有可能包含与情感相关的信息。类似地,填充短序列到期望的长度有两种方式:在句子之前添加填充字符(PAD_CHAR)(列表 9.7 中的 'pre' 选项)或在句子之后添加。在这里,我们也是任意选择了前一种选项。此列表中的代码来自 sentiment/sequence_utils.js。

列表 9.7. 将文本特征加载的一步截断和填充序列
export function padSequences(
    sequences, maxLen,
         padding = 'pre',
         truncating = 'pre',
          value = PAD_CHAR) {
  return sequences.map(seq => {                       ***1***
    if (seq.length > maxLen) {                        ***2***
      if (truncating === 'pre') {                     ***3***
        seq.splice(0, seq.length - maxLen);
      } else {
        seq.splice(maxLen, seq.length - maxLen);
      }
    }

    if (seq.length < maxLen) {                        ***4***
      const pad = [];
      for (let i = 0; i < maxLen - seq.length; ++i) {
        pad.push(value);                              ***5***
      }
      if (padding === 'pre') {                        ***6***
        seq = pad.concat(seq);
      } else {
        seq = seq.concat(pad);
      }
    }

    return seq;                                       ***7***
  });
}
  • 1 遍历所有的输入序列

  • 2 这个特定序列比指定的长度(maxLen)长:将其截断为该长度。

  • 3 有两种截断序列的方式:切掉开头 (‘pre’) 或结尾

  • 4 序列比指定的长度短:需要填充。

  • 5 生成填充序列

  • 6 与截断类似,填充子长度序列有两种方式:从开头 (‘pre’) 或从后面开始。

  • 7 注意:如果 seq 的长度恰好为 maxLen,则将原样返回。

在 IMDb 数据集上构建并运行 1D convnet

现在我们已经准备好了 1D convnet 的所有组件;让我们把它们放在一起,看看我们是否可以在 IMDb 情感分析任务上获得更高的准确率。列表 9.8 中的代码创建了我们的 1D convnet(从 sentiment/train.js 中摘录,简化了)。在此之后展示了生成的 tf.Model 对象的摘要。

列表 9.8. 构建 IMDb 问题的 1D convnet
const model = tf.sequential();
model.add(tf.layers.embedding({               ***1***
  inputDim: vocabularySize,                   ***2***
  outputDim: embeddingSize,
  inputLength: maxLen
}));
model.add(tf.layers.dropout({rate: 0.5}));    ***3***
model.add(tf.layers.conv1d({                  ***4***
  filters: 250,
  kernelSize: 5,
  strides: 1,
  padding: 'valid',
  activation: 'relu'
}));
model.add(tf.layers.globalMaxPool1d({}));     ***5***
model.add(tf.layers.dense({                   ***6***
       units: 250,                            ***6***
       activation: 'relu'                     ***6***
     }));                                     ***6***
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));

________________________________________________________________
Layer (type)                 Output shape              Param #
=================================================================
embedding_Embedding1 (Embedd [null,500,128]            1280000
_________________________________________________________________
dropout_Dropout1 (Dropout)   [null,500,128]            0
_________________________________________________________________
conv1d_Conv1D1 (Conv1D)      [null,496,250]            160250
_________________________________________________________________
global_max_pooling1d_GlobalM [null,250]                0
_________________________________________________________________
dense_Dense1 (Dense)         [null,250]                62750
_________________________________________________________________
dense_Dense2 (Dense)         [null,1]                  251
=================================================================
Total params: 1503251
Trainable params: 1503251
Non-trainable params: 0
_________________________________________________________________
  • 1 模型以嵌入层开始,它将输入的整数索引转换为相应的词向量。

  • 2 嵌入层需要知道词汇量的大小。否则,它无法确定嵌入矩阵的大小。

  • 3 添加一个 dropout 层以防止过拟合

  • 4 接下来是 conv1D 层。

  • 5 globalMaxPool1d 层通过提取每个过滤器中的最大元素值来折叠时间维度。输出准备好供后续的密集层(MLP)使用。

  • 6 在模型顶部添加了一个两层的 MLP

将 JavaScript 代码和文本摘要一起查看是有帮助的。这里有几个值得注意的地方:

  • 模型的形状为[null, 500],其中null是未确定的批次维度(示例数量),500 是每个评论的最大允许单词长度(maxLen)。输入张量包含截断和填充的整数单词索引序列。

  • 模型的第一层是嵌入层。它将单词索引转换为它们对应的单词向量,导致形状为[null, 500, 128]。正如你所看到的,序列长度(500)得到保留,并且嵌入维度(128)反映在形状的最后一个元素上。

  • 跟在嵌入层后面的层是 conv1d 层——这个模型的核心部分。它配置为具有大小为 5 的卷积核,默认步幅大小为 1,并且采用“valid”填充。因此,沿着序列维度有 500-5+1=496 个可能的滑动位置。这导致输出形状的第二个元素([null, 496, 250])中有一个值为 496。形状的最后一个元素(250)反映了 conv1d 层配置为具有的过滤器数量。

  • 接在 conv1d 层后面的 globalMaxPool1d 层与我们在图像卷积网络中看到的 maxPooling2d 层有些相似。但它进行了更激烈的汇集,将沿着序列维度的所有元素折叠成一个单一的最大值。这导致输出形状为[null, 250]

  • 现在张量具有 1D 形状(忽略批次维度),我们可以在其上构建两个密集层,形成 MLP 作为整个模型的顶部。

用命令yarn train --maxLen 500 cnn开始训练 1D 卷积网络。经过两到三个训练周期后,你会看到模型达到了约 0.903 的最佳验证准确率,相对于基于多热编码的 MLP 得到的准确率(0.890),这是一个小但坚实的提升。这反映了我们的 1D 卷积网络设法学习到的顺序信息,而这是多热编码 MLP 无法学习到的。

那么 1D 卷积网络如何捕捉顺序信息呢?它通过其卷积核来实现。卷积核的点积对元素的顺序敏感。例如,如果输入由五个单词组成,I like it so much,1D 卷积将输出一个特定的值;然而,如果单词的顺序改变为much so I like it,尽管元素集合完全相同,但 1D 卷积的输出将不同。

但需要指出的是,一维卷积层本身无法学习超出其核大小的连续模式。 例如,假设两个远离的单词的顺序影响句子的含义; 具有小于距离的核大小的 conv1d 层将无法学习长距离交互。 这是 RNN(如 GRU 和 LSTM)在一维卷积方面优于的方面之一。

一种一维卷积可以改善这一缺点的方法是深入研究-即,堆叠多个 conv1d 层,以便较高级别的 conv1d 层的“接受场”足够大,以捕获这种长距离依赖关系。 然而,在许多与文本相关的机器学习问题中,这种长距离依赖关系并不起重要作用,因此使用少量 conv1d 层的一维卷积网络就足够了。 在 IMDb 情感示例中,您可以尝试根据相同的 maxLen 值和嵌入维度训练基于 LSTM 的模型:

yarn train --maxLen 500 lstm

注意,LSTM 的最佳验证准确度(类似于但略为复杂于 GRU;请参见 figure 9.4)与一维卷积网络的最佳验证准确度大致相同。 这可能是因为长距离的单词和短语之间的相互作用对于这些电影评论和情感分类任务并不太重要。

因此,您可以看到一维卷积网络是这种文本问题的一种有吸引力的替代选择,而不是 RNN。 这在考虑到一维卷积网络的计算成本远低于 RNN 的计算成本时尤为明显。 从 cnnlstm 命令中,您可以看到训练一维卷积网络的速度约为训练 LSTM 模型的六倍。 LSTM 和 RNN 的性能较慢与它们的逐步内部操作有关,这些操作无法并行化; 卷积是可以通过设计进行并行化的。

使用嵌入式投影仪可视化学习到的嵌入向量

使用嵌入式投影仪在嵌入式投影器中使用 t-SNE 维度约减可视化经过训练的一维卷积网络的词嵌入。

在训练后,一维卷积网络的词嵌入中是否出现了任何有趣的结构? 要找出,请使用 yarn train 命令的可选标志 --embeddingFilesPrefix

yarn train --maxLen 500 cnn --epochs 2 --embeddingFilesPrefix
             /tmp/imdb_embed

此命令将生成两个文件:

  • /tmp/imdb_embed_vectors.tsv-一个包含单词嵌入的数值的制表符分隔值文件。 每一行包含一个单词的嵌入向量。 在我们的情况下,有 10,000 行(我们的词汇量大小),每行包含 128 个数字(我们的嵌入维度)。

  • /tmp/imdb_embed_labels.tsv-一个由与前一个文件中的向量对应的单词标签组成的文件。 每一行是一个单词。

这些文件可以上传到嵌入投影仪(projector.tensorflow.org)进行可视化(见前面的图)。因为我们的嵌入向量驻留在一个高维(128D)空间中,所以需要将它们的维度降低到三个或更少的维度,以便人类能够理解。嵌入投影仪工具提供了两种降维算法:t-分布随机邻域嵌入(t-SNE)和主成分分析(PCA),我们不会详细讨论。但简要地说,这些方法将高维嵌入向量映射到 3D,同时确保向量之间的关系损失最小。t-SNE 是两者中更复杂、计算更密集的方法。它产生的可视化效果如图所示。

每个点云中的点对应我们词汇表中的一个单词。将鼠标光标移动到点上方,悬停在点上以查看它们对应的单词。我们在较小的情感分析数据集上训练的嵌入向量已经显示出与单词语义相关的一些有趣结构。特别是,点云的一端包含许多在积极的电影评论中经常出现的词语(例如优秀鼓舞人心令人愉快),而另一端则包含许多听起来消极的词语(糟糕恶心自命不凡)。在更大的文本数据集上训练更大的模型可能会出现更有趣的结构,但是这个小例子已经给你一些关于词嵌入方法的威力的暗示。

因为词嵌入是文本导向的深度神经网络的重要组成部分,研究人员创建了预训练词嵌入,机器学习从业者可以直接使用,无需像我们在 IMDb 卷积神经网络示例中那样训练自己的词嵌入。最著名的预训练词嵌入集之一是斯坦福自然语言处理组的 GloVe(全局向量)(参见nlp.stanford.edu/projects/glove/)。

使用预训练的词嵌入(如 GloVe)的优势是双重的。首先,它减少了训练过程中的计算量,因为嵌入层不需要进一步训练,因此可以直接冻结。其次,像 GloVe 这样的预训练嵌入是从数十亿个单词中训练出来的,因此质量比在小数据集上训练可能得到的要高得多,比如这里的 IMDb 数据集。从这些意义上讲,预训练词嵌入在自然语言处理问题中的作用类似于在计算机视觉中所见到的预训练深度卷积基(例如 MobileNet,在第五章中见过)在计算机视觉中的作用。

在网页中使用 1D 卷积神经网络进行推理

在 sentiment/index.js 中,你可以找到部署在 Node.js 中训练的模型以在客户端使用的代码。要查看客户端应用程序的运行情况,请运行命令 yarn watch,就像本书中的大多数其他示例一样。该命令将编译代码,启动一个 web 服务器,并自动打开一个浏览器选项卡以显示 index.html 页面。在页面中,你可以点击一个按钮通过 HTTP 请求加载训练好的模型,并在文本框中执行情感分析。文本框中的电影评论示例可编辑,因此你可以对其进行任意编辑,并观察实时观察到这如何影响二进制预测。页面带有两个示例评论(一个积极的评论和一个消极的评论),你可以将其用作你调试的起点。加载的 1D convnet 运行速度足够快,可以在你在文本框中输入时实时生成情感分数。

推断代码的核心很简单(参见 列表 9.9,来自 sentiment/index.js),但有几个有趣的地方值得指出:

  • 该代码将所有输入文本转换为小写,丢弃标点符号,并在将文本转换为单词索引之前删除额外的空白。这是因为我们使用的词汇表只包含小写单词。

  • 超出词汇表的词汇——即词汇表之外的词汇——用特殊的单词索引(OOV_INDEX)表示。这些词汇包括罕见的词汇和拼写错误。

  • 我们在训练中使用的相同 padSequences() 函数(参见 列表 9.7)在此处用于确保输入到模型的张量具有正确的长度。通过截断和填充来实现这一点,正如我们之前所见。这是使用 TensorFlow.js 进行像这样的机器学习任务的一个好处的一个例子:你可以在后端训练环境和前端服务环境中使用相同的数据预处理代码,从而减少数据偏差的风险(有关数据偏差风险的更深入讨论,请参见 第六章)。

列表 9.9. 在前端使用训练好的 1D convnet 进行推断
predict(text) {
  const inputText =                                                     ***1***
      text.trim().toLowerCase().replace(/(\.|\,|\!)/g, '').split(' ');  ***1***
  const sequence = inputText.map(word => {
    let wordIndex =                                                     ***2***
             this.wordIndex[word] + this.indexFrom;                     ***2***
    if (wordIndex > this.vocabularySize) {
      wordIndex = OOV_INDEX;                                            ***3***
    }
    return wordIndex;
  });
  const paddedSequence =                                                ***4***
           padSequences([sequence], this.maxLen);                       ***4***
  const input = tf.tensor2d(                                            ***5***
           paddedSequence, [1, this.maxLen]);                           ***5***
  const beginMs = performance.now();                                    ***6***
  const predictOut = this.model.predict(input);                         ***7***
  const score = predictOut.dataSync()[0];
  predictOut.dispose();
  const endMs = performance.now();

  return {score: score, elapsed: (endMs - beginMs)};
}
  • 1 转换为小写;从输入文本中删除标点符号和额外的空白

  • 2 将所有单词映射到单词索引。this.wordIndex 已从 JSON 文件加载。

  • 3 超出词汇表的单词被表示为特殊的单词索引:OOV_INDEX。

  • 4 截断长评论,并填充短评论到所需长度

  • 5 将数据转换为张量表示,以便馈送到模型中

  • 6 跟踪模型推断所花费的时间

  • 7 实际推断(模型的前向传递)发生在这里。

9.3. 使用注意力机制的序列到序列任务

在 Jena-weather 和 IMDb 情感示例中,我们展示了如何从输入序列中预测单个数字或类别。然而,一些最有趣的序列问题涉及根据输入序列生成输出序列。这些类型的任务被恰当地称为序列到序列(或简称为 seq2seq)任务。seq2seq 任务有很多种,以下列表只是其中的一个小子集:

  • 文本摘要—给定一篇可能包含数万字的文章,生成其简洁摘要(例如,100 字或更少)。

  • 机器翻译—给定一种语言(例如英语)中的一个段落,生成其在另一种语言(例如日语)中的翻译。

  • 自动补全的单词预测—给定句子中的前几个单词,预测它们之后会出现什么单词。这对电子邮件应用程序和搜索引擎 UI 中的自动补全和建议非常有用。

  • 音乐创作—给定一系列音符的前导序列,生成以这些音符开头的旋律。

  • 聊天机器人—给定用户输入的一句话,生成一个满足某种对话目标的回应(例如,某种类型的客户支持或简单地用于娱乐聊天)。

注意力机制^([11])是一种强大且流行的用于 seq2seq 任务的方法。它通常与 RNNs 一起使用。在本节中,我们将展示如何使用注意力和 LSTMs 来解决一个简单的 seq2seq 任务,即将各种日历日期格式转换为标准日期格式。尽管这是一个有意简化的例子,但你从中获得的知识适用于像之前列出的更复杂的 seq2seq 任务。让我们首先制定日期转换问题。

¹¹

参见 Alex Graves,“Generating Sequences with Recurrent Neural Networks,”2013 年 8 月 4 日提交,arxiv.org/abs/1308.0850;以及 Dzmitry Bahdanau,Kyunghyun Cho 和 Yoshua Bengio,“Neural Machine Translation by Jointly Learning to Align and Translate,”2014 年 9 月 1 日提交,arxiv.org/abs/1409.0473

9.3.1. 序列到序列任务的制定

如果你像我们一样,你可能会因为写日历日期的可能方式太多而感到困惑(甚至可能有点恼火),特别是如果你去过不同的国家。有些人喜欢使用月-日-年的顺序,有些人采用日-月-年的顺序,还有些人使用年-月-日的顺序。即使在同一顺序中,对于月份是否写为单词(January)、缩写(Jan)、数字(1)或零填充的两位数字(01),也存在不同的选择。日期的选项包括是否在前面加零以及是否将其写为序数(4th 与 4)。至于年份,你可以写全四位数或只写最后两位数。而且,年、月和日的部分可以用空格、逗号、句点或斜杠连接,或者它们可以在没有任何中间字符的情况下连接在一起!所有这些选项以组合的方式结合在一起,至少产生了几十种写相同日期的方式。

因此,拥有一种算法可以将这些格式的日历日期字符串作为输入,并输出对应的 ISO-8601 格式的日期字符串(例如,2019-02-05)会很好。我们可以通过编写传统程序来非机器学习方式解决这个问题。但考虑到可能的格式数量庞大,这是一项有些繁琐且耗时的任务,结果代码很容易达到数百行。让我们尝试一种深度学习方法——特别是使用基于 LSTM 的注意力编码器-解码器架构。

为了限制本示例的范围,我们从以下示例展示的 18 种常见日期格式开始。请注意,所有这些都是写相同日期的不同方式:

"23Jan2015", "012315", "01/23/15", "1/23/15",
"01/23/2015", "1/23/2015", "23-01-2015", "23-1-2015",
"JAN 23, 15", "Jan 23, 2015", "23.01.2015", "23.1.2015",
"2015.01.23", "2015.1.23", "20150123", "2015/01/23",
"2015-01-23", "2015-1-23"

当然,还有其他日期格式。[12] 但是一旦模型训练和推理的基础奠定,添加对其他格式的支持基本上将是一项重复性的任务。我们把添加更多输入日期格式的部分留给了本章末尾的练习(练习 3)。

¹²

你可能已经注意到的另一件事是,我们使用了一组没有任何歧义的日期格式。如果我们在我们的格式集中同时包含 MM/DD/YYYY 和 DD/MM/YYYY,那么就会有含糊不清的日期字符串:即,无法确定地解释的字符串。例如,字符串“01/02/2019”可以被解释为 2019 年 1 月 2 日或 2019 年 2 月 1 日。

首先,让我们让示例运行起来。就像先前的情感分析示例一样,这个示例包括一个训练部分和一个推理部分。训练部分在后端环境中使用tfjs-nodetfjs-node-gpu运行。要启动训练,请使用以下命令:

    git clone https://github.com/tensorflow/tfjs-examples.git
    cd tfjs-examples/sentiment
    yarn
    yarn train

要使用 CUDA GPU 执行训练,请在yarn train命令中使用--gpu标志:

    yarn train --gpu

默认情况下,训练运行两个时期,这应该足以将损失值接近零并且转换精度接近完美。 在训练作业结束时打印的样本推断结果中,大多数,如果不是全部,结果应该是正确的。 这些推断样本来自与训练集不重叠的测试集。 训练好的模型将保存到相对路径dist/model,并将在基于浏览器的推断阶段使用。 要启动推断 UI,请使用

yarn watch

在弹出的网页中,您可以在输入日期字符串文本框中键入日期,然后按 Enter 键,观察输出日期字符串如何相应更改。 此外,具有不同色调的热图显示了转换期间使用的注意矩阵(请参见图 9.9)。 注意矩阵包含一些有趣的信息,并且是此 seq2seq 模型的核心。 它特别适合人类解释。 您应该通过与之互动来熟悉它。

图 9.9. 基于注意力的编码器-解码器在工作中进行日期转换,底部右侧显示了特定输入-输出对的注意力矩阵

让我们以图 9.9 中显示的结果为例。 模型的输出("2034-07-18")正确地转换了输入日期("JUL 18, 2034")。 注意矩阵的行对应于输入字符("J", "U", "L", " ", 等等),而列对应于输出字符("2", "0", "3", 等等)。 因此,注意矩阵的每个元素指示了在生成相应输出字符时有多少关注力放在相应的输入字符上。 元素的值越高,关注度就越高。 例如,看看最后一行的第四列: 也就是说,对应于最后一个输入字符("4")和第四个输出字符("4")的那个。 根据颜色刻度表,它具有相对较高的值。 这是有道理的,因为输出的年份部分的最后一位数字确实应该主要依赖于输入字符串的年份部分的最后一位数字。 相比之下,该列中的其他元素具有较低的值,这表明输出字符串中字符"4"的生成并未使用来自输入字符串的其他字符的太多信息。 在输出字符串的月份和日期部分也可以看到类似的模式。 鼓励您尝试使用其他输入日期格式,并查看注意矩阵如何变化。

9.3.2. 编码器-解码器架构和注意力机制

本节帮助您了解编码器-解码器架构如何解决 seq2seq 问题以及注意力机制在其中起什么作用的直觉。 机制的深入讨论将与下面的深入研究部分中的代码一起呈现。

到目前为止,我们见过的所有神经网络都输出单个项目。对于回归网络,输出只是一个数字;对于分类网络,它是对可能类别的单个概率分布。但是我们面临的日期转换问题不同:我们不是要预测单个项目,而是需要预测多个项目。具体来说,我们需要准确预测 ISO-8601 日期格式的 10 个字符。我们应该如何使用神经网络实现这一点?

解决方案是创建一个输出序列的网络。特别是,由于输出序列由来自具有确切 11 个项目的“字母表”的离散符号组成,我们让网络的输出张量形状为 3D 形状:[numExamples, OUTPUT_LENGTH, OUTPUT_VOCAB_SIZE]。第一个维度(numExamples)是传统的示例维度,使得像本书中看到的所有其他网络一样可以进行批处理。OUTPUT_LENGTH为 10,即 ISO-8601 格式输出日期字符串的固定长度。OUTPUT_VOCAB_SIZE是输出词汇表的大小(或更准确地说,“输出字母表”),其中包括数字 0 到 9 和连字符(-),以及我们稍后将讨论的一些具有特殊含义的字符。

这样就涵盖了模型的输出。那么模型的输入呢?原来,模型不是一个输入,而是两个输入。模型可以大致分为两部分,编码器和解码器,如图 9.10 所示。模型的第一个输入进入编码器部分。它是输入日期字符串本身,表示为形状为[numExamples, INPUT_LENGTH]的字符索引序列。INPUT_LENGTH是支持的输入日期格式中最大可能的长度(结果为 12)。比该长度短的输入在末尾用零填充。第二个输入进入模型的解码器部分。它是右移一个时间步长的转换结果,形状为[numExamples, OUTPUT_LENGTH]

图 9.10. 编码器-解码器架构如何将输入日期字符串转换为输出字符串。ST是解码器输入和输出的特殊起始标记。面板 A 和 B 分别显示了转换的前两个步骤。在第一个转换步骤之后,生成了输出的第一个字符("2")。在第二步之后,生成了第二个字符("0")。其余步骤遵循相同的模式,因此被省略。

等等,第一个输入是有意义的,因为它是输入日期字符串,但是为什么模型将转换结果作为额外的输入呢?这不是模型的输出吗?关键在于转换结果的偏移。请注意,第二个输入并不完全是转换结果。相反,它是转换结果的时延版本。时延为一步。例如,在训练期间,期望的转换结果是 "2034-07-18",那么模型的第二个输入将是 "<ST>2034-07-1",其中 <ST> 是一个特殊的序列起始符号。这个偏移的输入使解码器能够意识到到目前为止已经生成的输出序列。它使解码器更容易跟踪转换过程中的位置。

这类似于人类说话的方式。当你将一个想法用语言表达出来时,你的心智努力分为两个部分:想法本身和你到目前为止所说的内容。后者对于确保连贯、完整和不重复的言论至关重要。我们的模型以类似的方式工作:为了生成每个输出字符,它使用来自输入日期字符串和到目前为止已生成的输出字符的信息。

在训练阶段,转换结果的时延效果是有效的,因为我们已经知道正确的转换结果是什么。但是在推断过程中它是如何工作的呢?答案可以在 图 9.10 的两个面板中看到:我们逐个生成输出字符。如图的面板 A 所示,我们从将一个 ST 符号置于解码器输入的开头开始。通过一步推断(一个 Model.predict() 调用),我们得到一个新的输出项(面板中的 "2")。然后,这个新的输出项被附加到解码器输入中。然后进行转换的下一步。它在解码器输入中看到了新生成的输出字符 "2"(请参阅 图 9.10 的面板 B)。这一步涉及另一个 Model.predict() 调用,并生成一个新的输出字符("0"),然后再次附加到解码器输入中。这个过程重复,直到达到所需的输出长度(在本例中为 10)。注意,输出不包括 ST 项目,因此可以直接用作整个算法的最终输出。

¹³

实现逐步转换算法的代码是 date-conversion-attention/model.js 中的函数 runSeq2SeqInference()

注意机制的作用

注意机制的作用是使每个输出字符能够“关注”输入序列中的正确字符。例如,输出字符串"2034-07-18""7"部分应关注输入日期字符串的"JUL"部分。这与人类生成语言的方式类似。例如,当我们将语言 A 的句子翻译成语言 B 时,输出句子中的每个单词通常由输入句子中的少数单词确定。

这可能看起来显而易见:很难想象还有什么其他方法可能效果更好。但是,深度学习研究人员在 2014 年至 2015 年左右引入的注意机制的介绍是该领域的重大进展。要理解其历史原因,请查看图 9.10 A 面板中连接编码器框与解码器框的箭头。此箭头表示模型中编码器部分中 LSTM 的最后输出,该输出被传递到模型中解码器部分中的 LSTM 作为其初始状态。回想一下 RNN 的初始状态通常是全零的(例如,我们在 section 9.1.2 中使用的 simpleRNN);但是,TensorFlow.js 允许您将 RNN 的初始状态设置为任何给定形状的张量值。这可以用作向 LSTM 传递上游信息的一种方式。在这种情况下,编码器到解码器的连接使用此机制使解码器 LSTM 能够访问编码的输入序列。

但是,初始状态是将整个输入序列打包成单个向量。事实证明,对于更长且更复杂的序列(例如典型的机器翻译问题中看到的句子),这种表示方式有点太简洁了,解码器无法解压缩。这就是注意机制发挥作用的地方。

注意机制扩展了解码器可用的“视野”。不再仅使用编码器的最终输出,注意机制访问整个编码器输出序列。在转换过程的每一步中,该机制会关注编码器输出序列中特定的时间步,以决定生成什么输出字符。例如,第一次转换步骤可能会关注前两个输入字符,而第二次转换步骤则关注第二个和第三个输入字符,依此类推(见图 9.10 ,其中提供了这种注意矩阵的具体示例)。就像神经网络的所有权重参数一样,注意模型 学习 分配注意力的方式,而不是硬编码策略。这使得模型灵活且强大:它可以根据输入序列本身以及迄今为止在输出序列中生成的内容学习关注输入序列的不同部分。

在不看代码或打开编码器、解码器和注意力机制这些黑盒子的情况下,我们已经尽可能深入地讨论了编码器-解码器机制。如果你觉得这个处理过程对你来说太过高层或太模糊,请阅读下一节,我们将更深入地探讨模型的细节。这对于那些希望更深入了解基于注意力机制的编码器-解码器架构的人来说是值得付出的心智努力。要激励你去阅读它,要意识到相同的架构也支撑着一些系统,比如最先进的机器翻译模型(Google 神经网络机器翻译,或 GNMT),尽管这些生产模型使用了更多层的 LSTM 并且在比我们处理的简单日期转换模型大得多的数据上进行了训练。

9.3.3. 深入理解基于注意力机制的编码器-解码器模型

图 9.11 扩展了图 9.10 中的方框,并提供了它们内部结构的更详细视图。将它与构建模型的代码一起查看最具说明性:date-conversion-attention/model.js中的createModel()函数。接下来我们将逐步介绍代码的重要部分。

图 9.11. 深入理解基于注意力机制的编码器-解码器模型。你可以把这个图像看作是对图 9.10 中概述的编码器-解码器架构的扩展视图,显示了更细粒度的细节。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

首先,我们为编码器和解码器中的嵌入和 LSTM 层定义了一些常量:

  const embeddingDims = 64;
  const lstmUnits = 64;

我们将构建的模型接受两个输入,因此我们必须使用功能模型 API 而不是顺序 API。我们从模型的符号输入开始,分别是编码器输入和解码器输入:

  const encoderInput = tf.input({shape: [inputLength]});
  const decoderInput = tf.input({shape: [outputLength]});

编码器和解码器都对它们各自的输入序列应用了一个嵌入层。编码器的代码看起来像这样

  let encoder = tf.layers.embedding({
    inputDim: inputVocabSize,
    outputDim: embeddingDims,
    inputLength,
    maskZero: true
  }).apply(encoderInput);

这类似于我们在 IMDb 情感问题中使用的嵌入层,但它是对字符而不是单词进行嵌入。这表明嵌入方法并不局限于单词。事实上,它足够灵活,可以应用于任何有限的、离散的集合,比如音乐类型、新闻网站上的文章、一个国家的机场等等。嵌入层的maskZero: true配置指示下游的 LSTM 跳过所有零值的步骤。这样就可以节省在已经结束的序列上的不必要计算。

LSTM 是一种我们尚未详细介绍的 RNN 类型。我们不会在这里讨论其内部结构。简而言之,它类似于 GRU(图 9.4), 通过使得在多个时间步中传递状态变得更容易来解决梯度消失的问题。Chris Olah 的博文“理解 LSTM 网络”,在本章末尾提供了指针在 “进一步阅读资料” 中,对 LSTM 结构和机制进行了出色的评述和可视化。我们的编码器 LSTM 应用在字符嵌入向量上:

  encoder = tf.layers.lstm({
    units: lstmUnits,
    returnSequences: true
  }).apply(encoder);

returnSequences: true 配置使得 LSTM 的输出是输出向量序列,而不是默认的单个向量输出(就像我们在温度预测和情感分析模型中所做的那样)。这一步是下游注意力机制所需的。

跟随编码器 LSTM 的 GetLastTimestepLayer 层是一个自定义定义的层:

  const encoderLast = new GetLastTimestepLayer({
    name: 'encoderLast'
  }).apply(encoder);

它简单地沿着时间维度(第二维度)切片时间序列张量并输出最后一个时间步。这使我们能够将编码器 LSTM 的最终状态发送到解码器 LSTM 作为其初始状态。这种连接是解码器获取有关输入序列信息的方式之一。这在 图 9.11 中用将绿色编码器块中的 h[12] 与蓝色解码器块中的解码器 LSTM 层连接的箭头进行了说明。

代码的解码器部分以类似于编码器的拓扑结构的嵌入层和 LSTM 层开始:

  let decoder = tf.layers.embedding({
    inputDim: outputVocabSize,
    outputDim: embeddingDims,
    inputLength: outputLength,
    maskZero: true
  }).apply(decoderInput);
  decoder = tf.layers.lstm({
    units: lstmUnits,
    returnSequences: true
  }).apply(decoder, {initialState: [encoderLast, encoderLast]});

在代码片段的最后一行,注意编码器的最终状态如何用作解码器的初始状态。如果你想知道为什么在这里的代码的最后一行中重复使用符号张量 encoderLast,那是因为 LSTM 层包含两个状态,不像我们在 simpleRNN 和 GRU 中看到的单状态结构。

解码器更强大的另一种方式是获得输入序列的视图,当然,这是通过注意力机制实现的。注意力是编码器 LSTM 输出和解码器 LSTM 输出的点积(逐元素相乘),然后是 softmax 激活:

  let attention = tf.layers.dot({axes: [2, 2]}).apply([decoder, encoder]);
  attention = tf.layers.activation({
    activation: 'softmax',
    name: 'attention'
  }).apply(attention);

编码器 LSTM 的输出形状为 [null, 12, 64],其中 12 是输入序列的长度,64 是 LSTM 的大小。解码器 LSTM 的输出形状为 [null, 10, 64],其中 10 是输出序列的长度,64 是 LSTM 的大小。在最后一个(LSTM 特征)维度上执行两者的点积,得到 [null, 10, 12] 的形状(即 [null, inputLength, outputLength])。对点积应用 softmax 将值转换为概率分数,保证它们在矩阵的每一列上都是正数且总和为 1。这是我们模型中心的注意力矩阵。其值是早期 图 9.9 中可视化的。

然后,注意力矩阵应用于编码器 LSTM 的序列输出。这是转换过程学习如何在每个步骤上关注输入序列(以其编码形式)中的不同元素的方式。将注意力应用于编码器输出的结果称为上下文

  const context = tf.layers.dot({
    axes: [2, 1],
    name: 'context'
  }).apply([attention, encoder]);

上下文的形状为[null, 10, 64](即[null, outputLength, lstmUnits])。它与解码器的输出连接在一起,解码器的输出形状也为[null, 10, 64]。因此,连接的结果形状为[null, 10, 128]

  const decoderCombinedContext =
      tf.layers.concatenate().apply([context, decoder]);

decoderCombinedContext包含进入模型最终阶段的特征向量,即生成输出字符的阶段。

输出字符使用包含一个隐藏层和一个 softmax 输出层的 MLP 生成:

  let output = tf.layers.timeDistributed({
    layer: tf.layers.dense({
      units: lstmUnits,
      activation: 'tanh'
    })
  }).apply(decoderCombinedContext);
  output = tf.layers.timeDistributed({
    layer: tf.layers.dense({
      units: outputVocabSize,
      activation: 'softmax'
    })
  }).apply(output);

多亏了timeDistributed层,所有步骤共享同一个 MLP。timeDistributed层接受一个层,并在其输入的时间维度(即第二维度)上重复调用它。这将输入特征形状从[null, 10, 128]转换为[null, 10, 13],其中 13 对应于 ISO-8601 日期格式的 11 个可能字符,以及 2 个特殊字符(填充和序列起始)。

所有组件齐备后,我们将它们组装成一个具有两个输入和一个输出的tf.Model对象:

  const model = tf.model({
    inputs: [encoderInput, decoderInput],
    outputs: output
  });

为了准备训练,我们使用分类交叉熵损失函数调用compile()方法。选择这个损失函数是基于转换问题本质上是一个分类问题——在每个时间步,我们从所有可能字符的集合中选择一个字符:

  model.compile({
    loss: 'categoricalCrossentropy',
    optimizer: 'adam'
  });

推理时,对模型的输出张量应用argMax()操作以获取获胜的输出字符。在转换的每一步中,获胜的输出字符都会附加到解码器的输入中,因此下一转换步骤可以使用它(参见图 9.11 右端的箭头)。正如我们之前提到的,这个迭代过程最终产生整个输出序列。

进一步阅读的材料

  • Chris Olah,《理解 LSTM 网络》,博客,2015 年 8 月 27 日,mng.bz/m4Wa

  • Chris Olah 和 Shan Carter,《注意力和增强递归神经网络》,Distill,2016 年 9 月 8 日,distill.pub/2016/augmented-rnns/

  • Andrej Karpathy,《递归神经网络的不合理有效性》,博客,2015 年 5 月 21 日,mng.bz/6wK6

  • Zafarali Ahmed,《如何使用 Keras 可视化您的递归神经网络和注意力》,Medium,2017 年 6 月 29 日,mng.bz/6w2e

  • 在日期转换示例中,我们描述了一种基于argMax()的解码技术。这种方法通常被称为贪婪解码技术,因为它在每一步都提取具有最高概率的输出符号。贪婪解码方法的一个流行替代方案是波束搜索解码,它检查更大范围的可能输出序列,以确定最佳序列。你可以从 Jason Brownlee 的文章“如何为自然语言处理实现波束搜索解码器”中了解更多信息,2018 年 1 月 5 日,machinelearningmastery.com/beam-search-decoder-natural-language-processing/

  • Stephan Raaijmakers,《自然语言处理的深度学习》,Manning Publications,在出版中,www.manning.com/books/deep-learning-for-natural-language-processing

练习

  1. 尝试重新排列各种非连续数据的数据元素顺序。确认这种重新排序对建模的损失指标值(例如准确度)没有影响(超出由权重参数的随机初始化引起的随机波动)。你可以为以下两个问题进行此操作:

    1. 在鸢尾花示例(来自第三章)中,通过修改行来重新排列四个数字特征(花瓣长度、花瓣宽度、萼片长度和萼片宽度)的顺序

        shuffledData.push(data[indices[i]]);
      

      在 tfjs-examples 仓库的 iris/data.js 文件中。特别是,改变data[indices[i]]中四个元素的顺序。这可以通过 JavaScript 数组的slice()concat()方法来完成。请注意,所有示例的重新排列顺序应该是相同的。你可以编写一个 JavaScript 函数来执行重新排序。

    2. 在我们为 Jena 气象问题开发的线性回归器和 MLP 中,尝试重新排列 240 个时间步长14 个数字特征(气象仪器测量)。具体来说,你可以通过修改 jena-weather/data.js 中的nextBatchFn()函数来实现这一点。实现重新排序最容易的地方是

      samples.set(value, j, exampleRow, exampleCol++);
      

      在这里,你可以使用一个执行固定排列的函数将索引exampleRow映射到一个新值,并以类似的方式映射exampleCol

  2. 我们为 IMDb 情感分析构建的 1D 卷积神经网络仅包含一个 conv1d 层(参见清单 9.8)。正如我们讨论的那样,在其上叠加更多的 conv1d 层可能会给我们一个更深的 1D 卷积神经网络,能够捕捉到更长一段单词的顺序信息。在这个练习中,尝试修改 sentiment/train.js 中 buildModel() 函数中的代码。目标是在现有的层之后添加另一个 conv1d 层,重新训练模型,并观察其分类精度是否有所提高。新的 conv1d 层可以使用与现有层相同数量的滤波器和内核大小。此外,请阅读修改后模型的摘要中的输出形状,并确保您理解 filterskernelSize 参数如何影响新 conv1d 层的输出形状。

  3. 在日期转换注意事项示例中,尝试添加更多的输入日期格式。以下是您可以选择的新格式,按照编码难度递增的顺序排序。您也可以自己想出自己的日期格式:

    1. YYYY-MMM-DD 格式:例如,“2012 年 3 月 8 日”或“2012 年 3 月 18 日”。根据单个数字日期是否在前面补零(如 2015/03/12),这实际上可能是两种不同的格式。但是,无论如何填充,此格式的最大长度都小于 12,并且所有可能的字符都已经在 date-conversion-attention/date_format.js 中的 INPUT_VOCAB 中。因此,只需向文件添加一个或两个函数即可,这些函数可以模仿现有函数,例如 dateTupleToMMMSpaceDDSpaceYY()。确保将新函数添加到文件中的 INPUT_FNS 数组中,以便它们可以包含在训练中。作为最佳实践,您还应该为新的日期格式函数添加单元测试到 date-conversion-attention/date_format_test.js 中。

    2. 一个使用序数作为日期部分的格式,比如“3 月 8 日,2012 年”。请注意,这与现有的dateTupleToMMMSpaceDDComma-SpaceYYYY()格式相同,只是日期数字后缀了序数后缀("st""nd""th")。你的新函数应该包括根据日期值确定后缀的逻辑。此外,你需要将date_format_test.js中的INPUT_LENGTH常量修改为一个更大的值,因为此格式中日期字符串的最大可能长度超过了当前值 12。此外,需要将字母"t""h"添加到INPUT_VOCAB中,因为它们不出现在任何三个字母月份字符串中。

    3. 现在考虑一个使用完整的英文月份名称拼写的格式,比如“2012 年 3 月 8 日”。输入日期字符串的最大可能长度是多少?你应该如何相应地更改date_format.js中的INPUT_VOCAB

摘要

  • 由于能够提取和学习事物的顺序信息,循环神经网络(RNN)可以在涉及顺序输入数据的任务中胜过前馈模型(例如 MLP)。我们通过将 simpleRNN 和 GRU 应用于温度预测问题的示例来看到这一点。

  • TensorFlow.js 提供了三种类型的 RNN:simpleRNN、GRU 和 LSTM。后两种类型比 simpleRNN 更复杂,因为它们使用更复杂的内部结构来使得能够在许多时间步骤中保持内存状态,从而缓解了梯度消失问题。GRU 的计算量比 LSTM 小。在大多数实际问题中,您可能希望使用 GRU 和 LSTM。

  • 在构建文本的神经网络时,文本输入首先需要表示为数字向量。这称为文本向量化。文本向量化的最常用方法包括 one-hot 和 multi-hot 编码,以及更强大的嵌入方法。

  • 在词嵌入中,每个单词被表示为一个稀疏向量,其中元素值通过反向传播学习,就像神经网络的所有其他权重参数一样。在 TensorFlow.js 中执行嵌入的函数是tf.layers.embedding()

  • seq2seq 问题与基于序列的回归和分类问题不同,因为它们涉及生成一个新序列作为输出。循环神经网络(RNN)可以与其他类型的层一起用于形成编码器-解码器架构来解决 seq2seq 问题。

  • 在 seq2seq 问题中,注意机制使得输出序列的不同项能够选择性地依赖于输入序列的特定元素。我们演示了如何训练基于注意力的编码器-解码器网络来解决简单的日期转换问题,并在推断过程中可视化注意力矩阵。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值