欢迎光临
我们一直在努力

什么是语言训练仪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 中的代码在同一个坐标轴上创建的两个系列。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/06fig07_alt.jpg

有时您想在同一张图中绘制两条曲线,以显示它们之间的关系(例如相互比较)。您可以使用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 中的代码制作。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig02_alt.jpg

清单 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 生成的包含字符串和数字命名条的条形图

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig03_alt.jpg

代码清单 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 中。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig04_alt.jpg

因此,我们可以认为默认的启发式方法对于我们特定的数据点来说生成了太多的箱。如果箱数较少,那么不太可能会有任何箱是空的。您可以使用配置字段 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 中的代码渲染的热图。它展示了一个涉及三个类别的虚构混淆矩阵。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig05_alt.jpg

列表 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 年时间跨度。注意温度曲线中的年度周期以及春季和夏季期间气压相对其他季节更稳定的轻微倾向。

    https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig06_alt.jpg

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

    https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig07_alt.jpg

  • 对于任何超过一周的时间跨度,我们在将时间序列绘制到屏幕上之前进行降采样。例如,考虑时间跨度为一个月(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)  (normalized)` : series1);
  }
  if (series2 !== 'None')  (normalized)` : series2);
  }
  tfvis.render.linechart({values, series: series}, chartContainer, );
}
  • 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是最靠近输出层的。请注意,出于可视化目的,所有内部激活图像都缩放到相同的大小,尽管由于连续的卷积和池化,后续层的激活具有较小的尺寸(较低的分辨率)。这可以从后续层中的粗略像素模式中看出。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig08_alt.jpg

在内部激活中你可能注意到的第一件事是随着网络的深入,它们与原始输入的差异越来越大。较早的层(例如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)不同的地方。请注意,该图与先前显示的某些模型图有所不同,因为它将权重从模型中分离出来。这是为了突出两组可以通过反向传播更新的量:权重和输入。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig09_alt.jpg

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

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

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

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

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig10_alt.jpg

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

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

在可视化卷积网络的例子中,在 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) );

    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 热图叠加在原始输入图像上。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/07fig11_alt.jpg

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()函数中实现了此图中所示的代码。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig01_alt.jpg

为了生成训练示例的特征,我们在 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, );
  • 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 模型所做的那样

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig02_alt.jpg

清单 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)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig03_alt.jpg

创建了表面后,我们通过将表面传递给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 轴范围略有不同。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig04_alt.jpg

相对于之前的结果,训练损失的四倍以上的减少是由于我们的 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 轴比例不同。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig05_alt.jpg

通过比较两个 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)的损失曲线。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/08fig06_alt.jpg

最后,请注意,对训练的可视化不仅限于损失。其他指标通常也被可视化以帮助监视训练过程。本书中随处可见此类示例。例如,在第三章中,我们在训练二元分类器以识别网络钓鱼网站时绘制了 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.

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09eqa01.jpg

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig01_alt.jpg

实际上,有一个简单的方法可以显示,我们基于密集层的方法(即 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 中一样,我们仅显示了四个输入元素,并简化了偏差项。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig02_alt.jpg

图表显示了输入的时间片段(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 模型训练的日志。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig03_alt.jpg

使用 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) 到下一个时间步的同一单元的反馈连接。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig04_alt.jpg

代码清单 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 模型取得的最佳验证损失的小幅但真实的降低。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig05_alt.jpg

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)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig06_alt.jpg

如果我们有一个句子而不是单个单词呢?我们可以为构成句子的所有单词获得独热向量,并将它们放在一起形成句子单词的二维表示(参见图 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) => 
  });
});
  • 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. 描述嵌入矩阵工作原理的示意图。嵌入矩阵的每一行对应词汇表中的一个单词,每一列是一个嵌入维度。嵌入矩阵的元素值在图中以灰度表示,并随机选择。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig07_alt.jpg

如果你有一系列单词,就像图 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 层的核进行点乘,生成一个输出序列的滑动。这一过程对所有可能的滑动窗口位置重复进行,从而产生输出序列(图像右侧)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig08_alt.jpg

序列截断和填充

现在我们在文本导向的机器学习中使用 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)  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 的性能较慢与它们的逐步内部操作有关,这些操作无法并行化; 卷积是可以通过设计进行并行化的。

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/f0318_01_alt.jpg

使用嵌入式投影仪在嵌入式投影器中使用 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) 
    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. 基于注意力的编码器-解码器在工作中进行日期转换,底部右侧显示了特定输入-输出对的注意力矩阵

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig09_alt.jpg

让我们以图 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")。其余步骤遵循相同的模式,因此被省略。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig10_alt.jpg

等等,第一个输入是有意义的,因为它是输入日期字符串,但是为什么模型将转换结果作为额外的输入呢?这不是模型的输出吗?关键在于转换结果的偏移。请注意,第二个输入并不完全是转换结果。相反,它是转换结果的时延版本。时延为一步。例如,在训练期间,期望的转换结果是 "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 中概述的编码器-解码器架构的扩展视图,显示了更细粒度的细节。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/09fig11_alt.jpg

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

这一章涵盖了

  • 生成深度学习是什么,它的应用以及它与我们迄今看到的深度学习任务有何不同

  • 如何使用 RNN 生成文本

  • 什么是潜在空间以及它如何成为生成新图像的基础,通过变分自编码器示例

  • 生成对抗网络的基础知识

深度神经网络展示了生成看起来或听起来真实的图像、声音和文本的一些令人印象深刻的任务。如今,深度神经网络能够创建高度真实的人脸图像,([1])合成自然音质的语音,([2])以及组织连贯有力的文本,([3])这仅仅是一些成就的名单。这种*生成*模型在许多方面都很有用,包括辅助艺术创作,有条件地修改现有内容,以及增强现有数据集以支持其他深度学习任务。([4])

¹

Tero Karras, Samuli Laine 和 Timo Aila, “一种基于风格的生成对抗网络,” 提交日期:2018 年 12 月 12 日, arxiv.org/abs/1812.04948. 在 thispersondoesnotexist.com/ 查看演示。

²

Aäron van den Oord 和 Sander Dieleman, “WaveNet: 一种用于原始音频的生成模型,” 博客, 2016 年 9 月 8 日, mng.bz/MOrn.

³

“更好的语言模型及其影响”,OpenAI, 2019, openai.com/blog/better-language-models/.

Antreas Antoniou, Amos Storkey 和 Harrison Edwards, “数据增强生成对抗网络,” 提交日期:2017 年 11 月 12 日, arxiv.org/abs/1711.04340.

除了在潜在顾客的自拍照上化妆等实际应用外,生成模型还值得从理论上研究。生成模型和判别模型是机器学习中两种根本不同类型的模型。到目前为止,我们在本书中研究的所有模型都是判别模型。这些模型旨在将输入映射到离散或连续的值,而不关心生成输入的过程。回想一下,我们构建的网络针对钓鱼网站、鸢尾花、MNIST 数字和音频声音的分类器,以及对房价进行回归的模型。相比之下,生成模型旨在数学地模拟不同类别示例生成的过程。但是一旦生成模型学习到这种生成性知识,它也可以执行判别性任务。因此,与判别模型相比,可以说生成模型“更好地理解”数据。

本节介绍了文本和图像的深度生成模型的基础知识。在本章结束时,您应该熟悉基于 RNN 的语言模型、面向图像的自编码器和生成对抗网络的思想。您还应该熟悉这些模型在 TensorFlow.js 中的实现方式,并能够将这些模型应用到您自己的数据集上。

10.1. 使用 LSTM 生成文本

让我们从文本生成开始。为此,我们将使用我们在前一章中介绍的 RNN。虽然您将在这里看到的技术生成文本,但它并不局限于这个特定的输出领域。该技术可以适应生成其他类型的序列,比如音乐——只要能够以合适的方式表示音符,并找到一个足够的训练数据集。[5]类似的思想可以应用于生成素描中的笔画,以便生成漂亮的素描[6],甚至是看起来逼真的汉字[7]。

例如,请参阅 Google 的 Magenta 项目中的 Performance-RNN:magenta.tensorflow.org/performance-rnn

例如,请参阅 David Ha 和 Douglas Eck 的 Sketch-RNN:mng.bz/omyv

David Ha,“Recurrent Net Dreams Up Fake Chinese Characters in Vector Format with TensorFlow”,博客,2015 年 12 月 28 日,mng.bz/nvX4

10.1.1. 下一个字符预测器:生成文本的简单方法

首先,让我们定义文本生成任务。假设我们有一个相当大的文本数据语料库(至少几兆字节)作为训练输入,比如莎士比亚的全部作品(一个非常长的字符串)。我们想要训练一个模型,尽可能地生成看起来训练数据的新文本。这里的关键词当然是“看起来”。现在,让我们满足于不精确地定义“看起来”的含义。在展示方法和结果之后,这个意义将变得更加清晰。

让我们思考如何在深度学习范式中制定这个任务。在前一章节涉及的日期转换示例中,我们看到一个精确格式化的输出序列可以从一个随意格式化的输入序列中生成。那个文本到文本的转换任务有一个明确定义的答案:ISO-8601 格式中的正确日期字符串。然而,这里的文本生成任务似乎不适合这一要求。没有明确的输入序列,并且“正确”的输出并没有明确定义;我们只想生成一些“看起来真实的东西”。我们能做什么呢?

一个解决方案是构建一个模型,预测在一系列字符之后会出现什么字符。这被称为 下一个字符预测。例如,对于在莎士比亚数据集上训练良好的模型,当给定字符串“Love looks not with the eyes, b”作为输入时,应该以高概率预测字符“u”。然而,这只生成一个字符。我们如何使用模型生成一系列字符?为了做到这一点,我们简单地形成一个与之前相同长度的新输入序列,方法是将前一个输入向左移动一个字符,丢弃第一个字符,并将新生成的字符(“u”)粘贴到末尾。在这种情况下,我们的下一个字符预测器的新输入就是“ove looks not with the eyes, bu”。给定这个新的输入序列,模型应该以高概率预测字符“t”。这个过程,如图 10.1 所示,可以重复多次,直到生成所需长度的序列。当然,我们需要一个初始的文本片段作为起点。为此,我们可以从文本语料库中随机抽样。

图 10.1. 用基于 RNN 的下一个字符预测器生成文本序列的示意图,以初始输入文本片段作为种子。在每个步骤中,RNN 使用输入文本预测下一个字符。然后,将输入文本与预测的下一个字符连接起来,丢弃第一个字符。结果形成下一个步骤的输入。在每个步骤中,RNN 输出字符集中所有可能字符的概率分数。为了确定实际的下一个字符,进行随机抽样。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig01a_alt.jpg

这种表述将序列生成任务转化为基于序列的分类问题。这个问题类似于我们在第九章中看到的 IMDb 情感分析问题,其中从固定长度的输入中预测二进制类别。文本生成模型基本上做了同样的事情,尽管它是一个多类别分类问题,涉及到 N 个可能的类别,其中 N 是字符集的大小——即文本数据集中所有唯一字符的数量。

这种下一个字符预测的表述在自然语言处理和计算机科学中有着悠久的历史。信息论先驱克劳德·香农进行了一项实验,在实验中,被要求的人类参与者在看到一小段英文文本后猜测下一个字母。[8] 通过这个实验,他能够估计出在给定上下文的情况下,典型英文文本中每个字母的平均不确定性。这种不确定性约为 1.3 位的熵,告诉我们每个英文字母所携带的平均信息量。

1951 年的原始论文可在mng.bz/5AzB中获取。

当字母以完全随机的方式出现时,1.3 位的结果比如果 26 个字母完全随机出现所需的位数要少,该数值为 log2 = 4.7 位数。这符合我们的直觉,因为我们知道英语字母并不是随机出现的,而是具有某些模式。在更低的层次上,只有某些字母序列是有效的英语单词。在更高的层次上,只有某些单词的排序满足英语语法。在更高的层次上,只有某些语法上有效的句子实际上是有意义的。

如果你考虑一下,这正是我们的文本生成任务的基础所在:学习所有这些层面的模式。注意,我们的模型基本上是被训练来做 Shannon 实验中的那个志愿者所做的事情——也就是猜测下一个字符。现在,让我们来看一下示例代码以及它是如何工作的。请记住 Shannon 的 1.3 位结果,因为我们稍后会回到它。

10.1.2《LSTM-text-generation》示例

在 tfjs-examples 仓库中的 lstm-text-generation 示例中,我们训练了一个基于 LSTM 的下一个字符预测器,并利用它生成了新的文本。训练和生成都在 JavaScript 中使用 TensorFlow.js 完成。你可以在浏览器中或者使用 Node.js 运行示例。前者提供了更加图形化和交互式的界面,但后者具有更快的训练速度。

要在浏览器中查看此示例的运行情况,请使用以下命令:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/lstm-text-generation
yarn && yarn watch

在弹出的页面中,你可以选择并加载四个提供的文本数据集中的一个来训练模型。在下面的讨论中,我们将使用莎士比亚的数据集。一旦数据加载完成,你可以点击“创建模型”按钮为它创建一个模型。一个文本框允许你调整创建的 LSTM 将具有的单元数。它默认设置为 128。但你也可以尝试其他值,例如 64。如果你输入由逗号分隔的多个数字(例如 128,128),则创建的模型将包含多个叠放在一起的 LSTM 层。

若要使用 tfjs-node 或 tfjs-node-gpu 在后端执行训练,请使用 yarn train 命令而不是 yarn watch

yarn train shakespeare 
      --lstmLayerSize 128,128 
      --epochs 120 
      --savePath ./my-shakespeare-model

如果你已经正确地设置了 CUDA-enabled GPU,可以在命令中添加 --gpu 标志,让训练过程在 GPU 上运行,这将进一步加快训练速度。--lstmLayerSize 标志在浏览器版本的示例中起到了 LSTM-size 文本框的作用。前面的命令将创建并训练一个由两个 LSTM 层组成的模型,每个 LSTM 层都有 128 个单元,叠放在一起。

此处正在训练的模型具有堆叠 LSTM 架构。堆叠 LSTM 层是什么意思?在概念上类似于在 MLP 中堆叠多个密集层,这增加了 MLP 的容量。类似地,堆叠多个 LSTM 允许输入序列在被最终 LSTM 层转换为最终回归或分类输出之前经历多个 seq2seq 表示转换阶段。图 10.2 给出了这种架构的图解。一个重要的事情要注意的是,第一个 LSTM 的returnSequence属性被设置为true,因此生成包括输入序列的每个单个项目的输出序列。这使得可以将第一个 LSTM 的输出馈送到第二个 LSTM 中,因为 LSTM 层期望顺序输入而不是单个项目输入。

图 10.2. 在模型中如何堆叠多个 LSTM 层。在这种情况下,两个 LSTM 层被堆叠在一起。第一个 LSTM 的returnSequence属性被设置为true,因此输出一个项目序列。第一个 LSTM 的序列输出被传递给第二个 LSTM 作为其输入。第二个 LSTM 输出一个单独的项目而不是项目序列。单个项目可以是回归预测或 softmax 概率数组,它形成模型的最终输出。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig01.jpg

清单 10.1 包含构建下一个字符预测模型的代码,其架构如图 10.2 所示(摘自 lstm-text-generation/model.js)。请注意,与图表不同,代码包括一个稠密层作为模型的最终输出。密集层具有 softmax 激活。回想一下,softmax 激活将输出归一化,使其值介于 0 和 1 之间,并总和为 1,就像概率分布一样。因此,最终的密集层输出表示唯一字符的预测概率。

createModel() 函数的 lstmLayerSize 参数控制 LSTM 层的数量和每个层的大小。第一个 LSTM 层的输入形状根据 sampleLen(模型一次接收多少个字符)和 charSetSize(文本数据中有多少个唯一字符)进行配置。对于基于浏览器的示例,sampleLen 是硬编码为 40 的;对于基于 Node.js 的训练脚本,可以通过 --sampleLen 标志进行调整。对于莎士比亚数据集,charSetSize 的值为 71。字符集包括大写和小写英文字母、标点符号、空格、换行符和几个其他特殊字符。给定这些参数,清单 10.1 中的函数创建的模型具有输入形状 [40, 71](忽略批处理维度)。该形状对应于 40 个 one-hot 编码字符。模型的输出形状是 [71](同样忽略批处理维度),这是下一个字符的 71 种可能选择的 softmax 概率值。

清单 10.1. 构建一个用于下一个字符预测的多层 LSTM 模型
export function createModel(sampleLen,                    ***1***
                            charSetSize,                  ***2***
                            lstmLayerSizes) 

  const model = tf.sequential();
  for (let i = 0; i < lstmLayerSizes.length; ++i) {
    const lstmLayerSize = lstmLayerSizes[i];
    model.add(tf.layers.lstm({                            ***4***
      units: lstmLayerSize,
      returnSequences: i < lstmLayerSizes.length - 1,     ***5***
      inputShape: i === 0 ?
          [sampleLen, charSetSize] : undefined            ***6***
    }));
  }
  model.add(
      tf.layers.dense({
        units: charSetSize,
        activation: 'softmax'
  }));                                                    ***7***

  return model;
}
  • 1 模型输入序列的长度

  • 2 所有可能的唯一字符的数量

  • 3 模型的 LSTM 层的大小,可以是单个数字或数字数组

  • 4 模型以一堆 LSTM 层开始。

  • 5 设置 returnSequencestrue 以便可以堆叠多个 LSTM 层

  • 6 第一个 LSTM 层是特殊的,因为它需要指定其输入形状。

  • 7 模型以一个密集层结束,其上有一个 softmax 激活函数,适用于所有可能的字符,反映了下一个字符预测问题的分类特性。

为了准备模型进行训练,我们使用分类交叉熵损失对其进行编译,因为该模型本质上是一个 71 路分类器。对于优化器,我们使用 RMSProp,这是递归模型的常用选择:

const optimizer = tf.train.rmsprop(learningRate);
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});

输入模型训练的数据包括输入文本片段和每个片段后面的字符的对,所有这些都编码为 one-hot 向量(参见图 10.1)。在 lstm-text-generation/data.js 中定义的 TextData 类包含从训练文本语料库生成此类张量数据的逻辑。那里的代码有点乏味,但思想很简单:随机从我们的文本语料库中的非常长的字符串中抽取固定长度的片段,并将它们转换为 one-hot 张量表示。

如果您正在使用基于 Web 的演示,页面的“模型训练”部分允许您调整超参数,例如训练时期的数量、每个时期进入的示例数量、学习率等等。单击“训练模型”按钮启动模型训练过程。对于基于 Node.js 的训练,这些超参数可以通过命令行标志进行调整。有关详细信息,您可以通过输入 yarn train --help 命令获取帮助消息。

根据您指定的训练周期数和模型大小,训练时间可能会在几分钟到几个小时之间不等。基于 Node.js 的训练作业在每个训练周期结束后会自动打印模型生成的一些示例文本片段(见 表格 10.1)。随着训练的进行,您应该看到损失值从初始值约为 3.2 不断降低,并在 1.4–1.5 的范围内收敛。大约经过 120 个周期后,损失减小后,生成的文本质量应该会提高,以至于在训练结束时,文本应该看起来有些像莎士比亚的作品,而验证损失应该接近 1.5 左右——并不远离香农实验中的每字符信息不确定性 1.3 比特。但请注意,考虑到我们的训练范式和模型容量,生成的文本永远不会像实际的莎士比亚的写作。

表格 10.1. 基于 LSTM 的下一字符预测模型生成的文本样本。生成基于种子文本。初始种子文本:" “在每小时的关于你的特定繁荣的议会中,和 lo”。^([a]) 根据种子文本后续的实际文本(用于比较):“爱你不会比你的老父亲梅奈尼乌斯对你更差!…”。

^a

摘自 莎士比亚的《科里奥兰纳斯》,第 5 幕,第 2 场。请注意,示例中包括换行和单词中间的停顿(love)。

训练周期 验证损失 T = 0 T = 0.25 T = 0.5 T = 0.75 5 2.44 "rle the the the the the the the the the the the the the the the the the the the the the the the the the the the the the " “te ans and and and and and warl torle an at an yawl and tand and an an ind an an in thall ang ind an tord and and and wa” “te toll nlatese ant ann, tomdenl, teurteeinlndting fall ald antetetell linde ing thathere taod winld mlinl theens tord y” "p, af ane me pfleh; fove this? Iretltard efidestind ants anl het insethou loellr ard, 25 1.96 "ve tray the stanter an truent to the stanter to the stanter to the stanter to the stanter to the stanter to the stanter " “ve to the enter an truint to the surt an truin to me truent me the will tray mane but a bean to the stanter an trust tra” “ve of marter at it not me shank to an him truece preater the beaty atweath and that marient shall me the manst on hath s” “rd; not an an beilloters An bentest the like have bencest on it love gray to dreath avalace the lien I am sach me, m” 50 1.67 “世界的世界的世界的世界的世界的世界的世界的世界的世界的世界的世界” “他们是他们的英语是世界的世界的立场的证明了他们的弦应该世界我” “他们的愤怒的苦恼的,因为你对于你的设备的现在的将会” “是我的光,我将做 vall twell。斯伯” 100 1.61 “越多的人越多,越奇怪的是,越奇怪的是,越多的人越多” “越多的人越多越多” “越多的人越多。为了这样一个内容,” “和他们的 consent,你将会变成三个。长的和一个心脏和不奇怪的。一位 G” 120 1.49 “打击的打击的打击的打击的打击的打击和打击的打击的打击” “亲爱的打击我的排序的打击,打击打击,亲爱的打击和” “为他的兄弟成为这样的嘲笑。一个模仿的” “这是我的灵魂。Monty 诽谤他你的矫正。这是为了他的兄弟,这是愚蠢的”

表格 10.1 展示了在四个不同 温度值 下采样的一些文本,这是一个控制生成文本随机性的参数。在生成文本的样本中,您可能已经注意到,较低的温度值与更多重复和机械化的文本相关联,而较高的值与不可预测的文本相关联。由 Node.js 的训练脚本演示的最高温度值默认为 0.75,有时会导致看起来像英语但实际上不是英语单词的字符序列(例如表格中的“stratter”和“poins”)。在接下来的部分中,我们将探讨温度是如何工作的,以及为什么它被称为温度。

10.1.3. 温度:生成文本中的可调随机性

列表 10.2 中的函数 sample() 负责根据模型在文本生成过程的每一步的输出概率来确定选择哪个字符。正如您所见,该算法有些复杂:它涉及到三个低级 TensorFlow.js 操作的调用:tf.div()tf.log()tf.multinomial()。为什么我们使用这种复杂的算法而不是简单地选择具有最高概率得分的选项,这将需要一个单独的 argMax() 调用呢?

如果我们这样做,文本生成过程的输出将是确定性的。也就是说,如果你多次运行它,它将给出完全相同的输出。到目前为止,我们所见到的深度神经网络都是确定性的,也就是说,给定一个输入张量,输出张量完全由网络的拓扑结构和其权重值决定。如果需要的话,你可以编写一个单元测试来断言其输出值(见第十二章讨论机器学习算法的测试)。对于我们的文本生成任务来说,这种确定性并不理想。毕竟,写作是一个创造性的过程。即使给出相同的种子文本,生成的文本也更有趣些带有一些随机性。这就是tf.multinomial()操作和温度参数有用的地方。tf.multinomial()是随机性的来源,而温度控制着随机性的程度。

列表 10.2。带有温度参数的随机抽样函数
export function sample(probs, temperature) {
  return tf.tidy(() => {
    const logPreds = tf.div(
        tf.log(probs),                                                     ***1***
        Math.max(temperature, 1e-6));                                      ***2***
    const isNormalized = false;
    return tf.multinomial(logPreds, 1, null, isNormalized).dataSync()[0];  ***3***
  });
}
  • 1 模型的密集层输出归一化的概率分数;我们使用 log()将它们转换为未归一化的 logits,然后再除以温度。

  • 2 我们用一个小的正数来防止除以零的错误。除法的结果是调整了不确定性的 logits。

  • 3 tf.multinomial()是一个随机抽样函数。它就像一个多面的骰子,每个面的概率不相等,由 logPreds——经过温度缩放的 logits 来确定。

在列表 10.2 的sample()函数中最重要的部分是以下行:

const logPreds = tf.div(tf.log(probs),
                        Math.max(temperature, 1e-6));

它获取了probs(模型的概率输出)并将它们转换为logPreds,概率的对数乘以一个因子。对数运算(tf.log())和缩放(tf.div())做了什么?我们将通过一个例子来解释。为了简单起见,假设只有三个选择(字符集中的三个字符)。假设我们的下一个字符预测器在给定某个输入序列时产生了以下三个概率分数:

[0.1, 0.7, 0.2]

让我们看看两个不同的温度值如何改变这些概率。首先,让我们看一个相对较低的温度:0.25。缩放后的 logits 是

log([0.1, 0.7, 0.2]) / 0.25 = [-9.2103, -1.4267, -6.4378]

要理解 logits 的含义,我们通过使用 softmax 方程将它们转换回实际的概率分数,这涉及将 logits 的指数和归一化:

exp([-9.2103, -1.4267, -6.4378]) / sum(exp([-9.2103, -1.4267, -6.4378]))
= [0.0004, 0.9930, 0.0066]

正如你所看到的,当温度为 0.25 时,我们的 logits 对应一个高度集中的概率分布,在这个分布中,第二个选择的概率远高于其他两个选择(见图 10.3 的第二面板)。

图 10.3. 不同温度(T)值缩放后的概率得分。较低的 T 值导致分布更集中(更少随机);较高的 T 值导致分布在类别之间更均等(更多随机)。T 值为 1 对应于原始概率(无变化)。请注意,无论 T 的值如何,三个选择的相对排名始终保持不变。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig02_alt.jpg

如果我们使用更高的温度,比如说 0.75,通过重复相同的计算,我们得到

log([0.1, 0.7, 0.2]) / 0.75 = [-3.0701, -0.4756, -2.1459]
exp([-3.0701, -0.4756, -2.1459]) / sum([-3.0701, -0.4756, -2.1459])
= [0.0591, 0.7919 0.1490]

与之前的情况相比,这是一个峰值较低的分布,当温度为 0.25 时(请参阅图 10.3 的第四面板)。但是与原始分布相比,它仍然更尖峭。你可能已经意识到,温度为 1 时,你将得到与原始概率完全相同的结果(图 10.3,第五面板)。大于 1 的温度值会导致选择之间的概率分布更“均等”(图 10.3,第六面板),而选择之间的排名始终保持不变。

这些转换后的概率(或者说它们的对数)然后被馈送到 tf.multinomial() 函数中,该函数的作用类似于一个多面骰子,其面的不等概率由输入参数控制。这给我们了下一个字符的最终选择。

所以,这就是温度参数如何控制生成文本的随机性。术语 temperature 源自热力学,我们知道,温度较高的系统内部混乱程度较高。这个类比在这里是合适的,因为当我们在代码中增加温度值时,生成的文本看起来更加混乱。温度值有一个“甜蜜的中间值”。在此之下,生成的文本看起来太重复和机械化;在此之上,文本看起来太不可预测和古怪。

这结束了我们对文本生成 LSTM 的介绍。请注意,这种方法非常通用,可以应用于许多其他序列,只需进行适当的修改即可。例如,如果在足够大的音乐分数数据集上进行训练,LSTM 可以通过逐步从之前的音符中预测下一个音符来作曲。^([9])

Allen Huang 和 Raymond Wu,“Deep Learning for Music”,2016 年 6 月 15 日提交,arxiv.org/abs/1606.04930

10.2. 变分自动编码器:找到图像的高效和结构化的向量表示

前面的部分为您介绍了如何使用深度学习来生成文本等连续数据。在本章的剩余部分,我们将讨论如何构建神经网络来生成图像。我们将研究两种类型的模型:变分自编码器(VAE)和生成对抗网络(GAN)。与 GAN 相比,VAE 的历史更悠久,结构更简单。因此,它为您进入基于深度学习的图像生成的快速领域提供了很好的入口。

10.2.1. 传统自编码器和 VAE: 基本概念

图 10.4 以示意方式显示了自编码器的整体架构。乍一看,自编码器是一个有趣的模型,因为它的输入和输出模型的图像大小是相同的。在最基本的层面上,自编码器的损失函数是输入和输出之间的均方误差(MSE)。这意味着,如果经过适当训练,自编码器将接受一个图像,并输出一个几乎相同的图像。这种模型到底有什么用呢?

图 10.4. 传统自编码器的架构

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig03_alt.jpg

实际上,自编码器是一种重要的生成模型,而且绝不是无用的。对于前面的问题答案在于小时钟形状的架构(图 10.4)。自编码器的最细部分是一个与输入和输出图像相比具有更少元素的向量。因此,由自编码器执行的图像转换是非平凡的:它首先将输入图像转变为高压缩形式的表示,然后在不使用任何额外信息的情况下从该表示中重新构建图像。中间的有效表示称为潜在向量,或者z-向量。我们将这两个术语互换使用。这些向量所在的向量空间称为潜在空间,或者z-空间。将输入图像转换为潜在向量的自编码器部分称为编码器;将潜在向量转换回图像的后面部分称为解码器

和图像本身相比,潜在向量可以小几百倍,我们很快会通过一个具体的例子进行展示。因此,经过训练的自编码器的编码器部分是一个非常高效的维度约简器。它对输入图像的总结非常简洁,但包含足够重要的信息,以使得解码器可以忠实地复制输入图像,而不需要使用额外的信息。解码器能够做到这一点,这也是非常了不起的。

我们还可以从信息理论的角度来看待自编码器。假设输入和输出图像各包含N比特的信息。从表面上看,N是每个像素的位深度乘以像素数量。相比之下,自编码器中间的潜在向量由于其小的大小(假设为m比特),只能保存极少量的信息。如果m小于N,那么从潜在向量重构出图像就在理论上不可能。然而,图像中的像素不是完全随机的(完全由随机像素组成的图像看起来像静态噪音)。相反,像素遵循某些模式,比如颜色连续性和所描绘的现实世界对象的特征。这导致N的值比基于像素数量和深度的表面计算要小得多。自编码器的任务是学习这种模式;这也是自编码器能够工作的原因。

在自编码器训练完成后,其解码器部分可以单独使用,给定任何潜在向量,它都可以生成符合训练图像的模式和风格的图像。这很好地符合了生成模型的描述。此外,潜在空间将有望包含一些良好的可解释结构。具体而言,潜在空间的每个维度可能与图像的某个有意义的方面相关联。例如,假设我们在人脸图像上训练了一个自编码器,也许潜在空间的某个维度将与微笑程度相关。当你固定潜在向量中所有其他维度的值,仅变化“微笑维度”的值时,解码器产生的图像将是同一张脸,但微笑程度不同(例如,参见图 10.5)。这将使得有趣的应用成为可能,例如在保持所有其他方面不变的情况下,改变输入人脸图像的微笑程度。可以通过以下步骤来完成此操作。首先,通过应用编码器获取输入的潜在向量。然后,仅修改向量的“微笑维度”即可;最后,通过解码器运行修改后的潜在向量。

图 10.5. “微笑维度”。自编码器所学习的潜在空间中期望的结构的示例。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig04_alt.jpg

不幸的是,图 10.4 中所示的 经典自编码器 并不能产生特别有用和良好结构的潜变量空间。它们在压缩方面也不太出色。因此,到 2013 年,它们在很大程度上已经不再流行了。VAE(Variational Autoencoder)则在 2013 年 12 月由 Diederik Kingma 和 Max Welling 几乎同时发现^([10]),而在 2014 年 1 月由 Danilo Rezende、Shakir Mohamed 和 Daan Wiestra 发现^([11]),通过一点统计魔法增加了自编码器的能力,强制模型学习连续且高度结构化的潜变量空间。VAE 已经证明是一种强大的生成式图像模型。

¹⁰

Diederik P. Kingma 和 Max Welling,“Auto-Encoding Variational Bayes”,2013 年 12 月 20 日提交,arxiv.org/abs/1312.6114

¹¹

Danilo Jimenez Rezende,Shakir Mohamed 和 Daan Wierstra,“Stochastic Backpropagation and Approximate Inference in Deep Generative Models”,2014 年 1 月 16 日提交,arxiv.org/abs/1401.4082

VAE 不是将输入图像压缩为潜变量空间中的固定向量,而是将图像转化为统计分布的参数——具体来说是高斯分布的参数。高斯分布有两个参数:均值和方差(或者等效地,标准差)。VAE 将每个输入图像映射到一个均值上。唯一的额外复杂性在于,如果潜变量空间超过 1D,则均值和方差可以是高于一维的,正如我们将在下面的例子中看到的那样。本质上,我们假设图像是通过随机过程生成的,并且在编码和解码过程中应该考虑到这个过程的随机性。然后,VAE 使用均值和方差参数从分布中随机采样一个向量,并使用该随机向量将其解码回原始输入的大小(参见图 10.6)。这种随机性是 VAE 改善鲁棒性、强迫潜变量空间在每个位置都编码有意义表示的关键方式之一:在解码器解码时,潜变量空间中采样的每个点应该是一个有效的图像输出。

图 10.6. 比较经典自编码器(面板 A)和 VAE(面板 B)的工作原理。经典自编码器将输入图像映射到一个固定的潜变量向量上,并使用该向量进行解码。相比之下,VAE 将输入图像映射到一个由均值和方差描述的分布上,从该分布中随机采样一个潜变量向量,并使用该随机向量生成解码后的图像。这个 T 恤图案是来自 Fashion-MNIST 数据集的一个例子。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig05_alt.jpg

接下来,我们将通过使用 Fashion-MNIST 数据集展示 VAE 的工作原理。正如其名称所示,Fashion-MNIST^([12]) 受到了 MNIST 手写数字数据集的启发,但包含了服装和时尚物品的图像。与 MNIST 图像一样,Fashion-MNIST 图像是 28 × 28 的灰度图像。有着确切的 10 个服装和时尚物品类别(如 T 恤、套头衫、鞋子和包袋;请参见 图 10.6 作为示例)。然而,与 MNIST 数据集相比,Fashion-MNIST 数据集对机器学习算法来说略微“更难”,当前最先进的测试集准确率约为 96.5%,远低于 MNIST 数据集的 99.75% 最先进准确率。^([13]) 我们将使用 TensorFlow.js 构建一个 VAE 并在 Fashion-MNIST 数据集上对其进行训练。然后,我们将使用 VAE 的解码器从 2D 潜在空间中对样本进行采样,并观察该空间内部的结构。

¹²

Han Xiao、Kashif Rasul 和 Roland Vollgraf,“Fashion-MNIST: 用于机器学习算法基准测试的新型图像数据集”,提交于 2017 年 8 月 25 日,arxiv.org/abs/1708.07747

¹³

来源:“所有机器学习问题的最新技术结果”,GitHub,2019 年,mng.bz/6w0o

10.2.2. VAE 的详细示例:Fashion-MNIST 示例

要查看 fashion-mnist-vae 示例,请使用以下命令:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/fashion-mnist-vae
yarn
yarn download-data

这个例子由两部分组成:在 Node.js 中训练 VAE 和使用 VAE 解码器在浏览器中生成图像。要开始训练部分,请使用以下命令

yarn train

如果您正确设置了 CUDA 启用的 GPU,则可以使用 --gpu 标志来加速训练:

yarn train --gpu

训练在配备有 CUDA GPU 的合理更新的台式机上大约需要五分钟,没有 GPU 的情况下则需要不到一个小时。训练完成后,使用以下命令构建并启动浏览器前端:

yarn watch

前端将加载 VAE 的解码器,通过使用正则化的 2D 网格的潜在向量生成多个图像,并在页面上显示这些图像。这将让您欣赏到潜在空间的结构。

从技术角度来看,这就是 VAE 的工作原理:

  1. 编码器将输入样本转换为潜在空间中的两个参数:zMeanzLogVar,分别是均值和方差的对数(对数方差)。这两个向量的长度与潜在空间的维度相同。例如,我们的潜在空间将是 2D,因此 zMeanzLogVar 将分别是长度为 2 的向量。为什么我们使用对数方差(zLogVar)而不是方差本身?因为方差必须是非负的,但没有简单的方法来强制该层输出的符号要求。相比之下,对数方差允许具有任何符号。通过使用对数,我们不必担心层输出的符号。对数方差可以通过简单的指数运算(tf.exp())操作轻松地转换为相应的方差。

    ¹⁴

    严格来说,长度为 N 的潜在向量的协方差矩阵是一个 N × N 矩阵。然而,zLogVar 是一个长度为 N 的向量,因为我们将协方差矩阵约束为对角线矩阵——即,潜在向量的两个不同元素之间没有相关性。

  2. VAE 算法通过使用一个称为 epsilon 的向量——与 zMeanzLogVar 的长度相同的随机向量——从潜在正态分布中随机抽样一个潜在向量。在简单的数学方程中,这一步骤在文献中被称为重参数化,看起来像是

    z = zMean + exp(zLogVar * 0.5) * epsilon
    

    乘以 0.5 将方差转换为标准差,这基于标准差是方差的平方根的事实。等效的 JavaScript 代码是

    z = zMean.add(zLogVar.mul(0.5).exp().mul(epsilon));
    

    (见 listing 10.3。) 然后,z 将被馈送到 VAE 的解码器部分,以便生成输出图像。

在我们的 VAE 实现中,潜在向量抽样步骤是由一个名为 ZLayer 的自定义层执行的(见 listing 10.3)。我们在 第九章 中简要介绍了一个自定义 TensorFlow.js 层(我们在基于注意力的日期转换器中使用的 GetLastTimestepLayer 层)。我们 VAE 使用的自定义层略微复杂,值得解释一下。

ZLayer 类有两个关键方法:computeOutputShape()call()computeOutputShape() 被 TensorFlow.js 用来推断给定输入形状的 Layer 实例的输出形状。call() 方法包含了实际的数学计算。它包含了先前介绍的方程行。下面的代码摘自 fashion-mnist-vae/model.js。

listing 10.3 抽样潜在空间(z 空间)的代码示例
class ZLayer extends tf.layers.Layer {
  constructor(config) {
    super(config);
  }
  computeOutputShape(inputShape) {
    tf.util.assert(inputShape.length === 2 && Array.isArray(inputShape[0]),
        () => `Expected exactly 2 input shapes. ` +
              `But got: ${inputShape}`);             ***1***
    return inputShape[0];                            ***2***
  }
  call(inputs, kwargs) {
    const [zMean, zLogVar] = inputs;
    const batch = zMean.shape[0];
    const dim = zMean.shape[1];

    const mean = 0;
    const std = 1.0;
    const epsilon = tf.randomNormal(                 ***3***
        [batch, dim], mean, std);                    ***3***
    return zMean.add(                              ***4***
        zLogVar.mul(0.5).exp().mul(epsilon));        ***4***
  }
  static get ClassName() {                           ***5***
    return 'ZLayer';
  }
}
tf.serialization.registerClass(ZLayer);              ***6***
  • 1 检查确保我们只有两个输入:zMean 和 zLogVar

  • 2 输出(z)的形状将与 zMean 的形状相同。

  • 3 从单位高斯分布中获取一个随机批次的 epsilon

  • 4 这是 z 向量抽样发生的地方:zMean + standardDeviation * epsilon。

  • 5 如果要对该层进行序列化,则设置静态的 className 属性。

  • 6 注册类以支持反序列化

如清单 10.4 所示,ZLayer被实例化并被用作编码器的一部分。编码器被编写为一个功能型模型,而不是更简单的顺序模型,因为它具有非线性的内部结构,并且产生三个输出:zMeanzLogVarz(参见图 10.7 中的示意图)。编码器输出z是因为它将被解码器使用,但为什么编码器包括zMeanzLogVar在输出中?这是因为它们将用于计算 VAE 的损失函数,很快你就会看到。

图 10.7。TensorFlow.js 实现 VAE 的示意图,包括编码器和解码器部分的内部细节以及支持 VAE 训练的自定义损失函数和优化器。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig06_alt.jpg

除了ZLayer,编码器还包括两个单隐藏层的 MLP。它们用于将扁平化的输入 Fashion-MNIST 图像转换为zMeanzLogVar向量,分别。这两个 MLP 共享相同的隐藏层,但使用单独的输出层。这种分支模型拓扑结构也是由于编码器是一个功能型模型。

清单 10.4。我们 VAE 的编码器部分(摘自 fashion-mnist-vae/model.js)
function encoder(opts) {
  const {originalDim, intermediateDim, latentDim} = opts;

  const inputs = tf.input({shape: [originalDim], name: 'encoder_input'});
  const x = tf.layers.dense({units: intermediateDim, activation: 'relu'})
                .apply(inputs);                                              ***1***
  const zMean = tf.layers.dense({units: latentDim, name: 'z_mean'}).apply(x);***2***
  const zLogVar = tf.layers.dense({                                          ***2***
        units: latentDim,                                                    ***2***
        name: 'z_log_var'                                                    ***2***
      }).apply(x);                                                         ***2*** ***3***
  const z =                                                                  ***3***
      new ZLayer({name: 'z', outputShape: [latentDim]}).apply([zMean,        ***3***
     zLogVar]);                                                              ***3***

  const enc = tf.model({
    inputs: inputs,
    outputs: [zMean, zLogVar, z],
    name: 'encoder',
  })
  return enc;
}
  • 1 编码器底部是一个简单的 MLP,有一个隐藏层。

  • 2 与普通的 MLP 不同,我们在隐藏的密集层之后放置了两个层,分别用于预测 zMean 和 zLogVar。这也是我们使用功能型模型而不是更简单的顺序模型类型的原因。

  • 3 实例化我们自定义的 ZLayer,并使用它来生成遵循由 zMean 和 zLogVar 指定的分布的随机样本

清单 10.5 中的代码构建了解码器。与编码器相比,解码器的拓扑结构更简单。它使用一个 MLP 将输入的 z 向量(即潜在向量)转换为与编码器输入相同形状的图像。请注意,我们的 VAE 处理图像的方式有些简单和不寻常,因为它将图像扁平化为 1D 向量,因此丢弃了空间信息。面向图像的 VAE 通常使用卷积和池化层,但由于我们图像的简单性(其尺寸较小且仅有一个颜色通道),扁平化方法足够简单地处理此示例的目的。

清单 10.5。我们 VAE 的解码器部分(摘自 fashion-mnist-vae/model.js)
function decoder(opts) {
  const {originalDim, intermediateDim, latentDim} = opts;

  const dec = tf.sequential({name: 'decoder'});   ***1***
  dec.add(tf.layers.dense({
    units: intermediateDim,
    activation: 'relu',
    inputShape: [latentDim]
  }));
  dec.add(tf.layers.dense({
    units: originalDim,
    activation: 'sigmoid'                         ***2***
  }));
  return dec;
}
  • 1 解码器是一个简单的 MLP,将潜在(z)向量转换为(扁平化的)图像。

  • 2 Sigmoid 激活是输出层的一个好选择,因为它确保输出图像的像素值被限制在 0 和 1 之间。

将编码器和解码器合并成一个名为 VAE 的单个tf.LayerModel对象时,列表 10.6 中的代码会提取编码器的第三个输出(z 向量)并将其通过解码器运行。然后,组合模型会将解码图像暴露为其输出,同时还有其他三个输出:zMeanzLogVar和 z 向量。这完成了 VAE 模型拓扑结构的定义。为了训练模型,我们需要两个东西:损失函数和优化器。以下列表中的代码摘自 fashion-mnist-vae/model.js。

将编码器和解码器放在一起组成 VAE 时,列表 10.6 中完成。
function vae(encoder, decoder) {
  const inputs = encoder.inputs;                     ***1***
  const encoderOutputs = encoder.apply(inputs);
  const encoded = encoderOutputs[2];                 ***2***
  const decoderOutput = decoder.apply(encoded);
  const v = tf.model({                               ***3***
    inputs: inputs,
    outputs: [decoderOutput, ...encoderOutputs],     ***4***
    name: 'vae_mlp',
  })
  return v;
}
  • 1 VAE 的输入与编码器的输入相同:原始输入图像。

  • 2 在编码器的所有三个输出中,只有最后一个(z)进入解码器。

  • 3 由于模型的非线性拓扑结构,我们使用功能模型 API。

  • 4 VAE 模型对象的输出除了 zMean、zLogVar 和z之外还包括解码图像。

当我们访问第五章中的 simple-object-detection 模型时,我们描述了如何在 TensorFlow.js 中定义自定义损失函数的方式。在这里,需要自定义损失函数来训练 VAE。这是因为损失函数将是两个项的总和:一个量化输入和输出之间的差异,另一个量化潜在空间的统计属性。这让人想起了 simple-object-detection 模型的自定义损失函数,其中一个项用于对象分类,另一个项用于对象定位。

如您从列表 10.7 中的代码中所见(摘自 fashion-mnist-vae/model.js),定义输入输出差异项是直接的。我们简单地计算原始输入和解码器输出之间的均方误差(MSE)。然而,统计项,称为Kullbach-Liebler(KL)散度,数学上更加复杂。我们会免去详细的数学[¹⁵],但从直觉上讲,KL 散度项(代码中的 klLoss)鼓励不同输入图像的分布更均匀地分布在潜在空间的中心周围,这使得解码器更容易在图像之间进行插值。因此,klLoss项可以被视为 VAE 的主要输入输出差异项之上添加的正则化项。

¹⁵

Irhum Shafkat 的这篇博文包含了对 KL 散度背后数学的更深入讨论:mng.bz/vlvr

第 10.7 节列出了 VAE 的损失函数。
function vaeLoss(inputs, outputs) {
  const originalDim = inputs.shape[1];
  const decoderOutput = outputs[0];
  const zMean = outputs[1];
  const zLogVar = outputs[2];

  const reconstructionLoss =                                              ***1***
      tf.losses.meanSquaredError(inputs, decoderOutput).mul(originalDim); ***1***

  let klLoss = zLogVar.add(1).sub(zMean.square()).sub(zLogVar.exp());
  klLoss = klLoss.sum(-1).mul(-0.5);                                      ***2***
  return reconstructionLoss.add(klLoss).mean();                           ***3***
}
  • 1 计算“重构损失”项。最小化此项的目标是使模型输出与输入数据匹配。

  • 2 计算 zLogVar 和 zMean 之间的 KL 散度。最小化此项旨在使潜变量的分布更接近于潜在空间的中心处正态分布。

  • 3 将图像重建损失和 KL-散度损失汇总到最终的 VAE 损失中

我们 VAE 训练的另一个缺失部分是优化器及其使用的训练步骤。优化器的类型是流行的 ADAM 优化器(tf.train .adam())。VAE 的训练步骤与本书中所有其他模型不同,因为它不使用模型对象的fit()fitDataset()方法。相反,它调用优化器的minimize()方法(列表 10.8)。这是因为自定义损失函数的 KL-散度项使用模型的四个输出中的两个,但在 TensorFlow.js 中,只有在模型的每个输出都具有不依赖于任何其他输出的损失函数时,fit()fitDataset()方法才能正常工作。

如列表 10.8 所示,minimize()函数以箭头函数作为唯一参数进行调用。这个箭头函数返回当前批次的扁平化图像的损失(代码中的reshaped),这个损失被函数闭包。minimize()计算损失相对于 VAE 的所有可训练权重的梯度(包括编码器和解码器),根据 ADAM 算法调整它们,然后根据调整后的梯度在权重的相反方向应用更新。这完成了一次训练步骤。这一步骤重复进行,遍历 Fashion-MNIST 数据集中的所有图像,并构成一个训练时期。yarn train 命令执行多个训练周期(默认:5 个周期),在此之后损失值收敛,并且 VAE 的解码器部分被保存到磁盘上。编码器部分不保存的原因是它不会在接下来的基于浏览器的演示步骤中使用。

列表 10.8. VAE 的训练循环(摘自 fashion-mnist-vae/train.js)
  for (let i = 0; i < epochs; i++) {
    console.log(`
Epoch #${i} of ${epochs}
`)
    for (let j = 0; j < batches.length; j++) 
        return loss;
      });
      tf.dispose([batchedImages, reshaped]);
    }
    console.log('');
    await generate(decoderModel, vaeOpts.latentDim);            ***4***
  }
  • 1 获取一批(扁平化的)Fashion-MNIST 图像

  • 2 VAE 训练的单个步骤:使用 VAE 进行预测,并计算损失,以便 optimizer.minimize 可以调整模型的所有可训练权重

  • 3 由于我们不使用默认的fit()方法,因此不能使用内置的进度条,必须自己打印控制台上的状态更新。

  • 4 在每个训练周期结束时,使用解码器生成一幅图像,并将其打印到控制台以进行预览

yarn watch 命令打开的网页将加载保存的解码器,并使用它生成类似于图 10.8 所示的图像网格。这些图像是从二维潜在空间中的正则网格的潜在向量获得的。每个潜在维度上的上限和下限可以在 UI 中进行调整。

图 10.8. 在训练后对 VAE 的潜在空间进行采样。该图显示了一个 20 × 20 的解码器输出网格。该网格对应于一个 20 × 20 的二维潜在向量的正则间隔网格,其中每个维度位于[-4, 4]的区间内。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig07.jpg

图像网格显示了来自 Fashion-MNIST 数据集的完全连续的不同类型的服装,一种服装类型在潜在空间中沿着连续路径逐渐变形为另一种类型(例如,套头衫变成 T 恤,T 恤变成裤子,靴子变成鞋子)。潜在空间的特定方向在潜在空间的子域内具有一定的意义。例如,在潜在空间的顶部区域附近,水平维度似乎代表“靴子特性与鞋子特性;”在潜在空间的右下角附近,水平维度似乎代表“T 恤特性与裤子特性”,依此类推。

在接下来的章节中,我们将介绍另一种生成图像的主要模型类型:GANs。

10.3. 使用 GANs 进行图像生成

自从 Ian Goodfellow 和他的同事在 2014 年引入了 GANs^([16]) 这项技术以来,它的兴趣和复杂程度迅速增长。如今,GANs 已经成为生成图像和其他数据模态的强大工具。它们能够输出高分辨率图像,有些情况下,这些图像在人类眼中几乎无法与真实图像区分开来。查看 NVIDIA 的 StyleGANs 生成的人脸图像,如 图 10.9^([17]) 所示。如果不是人脸上偶尔出现的瑕疵点和背景中不自然的场景,人类观察者几乎无法将这些生成的图像与真实图像区分开来。

¹⁶

Ian Goodfellow 等人,“生成对抗网络”,NIPS 会议论文集,2014 年,mng.bz/4ePv

¹⁷

thispersondoesnotexist.com 的网站。有关学术论文,请参阅 Tero Karras,Samuli Laine 和 Timo Aila,“用于生成对抗网络的基于样式的生成器架构”,于 2018 年 12 月 12 日提交,arxiv.org/abs/1812.04948

图 10.9. NVIDIA 的 StyleGAN 生成的示例人脸图像,从 thispersondoesnotexist.com 中采样于 2019 年 4 月

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig08_alt.jpg

除了“从蓝天中生成引人注目的图像”之外,GAN 生成的图像还可以根据某些输入数据或参数进行条件约束,这带来了更多的特定任务和有用的应用。例如,GAN 可以用于从低分辨率输入(图像超分辨率)生成更高分辨率的图像,填补图像的缺失部分(图像修复),将黑白图像转换为彩色图像(图像着色),根据文本描述生成图像以及根据输入图像中同一人采取的姿势生成该人的图像。此外,已经开发了新类型的 GAN 用于生成非图像输出,例如音乐。^([18]) 除了在艺术、音乐制作和游戏设计等领域中生成无限量的逼真材料的明显价值之外,GAN 还有其他应用,例如通过在获取此类样本代价高昂的情况下生成训练示例来辅助深度学习。例如,GAN 正被用于为训练自动驾驶神经网络生成逼真的街景图像。^([19])

¹⁸

请参阅 Hao-Wen Dong 等人的 MuseGAN 项目:salu133445.github.io/musegan/

¹⁹

James Vincent,《NVIDIA 使用 AI 让永远阳光的街道下雪》,The Verge,2017 年 12 月 5 日,mng.bz/Q0oQ

虽然 VAE 和 GAN 都是生成模型,但它们基于不同的思想。VAE 通过使用原始输入和解码器输出之间的均方误差损失来确保生成的示例的质量,而 GAN 则通过使用鉴别器来确保其输出逼真,我们很快就会解释。此外,GAN 的许多变体允许输入不仅包含潜空间向量,还包括条件输入,例如所需的图像类别。我们将要探索的 ACGAN 就是这方面的一个很好的例子。在这种具有混合输入的 GAN 类型中,潜空间不再与网络输入具有连续性。

在这个部分,我们将深入研究一种相对简单的 GAN 类型。具体而言,我们将在熟悉的 MNIST 手写数字数据集上训练一个辅助分类器 GAN (ACGAN)^([20])。这将给我们一个能够生成与真实 MNIST 数字完全相似的数字图像的模型。同时,由于 ACGAN 的“辅助分类器”部分,我们将能够控制每个生成图像所属的数字类别(0 到 9)。为了理解 ACGAN 的工作原理,让我们一步一步来。首先,我们将解释 ACGAN 的基本“GAN”部分如何工作。然后,我们将描述 ACGAN 通过额外的机制如何使类别标识具有可控性。

²⁰

Augustus Odena、Christopher Olah 和 Jonathon Shlens,“带辅助分类器 GAN 的条件图像合成”,2016 年 10 月 30 日提交,arxiv.org/abs/1610.09585

10.3.1. GANs 背后的基本思想

生成对抗网络(GAN)是如何学习生成逼真图片的?它通过其包含的两个子部分之间的相互作用来实现这一点:一个生成器和一个鉴别器。把生成器想象成一个伪造者,其目标是创建高质量的假毕加索画作;而鉴别器则像是一位艺术品经销商,其工作是将假的毕加索画作与真实的区分开来。伪造者(生成器)努力创建越来越好的假画作以欺骗艺术品经销商(鉴别器),而艺术品经销商的工作是成为对画作的评判者越来越好,从而不被伪造者欺骗。我们两个角色之间的这种对抗是“GAN”名称中“对抗性”部分的原因。有趣的是,伪造者和艺术品经销商最终互相帮助变得更好,尽管表面上是对手。

起初,伪造者(生成器)在创建逼真的毕加索画作方面表现糟糕,因为其权重是随机初始化的。结果,艺术品经销商(鉴别器)很快就学会了区分真假毕加索画作。这里是所有这些工作的重要部分:每次伪造者给艺术品经销商带来一幅新画作时,他们都会得到详细的反馈(来自艺术品经销商),指出画作的哪些部分看起来不对劲,以及如何改变画作使其看起来更真实。伪造者学习并记住这一点,以便下次他们来到艺术品经销商那里时,他们的画作看起来会稍微好一些。这个过程重复多次。结果发现,如果所有参数都设置正确,我们最终会得到一个技艺精湛的伪造者(生成器)。当然,我们也会得到一个技艺精湛的鉴别器(艺术品经销商),但通常在 GAN 训练完成后我们只需要生成器。

图 10.10 更详细地描述了如何训练通用 GAN 模型的判别器部分。为了训练判别器,我们需要一批生成的图像和一批真实图像。生成的图像由生成器生成。但生成器无法从空气中制作图像。相反,它需要作为输入的随机向量。潜在向量在概念上类似于我们在第 10.2 节中用于 VAE 的向量。对于生成器生成的每个图像,潜在向量是形状为[latentSize]的一维张量。但像本书中大多数训练过程一样,我们一次对一批图像执行步骤。因此,潜在向量的形状为[batchSize, latentSize]。真实图像直接从实际 MNIST 数据集中提取。为了对称起见,我们在每个训练步骤中绘制与生成的图像完全相同数量的batchSize真实图像。

图 10.10. 示出 GAN 判别器部分训练算法的示意图。请注意,为简单起见,该图省略了 ACGAN 的数字类部分。有关 ACGAN 生成器训练的完整图表,请参见图 10.13。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig09_alt.jpg

生成的图像和真实图像随后被连接成一批图像,表示为形状为[2 * batchSize, 28, 28, 1]的张量。判别器在这批合并图像上执行,输出每个图像是真实的预测概率分数。这些概率分数可以轻松地通过二元交叉熵损失函数与基准真值进行测试(我们知道哪些是真实的,哪些是生成的!)。然后,熟悉的反向传播算法发挥作用,借助优化器(图中未显示)更新判别器的权重参数。这一步使判别器略微朝着正确的预测方向推进。请注意,生成器仅通过提供生成的样本参与此训练步骤,但它不会通过反向传播过程进行更新。下一步训练将更新生成器(图 10.11)。

图 10.11. 示出 GAN 生成器部分训练算法的示意图。请注意,为简单起见,该图省略了 ACGAN 的数字类部分。有关 ACGAN 生成器训练过程的完整图表,请参见图 10.14。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig10_alt.jpg

图 10.11 说明了生成器的训练步骤。我们让生成器生成另一批生成图像。但与鉴别器的训练步骤不同,我们不需要任何真实的 MNIST 图像。鉴别器被赋予了这批生成图像以及一批二进制真实性标签。我们假装这些生成图像都是真实的,将真实性标签设置为全部为 1。静下心来思考一下:这是 GAN 训练中最重要的技巧。当然,这些图像都是生成的(并非真实的),但我们让真实性标签表明它们是真实的。鉴别器可能(正确地)对一些或所有的输入图像分配较低的真实性概率。但是如果这样做,由于虚假的真实性标签,二进制交叉熵损失将得到较大的值。这将导致反向传播更新生成器,以使鉴别器的真实性得分稍微增加。请注意,反向传播只更新生成器,不对鉴别器进行任何更改。这是另一个重要的技巧:它确保生成器最终产生的图像看起来更真实一些,而不是降低鉴别器对真实性的要求。这是通过冻结模型的鉴别器部分实现的,这是我们在第五章中用于迁移学习的一种操作。

总结生成器训练步骤:冻结鉴别器并向其提供全是 1 的真实性标签,尽管它得到的是由生成器生成的生成图像。由于这样,对生成器的权重更新将导致其生成的图像在鉴别器中看起来稍微更真实。只有当鉴别器相当擅长区分真实和生成的图像时,这种训练生成器的方式才会奏效。我们如何确保这一点?答案是我们已经讨论过的鉴别器训练步骤。因此,你可以看到这两个训练步骤形成了一种复杂的阴阳动态,其中 GAN 的两个部分相互抵触并互相帮助。

这就是对通用 GAN 训练的高级概览。在下一节中,我们将介绍鉴别器和生成器的内部架构以及它们如何融入有关图像类别的信息。

10.3.2. ACGAN 的构建模块

清单 10.9 显示了创建 MNIST ACGAN 判别器部分的 TensorFlow.js 代码(摘自 mnist-acgan/gan.js)。在判别器的核心是一种类似于我们在第四章中看到的深度卷积网络。其输入具有 MNIST 图像的经典形状,即 [28, 28, 1]。输入图像通过四个 2D 的卷积(conv2d)层,然后被展平并经过两个全连接层处理。其中一个全连接层为输入图像的真实性二进制预测输出,另一个输出 10 个数字类别的 softmax 概率。判别器是一个有两个全连接层输出的函数模型。图 10.12 的面板 A 提供了判别器的一个输入-两个输出拓扑结构的示意图。

图 10.12。ACGAN 的判别器(面板 A)和生成器(面板 B)部分的内部拓扑示意图。为简洁起见,省略了某些细节(例如判别器中的 dropout 层)。有关详细的代码,请参见清单 10.9 和 10.10。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig11_alt.jpg

清单 10.9。创建 ACGAN 的判别器部分。
function buildDiscriminator() {
  const cnn = tf.sequential();

  cnn.add(tf.layers.conv2d({
    filters: 32,
    kernelSize: 3,
    padding: 'same',
    strides: 2,
    inputShape: [IMAGE_SIZE, IMAGE_SIZE, 1]                                ***1***
  }));
  cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
  cnn.add(tf.layers.dropout({rate: 0.3}));                                 ***2***

  cnn.add(tf.layers.conv2d(
      {filters: 64, kernelSize: 3, padding: 'same', strides: 1}));
  cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
  cnn.add(tf.layers.dropout({rate: 0.3}));
  cnn.add(tf.layers.conv2d(
      {filters: 128, kernelSize: 3, padding: 'same', strides: 2}));
  cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
  cnn.add(tf.layers.dropout({rate: 0.3}));

  cnn.add(tf.layers.conv2d(
      {filters: 256, kernelSize: 3, padding: 'same', strides: 1}));
  cnn.add(tf.layers.leakyReLU({alpha: 0.2}));

  cnn.add(tf.layers.dropout({rate: 0.3}));

  cnn.add(tf.layers.flatten());

  const image = tf.input({shape: [IMAGE_SIZE, IMAGE_SIZE, 1]});
  const features = cnn.apply(image);

  const realnessScore =                                                    ***3***
      tf.layers.dense({units: 1, activation: 'sigmoid'}).apply(features);  ***3***
  const aux = tf.layers.dense({units: NUM_CLASSES, activation: 'softmax'}) ***4***
                  .apply(features);                                        ***4***
  return tf.model({inputs: image, outputs: [realnessScore, aux]});
}
  • 1 判别器只接受 MNIST 格式的图像作为输入。

  • 2 使用 Dropout 层来对抗过拟合。

  • 3 判别器的两个输出之一是二进制真实性分类的概率分数。

  • 4 第二个输出是 10 个 MNIST 数字类别的 softmax 概率。

清单 10.10 中的代码负责创建 ACGAN 生成器。正如我们之前暗示的那样,生成器的生成过程需要一个叫做潜在向量(代码中称为 latent)的输入。这体现在其第一个全连接层的 inputShape 参数中。然而,如果你仔细检查代码,就会发现生成器实际上接受两个输入。这在 图 10.12 的面板 B 中有描述。除了潜在向量外,也就是一个形状为 [latentSize] 的一维张量,生成器需要一个额外的输入,名为 imageClass,形状简单,为 [1]。这是告诉模型要生成哪个 MNIST 数字(0 到 9)的方式。例如,如果我们想要模型生成数字 8 的图像,我们应该将形状为 tf.tensor2d([[8]]) 的张量值输入到第二个输入(请记住,即使只有一个示例,模型也始终期望批量张量)。同样,如果我们想要模型生成两个图像,一个是数字 8,另一个是数字 9,则馈送的张量应为 tf.tensor2d([[8], [9]])

一旦 imageClass 输入进入生成器,嵌入层将其转换为与 latent 相同形状的张量 ([latentSize])。这一步在数学上类似于我们在 第九章 中用于情感分析和日期转换模型的嵌入查找过程。期望的数字类别是一个整数量,类似于情感分析数据中的单词索引和日期转换数据中的字符索引。它被转换为与单词和字符索引转换为 1D 向量的方式相同的 1D 向量。然而,我们在这里对 imageClass 使用嵌入查找是为了不同的目的:将其与 latent 向量合并并形成一个单一的组合向量(在 清单 10.10 中命名为 h)。这个合并是通过一个 multiply 层完成的,该层在两个相同形状的向量之间执行逐元素相乘。结果张量的形状与输入相同 ([latentSize]),并传入生成器的后续部分。

生成器立即在合并的潜在向量 (h) 上应用一个密集层,并将其重塑为 3D 形状 [3, 3, 384]。这种重塑产生了一个类似图像的张量,随后可以由生成器的后续部分转换为具有标准 MNIST 形状 ([28, 28, 1]) 的图像。

生成器不使用熟悉的 conv2d 层来转换输入,而是使用 conv2dTranspose 层来转换其图像张量。粗略地说,conv2dTranspose 执行与 conv2d 的逆操作(有时称为反卷积)。conv2d 层的输出通常比其输入具有更小的高度和宽度(除了 kernelSize 为 1 的情况之外),如您在 第四章 中的 convnets 中所见。然而,conv2dTranspose 层的输出通常比其输入具有更大的高度和宽度。换句话说,虽然 conv2d 层通常缩小其输入的维度,但典型的 conv2dTranspose 层扩展它们。这就是为什么在生成器中,第一个 conv2dTranspose 层接受高度为 3 和宽度为 3 的输入,但最后一个 conv2dTranspose 层输出高度为 28 和宽度为 28 的原因。这就是生成器将输入潜在向量和数字索引转换为标准 MNIST 图像尺寸的图像的方式。以下清单中的代码摘录自 mnist-acgan/gan.js; 为了清晰起见,删除了一些错误检查代码。

清单 10.10. 创建 ACGAN 的生成器部分
function buildGenerator(latentSize) {
  const cnn = tf.sequential();
  cnn.add(tf.layers.dense({
    units: 3 * 3 * 384,                                ***1***
    inputShape: [latentSize],
    activation: 'relu'
  }));
  cnn.add(tf.layers.reshape());

  cnn.add(tf.layers.conv2dTranspose({                  ***2***
    filters: 192,
    kernelSize: 5,
    strides: 1,
    padding: 'valid',
    activation: 'relu',
    kernelInitializer: 'glorotNormal'
  }));
  cnn.add(tf.layers.batchNormalization());

  cnn.add(tf.layers.conv2dTranspose({                  ***3***
    filters: 96,
    kernelSize: 5,
    strides: 2,
    padding: 'same',
    activation: 'relu',
    kernelInitializer: 'glorotNormal'
  }));
  cnn.add(tf.layers.batchNormalization());

  cnn.add(tf.layers.conv2dTranspose({                  ***4***
    filters: 1,
    kernelSize: 5,
    strides: 2,
    padding: 'same',
    activation: 'tanh',
    kernelInitializer: 'glorotNormal'
  }));

  const latent = tf.input({shape: [latentSize]});      ***5***

  const imageClass = tf.input({shape: [1]});           ***6***

  const classEmbedding = tf.layers.embedding({         ***7***
    inputDim: NUM_CLASSES,
    outputDim: latentSize,
    embeddingsInitializer: 'glorotNormal'
  }).apply(imageClass);

  const h = tf.layers.multiply().apply(                ***8***

      [latent, classEmbedding]);                       ***8***

  const fakeImage = cnn.apply(h);
  return tf.model({                                    ***9***
   inputs: [latent, imageClass],                       ***9***
   outputs: fakeImage                                  ***9***
  });                                                  ***9***
}
  • 1 单元的数量被选择为当输出被重塑并通过后续的 conv2dTranspose 层时,最终输出的张量的形状与 MNIST 图像完全匹配 ([28, 28, 1])。

  • 2 从 [3, 3, …] 上采样至 [7, 7, …]

  • 3 上采样至 [14, 14, …]

  • 4 上采样至 [28, 28, …]

  • 5 这是生成器的两个输入之一:作为伪图像生成的“种子”的潜在(z-空间)向量。

  • 6 生成器的第二个输入:控制生成的图像属于哪个 MNIST 数字类别的类标签

  • 7 通过嵌入查找将期望标签转换为长度为 latentSize 的向量

  • 8 通过乘法将潜在向量和类别条件嵌入组合起来

  • 9 最终创建模型,以顺序卷积网络为核心。

10.3.3. 更深入地了解 ACGAN 的训练

最后一节应该让你更好地理解了 ACGAN 的鉴别器和生成器的内部结构,以及它们如何整合数字类别信息(ACGAN 名字中的“AC”部分)。有了这些知识,我们就可以扩展 figures 10.10 和 10.11,以全面了解 ACGAN 的训练方式。

Figure 10.13 是 figure 10.10 的扩展版本。它展示了 ACGAN 的鉴别器部分的训练。与之前相比,这一训练步骤不仅提高了鉴别器区分真实和生成(伪造)图像的能力,还磨练了其确定给定图像(包括真实和生成的图像)属于哪个数字类别的能力。为了更容易与之前的简单图表进行比较,我们将已在 figure 10.10 中看到的部分灰暗显示,并突出显示新的部分。首先,注意到生成器现在有了一个额外的输入(数字类别),这使得指定生成器应该生成什么数字成为可能。此外,鉴别器不仅输出真实性预测,还输出数字类别预测。因此,鉴别器的两个输出头都需要进行训练。对于真实性预测的训练与之前相同(figure 10.10);类别预测部分的训练依赖于我们知道生成和真实图像属于哪些数字类别。模型的两个头部编译了不同的损失函数,反映了两种预测的不同性质。对于真实性预测,我们使用二元交叉熵损失,但对于数字类别预测,我们使用了稀疏分类交叉熵损失。你可以在 mnist-acgan/gan.js 的下一行中看到这一点:

  discriminator.compile({
    optimizer: tf.train.adam(args.learningRate, args.adamBeta1),
    loss: ['binaryCrossentropy', 'sparseCategoricalCrossentropy']
  });
图 10.13. 说明 ACGAN 的鉴别器部分是如何训练的示意图。这个图表在 figure 10.10 的基础上添加了与数字类别相关的部分。图表的其余部分已经在 figure 10.10 中出现,并且被灰暗显示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig12_alt.jpg

如 图 10.13 中的两条弯曲箭头所示,当更新鉴别器的权重时,通过反向传播的梯度会相互叠加。图 10.14 是 图 10.11 的扩展版本,提供了 ACGAN 生成器部分训练的详细示意图。该图显示了生成器学习如何根据指定的数字类别生成正确的图像,以及学习如何生成真实的图像。与 图 10.13 类似,新添加的部分被突出显示,而已经存在于 图 10.11 的部分则被隐藏。从突出显示的部分中,可以看到我们在训练步骤中输入的标签现在不仅包括真实性标签,还包括数字类别标签。与以前一样,真实性标签都是故意虚假的。但是新添加的数字类别标签更加真实,因为我们确实将这些类别标签给了生成器。

图 10.14. 示意图,说明 ACGAN 的生成器部分是如何训练的。这个图是 图 10.11 的扩展,显示了与数字类别相关的部分。图的其余部分已经在图 10.11 中出现,已被隐藏。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig13_alt.jpg

先前,我们看到虚假真实标签与鉴别器的真实概率输出之间的任何差异会被用来更新 ACGAN 的生成器,使其在“欺骗”鉴别器方面更加优秀。在这里,鉴别器的数字分类预测发挥了类似的作用。例如,如果我们告诉生成器生成一个数字 8 的图像,但是鉴别器将图像分类为 9,则稀疏分类交叉熵的值将较高,并且与之关联的梯度将有较大的幅度。因此,生成器权重的更新将导致生成器生成一个更像数字 8 的图像(根据鉴别器的判断)。显然,只有当鉴别器在将图像分类为 10 个 MNIST 数字类别方面足够好时,这种训练生成器的方法才会起作用。这就是前一个鉴别器训练步骤所帮助确保的。再次强调,在 ACGAN 的训练过程中,我们看到了鉴别器和生成器部分之间的阴阳动力学。

GAN 训练:一大堆诡计

训练和调整 GAN 的过程众所周知地困难。您在 mnist-acgan 示例中看到的训练脚本是研究人员大量试错的结晶。像深度学习中的大多数事物一样,这更像是一种艺术而不是精确科学:这些技巧是启发式的,没有系统理论的支持。它们得到了对手头现象的直觉理解,并且在经验上被证明效果良好,尽管不一定在每种情况下都有效。

以下是本节中 ACGAN 中使用的一些值得注意的技巧列表:

  • 我们在生成器的最后一个 conv2dTranspose 层中使用 tanh 作为激活函数。在其他类型的模型中,tanh 激活函数出现得较少。

  • 随机性有助于诱导鲁棒性。因为 GAN 的训练可能导致动态平衡,所以 GAN 很容易陷入各种各样的困境中。在训练过程中引入随机性有助于防止这种情况发生。我们通过两种方式引入随机性:在鉴别器中使用 dropout,以及为鉴别器的真实标签使用“soft one”值(0.95)。

  • 稀疏梯度(许多值为零的梯度)可能会妨碍 GAN 的训练。在其他类型的深度学习中,稀疏性通常是一种理想的特性,但在 GAN 中不是这样。梯度中的稀疏性可能由两个因素引起:最大池化操作和 relu 激活函数。建议使用步幅卷积进行下采样,而不是最大池化,这正是生成器创建代码中所示的内容。建议使用 leakyReLU 激活函数,其中负部分具有小的负值,而不是严格的零。这也在清单 10.10 中显示。

10.3.4. 查看 MNIST ACGAN 训练和生成

mnist-acgan 示例可以通过以下命令检出和准备:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/mnist-acganyarn

运行示例涉及两个阶段:在 Node.js 中进行训练,然后在浏览器中进行生成。要启动训练过程,只需使用以下命令:

yarn train

训练默认使用 tfjs-node。然而,像我们之前见过的涉及卷积神经网络的示例一样,使用 tfjs-node-gpu 可以显著提高训练速度。如果您的计算机上正确设置了支持 CUDA 的 GPU,您可以在yarn train命令中追加--gpu标志来实现。训练 ACGAN 至少需要几个小时。对于这个长时间运行的训练任务,您可以使用--logDir标志通过 TensorBoard 监控进度:

yarn train --logDir /tmp/mnist-acgan-logs

一旦在单独的终端中使用以下命令启动了 TensorBoard 进程,

tensorboard --logdir /tmp/mnist-acgan-logs

您可以在浏览器中导航到 TensorBoard URL(由 TensorBoard 服务器进程打印)以查看损失曲线。图 10.15 显示了训练过程中的一些示例损失曲线。GAN 训练的损失曲线的一个显著特征是,它们并不总是像大多数其他类型的神经网络的损失曲线那样趋向于下降。相反,判别器的损失(图中的 dLoss)和生成器的损失(图中的 gLoss)都以非单调方式变化,并相互交织形成复杂的舞蹈。

图 10.15. ACGAN 训练作业中的样本损失曲线。dLoss 是判别器训练步骤的损失。具体来说,它是真实性预测的二元交叉熵和数字类别预测的稀疏分类交叉熵的总和。gLoss 是生成器训练步骤的损失。与 dLoss 类似,gLoss 是来自二元真实性分类和多类数字分类的损失的总和。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig14_alt.jpg

在训练接近结束时,两者的损失都不会接近零。相反,它们只是趋于平稳(收敛)。此时,训练过程结束并将模型的生成器部分保存到磁盘上,以便在浏览器内生成步骤中进行服务:

await generator.save(saveURL);

要运行浏览器内生成演示,请使用命令 yarn watch。它将编译 mnist-acgan/index.js 和相关的 HTML 和 CSS 资源,然后会在您的浏览器中打开一个标签页并显示演示页面。^([21])

²¹

您还可以完全跳过训练和构建步骤,直接导航到托管的演示页面,网址为 mng.bz/4eGw

演示页面加载了从前一阶段保存的训练好的 ACGAN 生成器。由于判别器在此演示阶段并不真正有用,因此它既不保存也不加载。有了生成器加载后,我们可以构建一批潜在向量,以及一批期望的数字类别索引,并调用生成器的 predict()。执行此操作的代码位于 mnist-acgan/index.js 中:

    const latentVectors = getLatentVectors(10);
    const sampledLabels = tf.tensor2d(
        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [10, 1]);
    const generatedImages =
        generator.predict([latentVectors, sampledLabels]).add(1).div(2);

我们的数字类别标签批次始终是一个有序的 10 元素向量,从 0 到 9。这就是为什么生成的图像批次总是一个从 0 到 9 的有序图像数组。这些图像使用 tf.concat() 函数拼接在一起,并在页面上的 div 元素中呈现(参见图 10.16 中的顶部图像)。与随机抽样的真实 MNIST 图像(参见图 10.16 中的底部图像)相比,这些 ACGAN 生成的图像看起来就像真实的一样。此外,它们的数字类别身份看起来是正确的。这表明我们的 ACGAN 训练是成功的。如果您想查看 ACGAN 生成器的更多输出,请点击页面上的 Generator 按钮。每次点击按钮,都会生成并显示页面上的新批次包含 10 张假图像。您可以玩一下,直观地感受图像生成的质量。

图 10.16. ACGAN 训练模型的生成器部分生成的样本图片(顶部的 10 x 1 面板)。底部的面板展示了一个 10 x 10 的真实 MNIST 图像网格,以进行比较。点击“显示 Z-向量滑块”按钮,您可以打开一个填满了 100 个滑块的区域。这些滑块允许您改变潜在向量(z-向量)的元素,并观察其对生成的 MNIST 图像的影响。请注意,如果您逐个更改滑块,大多数滑块对图像的影响都很微小且不易察觉。但偶尔,您会发现一个具有更大且更明显影响的滑块。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-js/img/10fig15_alt.jpg

进一步阅读材料

  • Ian Goodfellow、Yoshua Bengio 和 Aaron Courville,“深度生成模型”,深度学习,第二十章,麻省理工学院出版社,2017 年。

  • Jakub Langr 和 Vladimir Bok,《GAN 行动中:生成对抗网络的深度学习》,Manning 出版社,2019 年。

  • Andrej Karpathy,“循环神经网络的不合理有效性”,博客,2015 年 5 月 21 日,karpathy.github.io/2015/05/21/rnn-effectiveness/

  • Jonathan Hui,“GAN—什么是生成对抗网络 GAN?” Medium,2018 年 6 月 19 日,mng.bz/Q0N6

  • GAN 实验室,一个用 TensorFlow.js 构建的交互式网络环境,用于理解和探索 GAN 的工作原理:Minsuk Kahng 等人,poloclub.github.io/ganlab/

练习

  1. 除了莎士比亚文本语料库外,lstm-text-generation 示例还配置了其他几个文本数据集,并准备好供您探索。运行它们的训练,并观察其效果。例如,使用未压缩的 TensorFlow.js 代码作为训练数据集。在模型训练期间和之后,观察生成的文本是否表现出以下 JavaScript 源代码的模式以及温度参数如何影响这些模式:

    1. 较短程模式,例如关键字(例如,“for”和“function”)

    2. 中程模式,例如代码的逐行组织

    3. 较长程模式,例如括号和方括号的配对,以及每个“function”关键字后必须跟着一对括号和一对花括号

  2. 在 fashion-mnist-vae 示例中,如果您将 VAE 的自定义损失中的 KL 散度项删除会发生什么?通过修改 fashion-mnist-vae/model.js 中的 vaeLoss() 函数(清单 10.7)来测试。从潜在空间采样的图像是否仍然看起来像 Fashion-MNIST 图像?空间是否仍然展现出可解释的模式?

  3. 在 mnist-acgan 示例中,尝试将 10 个数字类别合并为 5 个(0 和 1 将成为第一类,2 和 3 将成为第二类,依此类推),并观察在训练后这如何改变 ACGAN 的输出。当您指定第一类时,您期望看到生成的图像是什么?例如,当您指定第一类时,您期望 ACGAN 生成什么?提示:要进行此更改,您需要修改 mnist-acgan/data.js 中的 loadLabels() 函数。需要相应修改 gan.js 中的常量 NUM_CLASSES。此外,generateAnd-VisualizeImages() 函数(位于 index.js 中)中的 sampledLabels 变量也需要修改。

总结

  • 生成模型与我们在本书早期章节中学习的判别模型不同,因为它们旨在模拟训练数据集的生成过程,以及它们的统计分布。由于这种设计,它们能够生成符合分布并且看起来类似于真实训练数据的新样本。

  • 我们介绍了一种模拟文本数据集结构的方法:下一个字符预测。LSTM 可以用来以迭代方式执行此任务,以生成任意长度的文本。温度参数控制生成文本的随机性(多么随机和不可预测)。

  • 自动编码器是一种由编码器和解码器组成的生成模型。首先,编码器将输入数据压缩为称为潜在向量或 z-向量的简明表示。然后,解码器尝试仅使用潜在向量来重构输入数据。通过训练过程,编码器变成了一个高效的数据摘要生成器,解码器则具有对示例的统计分布的知识。VAE 对潜在向量添加了一些额外的统计约束,使得在 VAE 训练后组成这些向量的潜在空间显示出连续变化和可解释的结构。

  • GAN 基于鉴别器和生成器之间的竞争和合作的想法。鉴别器试图区分真实数据示例和生成的数据示例,而生成器旨在生成“欺骗”鉴别器的虚假示例。通过联合训练,生成器部分最终将能够生成逼真的示例。ACGAN 在基本 GAN 架构中添加了类信息,以便指定要生成的示例的类别。

赞(0)
未经允许不得转载:上海聚慕医疗器械有限公司 » 什么是语言训练仪JavaScript 深度学习(三)

登录

找回密码

注册