JavaScript 深度学习(二)(4)

简介: JavaScript 深度学习(二)

JavaScript 深度学习(二)(3)https://developer.aliyun.com/article/1516955

第六章:处理数据

本章涵盖内容

  • 如何使用tf.data API 来使用大型数据集训练模型
  • 探索您的数据以查找和解决潜在问题
  • 如何使用数据增强来创建新的“伪样本”以提高模型质量

大量高质量数据的广泛可用是导致当今机器学习革命的主要因素。如果没有轻松获取大量高质量数据,机器学习的急剧上升就不会发生。现在数据集可以在互联网上随处可得——在 Kaggle 和 OpenML 等网站上免费共享——同样可以找到最先进性能的基准。整个机器学习领域都是通过可用的“挑战”数据集前进的,这些数据集设定了一个标准和一个共同的基准用于社区。如果说机器学习是我们这一代的太空竞赛,那么数据显然就是我们的火箭燃料;它是强大的,它是有价值的,它是不稳定的,它对于一个正常工作的机器学习系统绝对至关重要。更不用说污染的数据,就像污染的燃料一样,很快就会导致系统性失败。这一章是关于数据的。我们将介绍组织数据的最佳实践,如何检测和清除问题,以及如何高效使用数据。

¹

看看 ImageNet 如何推动目标识别领域,或者 Netflix 挑战对协同过滤做出了什么贡献。

²

感谢 Edd Dumbill 将这个类比归功于“大数据是火箭燃料”,大数据,卷 1,号 2,第 71-72 页。

“但我们不是一直在处理数据吗?”你可能会抗议。没错,在之前的章节中,我们处理过各种数据源。我们使用合成和网络摄像头图像数据集来训练图像模型。我们使用迁移学习从音频样本数据集构建了一个口语识别器,并访问了表格数据集以预测价格。那么还有什么需要讨论的呢?我们不是已经能够熟练处理数据了吗?

在我们之前的例子中回顾我们对数据使用的模式。我们通常需要首先从远程源下载数据。然后我们(通常)对数据应用一些转换,将数据转换为正确的格式,例如将字符串转换为独热词汇向量,或者规范化表格源的均值和方差。然后我们总是需要对数据进行分批处理,并将其转换为表示为张量的标准数字块,然后再连接到我们的模型。这一切都是在我们运行第一个训练步骤之前完成的。

下载 - 转换 - 批处理的模式非常常见,TensorFlow.js 提供了工具,使这些类型的操作更加简单、模块化和不容易出错。本章将介绍 tf.data 命名空间中的工具,最重要的是 tf.data.Dataset,它可以用于惰性地流式传输数据。惰性流式传输的方法允许按需下载、转换和访问数据,而不是将数据源完全下载并在访问时将其保存在内存中。惰性流式传输使得更容易处理数据源,这些数据源太大,无法放入单个浏览器标签页甚至单台机器的 RAM 中。

我们首先介绍 tf.data.Dataset API,并演示如何配置它并与模型连接起来。然后,我们将介绍一些理论和工具,帮助你审查和探索数据,并解决可能发现的问题。本章还介绍了数据增强,这是一种通过创建合成伪示例来扩展数据集,提高模型质量的方法。

6.1. 使用 tf.data 管理数据

如果你的电子邮件数据库有数百 GB 的容量,并且需要特殊凭据才能访问,你该如何训练垃圾邮件过滤器?如果训练图像数据库的规模太大,无法放入单台机器上,你该如何构建图像分类器?

访问和操作大量数据是机器学习工程师的关键技能,但到目前为止,我们所处理的应用程序都可以在可用内存中容纳得下数据。许多应用程序需要处理大型、笨重且可能涉及隐私的数据源,此技术就不适用了。大型应用程序需要一种技术,能够按需逐块从远程数据源中访问数据。

TensorFlow.js 附带了一个集成库,专门用于这种类型的数据管理。它的构建目标是以简洁易读的方式,让用户能够快速地摄取、预处理和路由数据,灵感来自于 TensorFlow Python 版本中的 tf.data API。假设你的代码使用类似于 import 语句来导入 TensorFlow.js,像这样:

import * as tf from '@tensorflow/tfjs';

这个功能在 tf.data 命名空间下可用。

6.1.1. tf.data.Dataset 对象

tfjs-data的大多数交互都通过一种称为Dataset的单一对象类型进行。tf.data.Dataset对象提供了一种简单、可配置和高性能的方式来迭代和处理大型(可能是无限的)数据元素列表^([3])。在最粗略的抽象中,你可以将数据集想象成任意元素的可迭代集合,不太不同于 Node.js 中的Stream。每当需要从数据集中请求下一个元素时,内部实现将根据需要下载、访问或执行函数来创建它。这种抽象使得模型能够在内存中一次性存储的数据量比可以想象的要多。它还使得在有多个要跟踪的数据集时,将数据集作为一流对象进行共享和组织变得更加方便。Dataset通过仅流式传输所需的数据位而不是一次性访问整个数据来提供内存优势。Dataset API 还通过预取即将需要的值来提供性能优化。

³

在本章中,我们将经常使用术语元素来指代Dataset中的项。在大多数情况下,元素示例数据点是同义词——也就是说,在训练数据集中,每个元素都是一个(x, y)对。当从 CSV 源中读取数据时,每个元素都是文件的一行。Dataset足够灵活,可以处理异构类型的元素,但不建议这样做。

6.1.2. 创建tf.data.Dataset

截至 TensorFlow.js 版本 1.2.7,有三种方法可以将tf.data.Dataset连接到某个数据提供程序。我们将对每种方法进行详细介绍,但 table 6.1 中包含了简要摘要。

表 6.1. 从数据源创建一个tf.data.Dataset对象
如何获得新的 tf.data.Dataset API 如何使用它构建数据集
从 JavaScript 数组中获取元素;也适用于像 Float32Array 这样的类型化数组 tf.data.array(items) const dataset = tf.data.array([1,2,3,4,5]); 有关更多信息,请参见 listing 6.1。

| 从(可能是远程)CSV 文件中获取,其中每一行都是一个元素 | tf.data.csv( source,

csvConfig) | const dataset = tf.data.csv(“https://path/to/my.csv”); 有关更多信息,请参见 listing 6.2。唯一必需的参数是从中读取数据的 URL。此外,csvConfig 接受一个带有键的对象来帮助指导 CSV 文件的解析。例如,

  • columnNames—可以提供一个字符串数组来手动设置列的名称,如果它们在标题中不存在或需要被覆盖。
  • delimiter—可以使用单字符字符串来覆盖默认的逗号分隔符。
  • columnConfigs—可以提供一个从字符串列名到 columnConfig 对象的映射,以指导数据集的解析和返回类型。columnConfig 将通知解析器元素的类型(字符串或整数),或者如果列应被视为数据集标签。
  • configuredColumnsOnly—是否仅返回 CSV 中包含的列或仅返回列配置对象中包含的列的数据。

更多详细信息请查阅js.tensorflow.org上的 API 文档。 |

| 从生成元素的通用生成函数 | tf.data.generator(generatorFunction) | function* countDownFrom10() { for (let i=10; i>0; i–) {

yield(i);
}
}
const dataset =

tf.data.generator(countDownFrom10); 详见清单 6.3。请注意,在没有参数的情况下调用 tf.data.generator()时传递给 tf.data.generator()的参数将返回一个 Generator 对象。 |

从数组创建 tf.data.Dataset

创建新的tf.data.Dataset最简单的方法是从一个 JavaScript 数组中构建。假设已经存在一个内存中的数组,您可以使用tf.data.array()函数创建一个由该数组支持的数据集。当然,这不会比直接使用数组带来任何训练速度或内存使用上的好处,但通过数据集访问数组提供了其他重要的好处。例如,使用数据集更容易设置预处理,并通过简单的model.fitDataset()model.evaluateDataset()API 使我们的训练和评估更加简单,就像我们将在第 6.2 节中看到的那样。与model.fit(x, y)相比,model.fitDataset(myDataset)不会立即将所有数据移入 GPU 内存,这意味着可以处理比 GPU 能够容纳的更大的数据集。请注意,V8 JavaScript 引擎的内存限制(64 位系统上为 1.4 GB)通常比 TensorFlow.js 一次可以在 WebGL 内存中容纳的内存要大。使用tf.data API 也是良好的软件工程实践,因为它使得以模块化的方式轻松地切换到另一种类型的数据而无需改变太多代码。如果没有数据集抽象,很容易让数据集源的实现细节泄漏到模型训练中的使用中,这种纠缠将需要在使用不同实现时解开。

要从现有数组构建数据集,请使用tf.data.array(itemsAsArray),如下面的示例所示。

清单 6.1. 从数组构建tf.data.Dataset
const myArray = [{xs: [1, 0, 9], ys: 10},
                   {xs: [5, 1, 3], ys: 11},
                   {xs: [1, 1, 9], ys: 12}];
  const myFirstDataset = tf.data.array(myArray);    ***1***
  await myFirstDataset.forEachAsync(
       e => console.log(e));                        ***2***
// Yields output like
// {xs: Array(3), ys: 10}
// {xs: Array(3), ys: 11}
// {xs: Array(3), ys: 12}
  • 1 创建由数组支持的 tfjs-data 数据集。请注意,这不会克隆数组或其元素。
  • 2 使用forEachAsync()方法迭代数据集提供的所有值。请注意,forEachAsync()是一个异步函数,因此应该在其前面使用 await。

我们使用forEachAsync()函数迭代数据集的元素,该函数依次生成每个元素。有关Dataset.forEachAsync函数的更多详细信息,请参见第 6.1.3 节。

数据集的元素可能包含 JavaScript 基元^([4])(如数字和字符串),以及元组、数组和这些结构的嵌套对象,除了张量。在这个小例子中,数据集的三个元素都具有相同的结构。它们都是具有相同键和相同类型值的对象。tf.data.Dataset通常支持各种类型的元素,但常见的用例是数据集元素是具有相同类型的有意义的语义单位。通常,它们应该表示同一类型的示例。因此,除非在非常不寻常的用例中,每个元素都应具有相同的类型和结构。

如果您熟悉 Python TensorFlow 中 tf.data 的实现,您可能会对 tf.data.Dataset 可以包含 JavaScript 基元以及张量感到惊讶。

从 CSV 文件创建 tf.data.Dataset

一种非常常见的数据集元素类型是表示表的一行的键值对象,例如 CSV 文件的一行。下一个列表显示了一个非常简单的程序,该程序将连接并列出波士顿房屋数据集,我们首先在 chapter 2 中使用过的数据集。

列表 6.2. 从 CSV 文件构建 tf.data.Dataset
const myURL =
      "https://storage.googleapis.com/tfjs-examples/" +
          "multivariate-linear-regression/data/train-data.csv";
  const myCSVDataset = tf.data.csv(myURL);                       ***1***
  await myCSVDataset.forEachAsync(e => console.log(e));          ***2***
// Yields output of 333 rows like
// {crim: 0.327, zn: 0, indus: 2.18, chas: 0, nox: 0.458, rm: 6.998,
// age: 45.8, tax: 222}
// ...
  • 1 创建由远程 CSV 文件支持的 tfjs-data 数据集
  • 2 使用 forEachAsync() 方法在数据集提供的所有值上进行迭代。请注意,forEachAsync() 是一个异步函数。

这里我们使用 tf.data.csv() 而不是 tf.data.array(),并指向 CSV 文件的 URL。这将创建一个由 CSV 文件支持的数据集,并且在数据集上进行迭代将遍历 CSV 行。在 Node.js 中,我们可以通过使用以 file:// 为前缀的 URL 句柄连接到本地 CSV 文件,如下所示:

> const data = tf.data.csv(
     'file://./relative/fs/path/to/boston-housing-train.csv');

在迭代时,我们看到每个 CSV 行都被转换为 JavaScript 对象。从数据集返回的元素是具有 CSV 的每列的一个属性的对象,并且属性根据 CSV 文件中的列名命名。这对于与元素交互非常方便,因为不再需要记住字段的顺序。Section 6.3.1 将详细描述如何处理 CSV 并通过一个例子进行说明。

从生成器函数创建 tf.data.Dataset

创建tf.data.Dataset的第三种最灵活的方式是使用生成器函数构建。这是使用tf.data.generator()方法完成的。tf.data.generator()接受一个 JavaScript 生成器函数(或function*)^([5])作为参数。如果您对生成器函数不熟悉,它们是相对较新的 JavaScript 功能,您可能希望花一点时间阅读它们的文档。生成器函数的目的是在需要时“产出”一系列值,可以是永远或直到序列用尽为止。从生成器函数产生的值将流经并成为数据集的值。一个非常简单的生成器函数可以产生随机数,或者从连接的硬件中提取数据的快照。一个复杂的生成器函数可以与视频游戏集成,产生屏幕截图、得分和控制输入输出。在下面的示例中,非常简单的生成器函数产生骰子掷得的样本。

了解有关 ECMAscript 生成器函数的更多信息,请访问mng.bz/Q0rj

清单 6.3。构建用于随机掷骰子的tf.data.Dataset
let numPlaysSoFar = 0;                            ***1***
  function rollTwoDice() {
    numPlaysSoFar++;
    return [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)];
  }
  function* rollTwoDiceGeneratorFn() {              ***2***
    while(true) {                                   ***2***
      yield rollTwoDice();                          ***2***
    }
  }
  const myGeneratorDataset = tf.data.generator(     ***3***
      rollTwoDiceGeneratorFn);                      ***3***
  await myGeneratorDataset.take(1).forEachAsync(    ***4***
      e => console.log(e));                         ***4***
// Prints to the console a value like
// [4, 2]
  • 1 numPlaysSoFar 被 rollTwoDice()闭合,这使我们可以计算数据集执行该函数的次数。
  • 2定义了一个生成器函数(使用 function*语法),可以无限次调用 rollTwoDice()并产生结果。
  • 3数据集在此处创建。
  • 4获取数据集中仅一个元素的样本。 take()方法将在第 6.1.4 节中描述。

关于在清单 6.3 中创建的游戏模拟数据集,有一些有趣的要点。首先,请注意,这里创建的数据集myGeneratorDataset是无限的。由于生成器函数永远不会返回,我们可以从数据集中无限次取样。如果我们在此数据集上执行forEachAsync()toArray()(参见第 6.1.3 节),它将永远不会结束,并且可能会使我们的服务器或浏览器崩溃,所以要小心。为了使用这样的对象,我们需要创建一些其他数据集,它是无限数据集的有限样本,使用take(n)。稍后会详细介绍。

其次,请注意,数据集会关闭局部变量。这对于记录和调试,以确定生成器函数执行了多少次非常有帮助。

此外,请注意数据直到被请求才存在。在这种情况下,我们只能访问数据集的一项样本,并且这会反映在numPlaysSoFar的值中。

生成器数据集非常强大且非常灵活,允许开发人员将模型连接到各种提供数据的 API,例如来自数据库查询、通过网络逐段下载的数据,或者来自一些连接的硬件。有关tf.data.generator() API 的更多详细信息请参见信息框 6.1。

tf.data.generator()参数规范

tf.data.generator()API 是灵活且强大的,允许用户将模型连接到许多类型的数据提供者。传递给tf.data.generator()的参数必须符合以下规范:

  • 它必须可调用且不带参数。
  • 在不带参数的情况下调用时,它必须返回符合迭代器和可迭代协议的对象。这意味着返回的对象必须具有一个next()方法。当调用next()而没有参数时,它应该返回 JavaScript 对象{value: ELEMENT, done: false},以便将值ELEMENT传递给下一步。当没有更多值可返回时,它应该返回{value: undefined, done: true}

JavaScript 的生成器函数返回Generator对象,符合此规范,因此是使用tf.data.generator()的最简单方法。该函数可能闭包于局部变量,访问本地硬件,连接到网络资源等。

表 6.1 包含以下代码,说明如何使用tf.data.generator()

function* countDownFrom10() {
  for (let i = 10; i > 0; i--) {
    yield(i);
  }
}
const dataset = tf.data.generator(countDownFrom10);

如果出于某种原因希望避免使用生成器函数,而更愿意直接实现可迭代协议,还可以以以下等效方式编写前面的代码:

function countDownFrom10Func() {
  let i = 10;
  return {
    next: () => {
      if (i > 0) {
        return {value: i--, done: false};
      } else {
        return {done: true};
      }
    }
  }
}
const dataset = tf.data.generator(countDownFrom10Func);
6.1.3. 访问数据集中的数据

一旦将数据作为数据集,您必然会想要访问其中的数据。创建但从不读取数据结构实际上并不实用。有两种 API 可以访问数据集中的数据,但tf.data用户应该只需要偶尔使用这些 API。更典型的情况是,高级 API 将为您访问数据集中的数据。例如,在训练模型时,我们使用model.fitDataset()API,如第 6.2 节所述,它会为我们访问数据集中的数据,而我们,用户,从不需要直接访问数据。然而,在调试、测试和理解Dataset对象工作原理时,了解如何查看内容很重要。

从数据集中访问数据的第一种方法是使用Dataset.toArray()将其全部流出到数组中。这个函数的作用正是它的名字听起来的样子。它遍历整个数据集,将所有元素推入数组中,并将该数组返回给用户。用户在执行此函数时应小心,以免无意中生成一个对 JavaScript 运行时来说太大的数组。如果,例如,数据集连接到一个大型远程数据源或是从传感器读取的无限数据集,则很容易犯这个错误。

从数据集中访问数据的第二种方法是使用dataset.forEachAsync(f)在数据集的每个示例上执行函数。提供给forEachAsync()的参数将逐个应用于每个元素,类似于 JavaScript 数组和集合中的forEach()构造——即本地的Array.forEach()Set.forEach()

需要注意的是,Dataset.forEachAsync()Dataset.toArray() 都是异步函数。这与 Array.forEach() 相反,后者是同步的,因此在这里可能很容易犯错。Dataset.toArray() 返回一个 Promise,并且通常需要使用 await.then() 来获取同步行为。要小心,如果忘记使用 await,则 Promise 可能不会按照您期望的顺序解析,从而导致错误。一个典型的错误是数据集看起来为空,因为在 Promise 解析之前已经迭代了其内容。

Dataset.forEachAsync() 是异步的而 Array.forEach() 不是的原因是,数据集正在访问的数据通常需要创建、计算或从远程源获取。异步性使我们能够在等待期间有效地利用可用的计算资源。这些方法在 table 6.2 中进行了总结。

表 6.2. 迭代数据集的方法
tf.data.Dataset 对象的实例方法 功能 示例
| .toArray() | 异步地迭代整个数据集,并将每个元素推送到一个数组中,然后返回该数组 | const a = tf.data.array([1, 2, 3, 4, 5, 6]); const arr = await a.toArray();
console.log(arr);
// 1,2,3,4,5,6 |
| .forEachAsync(f) | 异步地迭代数据集的所有元素,并对每个元素执行 f | const a = tf.data.array([1, 2, 3]); await a.forEachAsync(e => console.log("hi " + e));
// hi 1
// hi 2
// hi 3 |
6.1.4. 操作 tfjs-data 数据集

当我们可以直接使用数据而不需要任何清理或处理时,这当然是非常好的。但在作者的经验中,除了用于教育或基准测试目的构建的示例外,这几乎从未发生。在更常见的情况下,数据必须在某种程度上进行转换,然后才能进行分析或用于机器学习任务。例如,源代码通常包含必须进行过滤的额外元素;或者需要解析、反序列化或重命名某些键的数据;或者数据已按排序顺序存储,因此在使用它来训练或评估模型之前,需要对其进行随机洗牌。也许数据集必须分割成用于训练和测试的非重叠集。预处理几乎是不可避免的。如果你遇到了一个干净且可直接使用的数据集,很有可能是有人已经为你清理和预处理了!

tf.data.Dataset 提供了可链式 API 的方法来执行这些操作,如表 6.3 中所述。每一个这些方法都返回一个新的 Dataset 对象,但不要被误导以为所有数据集元素都被复制或每个方法调用都迭代所有数据集元素!tf.data.Dataset API 只会懒惰地加载和转换元素。通过将这些方法串联在一起创建的数据集可以看作是一个小程序,它只会在从链的末端请求元素时执行。只有在这个时候,Dataset 实例才会爬回操作链,可能一直爬回到请求来自远程源的数据。

表 6.3. tf.data.Dataset 对象上的可链式方法
tf.data.Dataset 对象的实例方法 它的作用 示例
.filter(谓词) 返回一个仅包含谓词为真的元素的数据集 myDataset.filter(x => x < 10); 返回一个数据集,仅包含 myDataset 中小于 10 的值。
.map(转换) 将提供的函数应用于数据集中的每个元素,并返回一个新数据集,其中包含映射后的元素 myDataset.map(x => x * x); 返回一个数据集,包含原始数据集的平方值。
.mapAsync(异步转换) 类似 map,但提供的函数必须是异步的 myDataset.mapAsync(fetchAsync); 假设 fetchAsync 是一个异步函数,可以从提供的 URL 获取数据,将返回一个包含每个 URL 数据的新数据集。

| .batch(批次大小,

smallLastBatch?) | 将连续的元素跨度捆绑成单一元素组,并将原始元素转换为张量 | const a = tf.data.array([1, 2, 3, 4, 5, 6, 7, 8])

.batch(4);
await a.forEach(e => e.print());
// 输出:
// 张量 [1, 2, 3, 4]
// 张量 [5, 6, 7, 8] |
.concatenate(数据集) 将两个数据集的元素连接在一起形成一个新的数据集 myDataset1.concatenate(myDataset2) 返回一个数据集,首先迭代 myDataset1 中的所有值,然后迭代 myDataset2 中的所有值。
.repeat(次数) 返回一个将多次(可能无限次)迭代原始数据集的 dataset myDataset.repeat(NUM_EPOCHS) 返回一个 dataset,将迭代 myDataset 中所有的值 NUM_EPOCHS 次。如果 NUM_EPOCHS 为负数或未定义,则结果将无限次迭代。
.take(数量) 返回一个仅包含前数量个示例的数据集 myDataset.take(10); 返回一个仅包含 myDataset 的前 10 个元素的数据集。如果 myDataset 中的元素少于 10 个,则没有变化。
.skip(count) 返回一个跳过前 count 个示例的数据集 myDataset.skip(10); 返回一个包含 myDataset 中除了前 10 个元素之外所有元素的数据集。如果 myDataset 包含 10 个或更少的元素,则返回一个空数据集。
| .shuffle( bufferSize,
种子?
) | 生成原始数据集元素的随机洗牌数据集 注意:此洗牌是通过在大小为 bufferSize 的窗口内随机选择来完成的;因此,超出窗口大小的排序被保留。| const a = tf.data.array( [1, 2, 3, 4, 5, 6]).shuffle(3);
await a.forEach(e => console.log(e));
// 输出,例如,2, 4, 1, 3, 6, 5 以随机洗牌顺序输出 1 到 6 的值。洗牌是部分的,因为不是所有的顺序都是可能的,因为窗口比总数据量小。例如,最后一个元素 6 现在成为新顺序中的第一个元素是不可能的,因为 6 需要向后移动超过 bufferSize(3)个空间。|

这些操作可以链接在一起创建简单但强大的处理管道。例如,要将数据集随机分割为训练和测试数据集,可以按照以下列表中的步骤操作(参见 tfjs-examples/iris-fitDataset/data.js)。

列表 6.4. 使用 tf.data.Dataset 创建训练/测试分割
const seed = Math.floor(
      Math.random() * 10000);                     ***1***
  const trainData = tf.data.array(IRIS_RAW_DATA)
       .shuffle(IRIS_RAW_DATA.length, seed);      ***1***
       .take(N);                                  ***2***
       .map(preprocessFn);
  const testData = tf.data.array(IRIS_RAW_DATA)
       .shuffle(IRIS_RAW_DATA.length, seed);      ***1***
       .skip(N);                                  ***3***
       .map(preprocessFn);
  • 1 我们在训练和测试数据中使用相同的洗牌种子;否则它们将独立洗牌,并且一些样本将同时出现在训练和测试中。
  • 2 获取训练数据的前 N 个样本
  • 3 跳过测试数据的前 N 个样本

在这个列表中有一些重要的考虑事项需要注意。我们希望将样本随机分配到训练和测试集中,因此我们首先对数据进行洗牌。我们取前 N 个样本作为训练数据。对于测试数据,我们跳过这些样本,取剩下的样本。当我们取样本时,数据以 相同的方式 进行洗牌非常重要,这样我们就不会在两个集合中都有相同的示例;因此当同时采样两个管道时,我们使用相同的随机种子。

还要注意,我们在跳过操作之后应用 map() 函数。也可以在跳过之前调用 .map(preprocessFn),但是那样 preprocessFn 就会对我们丢弃的示例执行——这是一种计算浪费。可以使用以下列表来验证这种行为。

列表 6.5. 说明 Dataset.forEach skip()map() 交互
let count = 0;
  // Identity function which also increments count.
  function identityFn(x) {
    count += 1;
    return x;
  }
  console.log('skip before map');
  await tf.data.array([1, 2, 3, 4, 5, 6])
      .skip(6)                            ***1***
    .map(identityFn)
    .forEachAsync(x => undefined);
  console.log(`count is ${count}`);
  console.log('map before skip');
  await tf.data.array([1, 2, 3, 4, 5, 6])
      .map(identityFn)                    ***2***
    .skip(6)
    .forEachAsync(x => undefined);
  console.log(`count is ${count}`);
// Prints:
// skip before map
// count is 0
// map before skip
// count is 6
  • 1 先跳过再映射
  • 2 先映射再跳过

dataset.map() 的另一个常见用法是对输入数据进行归一化。我们可以想象一种情况,我们希望将输入归一化为零均值,但我们有无限数量的输入样本。为了减去均值,我们需要先计算分布的均值,但是计算无限集合的均值是不可行的。我们还可以考虑取一个代表性的样本,并计算该样本的均值,但如果我们不知道正确的样本大小,就可能犯错误。考虑一个分布,几乎所有的值都是 0,但每一千万个示例的值为 1e9。这个分布的均值是 100,但如果你在前 100 万个示例上计算均值,你就会相当偏离。

我们可以使用数据集 API 进行流式归一化,方法如下(示例 6.6)。在此示例中,我们将跟踪我们已经看到的样本数量以及这些样本的总和。通过这种方式,我们可以进行流式归一化。此示例操作标量(不是张量),但是针对张量设计的版本具有类似的结构。

示例 6.6. 使用 tf.data.map() 进行流式归一化
function newStreamingZeroMeanFn() {    ***1***
    let samplesSoFar = 0;
    let sumSoFar = 0;
    return (x) => {
      samplesSoFar += 1;
      sumSoFar += x;
      const estimatedMean = sumSoFar / samplesSoFar;
      return x - estimatedMean;
    }
  }
  const normalizedDataset1 =
     unNormalizedDataset1.map(newStreamingZeroMeanFn());
  const normalizedDataset2 =
     unNormalizedDataset2.map(newStreamingZeroMeanFn());
  • 1 返回一个一元函数,它将返回输入减去迄今为止其所有输入的均值。

请注意,我们生成了一个新的映射函数,它在自己的样本计数器和累加器上关闭。这是为了允许多个数据集独立归一化。否则,两个数据集将使用相同的变量来计数调用和求和。这种解决方案并不是没有限制,特别是在 sumSoFarsamplesSoFar 中可能发生数值溢出的情况下,需要谨慎处理。

6.2. 使用 model.fitDataset 训练模型

流式数据集 API 不错,我们已经看到它可以让我们进行一些优雅的数据操作,但是 tf.data API 的主要目的是简化将数据连接到模型进行训练和评估的过程。那么 tf.data 如何帮助我们呢?

自第二章以来,每当我们想要训练一个模型时,我们都会使用 model.fit() API。回想一下,model.fit()至少需要两个必要参数 - xsys。作为提醒,xs 变量必须是一个表示一系列输入示例的张量。ys 变量必须绑定到表示相应输出目标集合的张量。例如,在上一章的 示例 5.11 中,我们使用类似的调用对我们的合成目标检测模型进行训练和微调。

model.fit(images, targets, modelFitArgs)

其中images默认情况下是一个形状为[2000, 224, 224, 3]的秩为 4 的张量,表示一组 2000 张图像。modelFitArgs配置对象指定了优化器的批处理大小,默认为 128。回顾一下,我们可以看到 TensorFlow.js 被提供了一个内存中^([6])的包含 2000 个示例的集合,表示整个数据集,然后循环遍历该数据,每次以 128 个示例为一批完成每个时期的训练。

GPU内存中,通常比系统 RAM 有限!

如果这些数据不足,我们想要用更大的数据集进行训练怎么办?在这种情况下,我们面临着一对不太理想的选择。选项 1 是加载一个更大的数组,然后看看它是否起作用。然而,到某个时候,TensorFlow.js 会耗尽内存,并发出一条有用的错误消息,指示它无法为训练数据分配存储空间。选项 2 是我们将数据上传到 GPU 中的不同块中,并在每个块上调用model.fit()。我们需要执行自己的model.fit()协调,每当准备好时,就对我们的训练数据的部分进行迭代训练我们的模型。如果我们想要执行多个时期,我们需要回到一开始,以某种(可能是打乱的)顺序重新下载我们的块。这种协调不仅繁琐且容易出错,而且还干扰了 TensorFlow 自己的时期计数器和报告的指标,我们将被迫自己将它们拼接在一起。

Tensorflow.js 为我们提供了一个更方便的工具,使用model.fitDataset()API:

model.fitDataset(dataset, modelFitDatasetArgs)

model.fitDataset() 的第一个参数接受一个数据集,但数据集必须符合特定的模式才能工作。具体来说,数据集必须产生具有两个属性的对象。第一个属性名为xs,其值为Tensor类型,表示一批示例的特征;这类似于model.fit()中的xs参数,但数据集一次产生一个批次的元素,而不是一次产生整个数组。第二个必需的属性名为ys,包含相应的目标张量。^([7]) 与model.fit()相比,model.fitDataset()提供了许多优点。首先,我们不需要编写代码来管理和协调数据集的下载——这在需要时以一种高效、按需流式处理的方式为我们处理。内置的缓存结构允许预取预期需要的数据,有效利用我们的计算资源。这个 API 调用也更强大,允许我们在比 GPU 容量更大得多的数据集上进行训练。事实上,我们可以训练的数据集大小现在只受到我们拥有多少时间的限制,因为只要我们能够获得新的训练样本,我们就可以继续训练。这种行为在 tfjs-examples 存储库中的数据生成器示例中有所体现。

对于具有多个输入的模型,期望的是张量数组而不是单独的特征张量。对于拟合多个目标的模型,模式类似。

在此示例中,我们将训练一个模型来学习如何估计获胜的可能性,一个简单的游戏机会。和往常一样,您可以使用以下命令来查看和运行演示:

git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/data-generator
yarn
yarn watch

此处使用的游戏是一个简化的纸牌游戏,有点像扑克牌。每个玩家都会被分发 N 张牌,其中 N 是一个正整数,并且每张牌由 1 到 13 之间的随机整数表示。游戏规则如下:

  • 拥有相同数值牌最多的玩家获胜。例如,如果玩家 1 有三张相同的牌,而玩家 2 只有一对,玩家 1 获胜。
  • 如果两名玩家拥有相同大小的最大组,则拥有最大面值组的玩家获胜。例如,一对 5 比一对 4 更大。
  • 如果两名玩家甚至都没有一对牌,那么拥有最高单张牌的玩家获胜。
  • 平局将随机解决,50/50。

要说服自己,每个玩家都有平等的获胜机会应该很容易。因此,如果我们对自己的牌一无所知,我们应该只能猜测我们是否会赢得比赛的时间一半。我们将构建和训练一个模型,该模型以玩家 1 的牌作为输入,并预测该玩家是否会获胜。在图 6.1 的屏幕截图中,您应该看到我们在大约 250,000 个示例(50 个周期 * 每周期 50 个批次 * 每个批次 100 个样本)训练后,在这个问题上达到了约 75% 的准确率。在这个模拟中,每手使用五张牌,但对于其他数量也可以达到类似的准确率。通过使用更大的批次和更多的周期,可以实现更高的准确率,但是即使在 75% 的情况下,我们的智能玩家也比天真的玩家在估计他们将获胜的可能性时具有显著的优势。

图 6.1. 数据生成器示例的用户界面。游戏规则的描述和运行模拟的按钮位于左上方。在此下方是生成的特征和数据管道。Dataset-to-Array 按钮运行链接数据集操作,模拟游戏,生成特征,将样本批次化,取 N 个这样的批次,将它们转换为数组,并将数组打印出来。右上角有用于使用此数据管道训练模型的功能。当用户点击 Train-Model-Using-Fit-Dataset 按钮时,model.fitDataset() 操作接管并从管道中提取样本。下方打印了损失和准确率曲线。在右下方,用户可以输入玩家 1 的手牌值,并按下按钮从模型中进行预测。更大的预测表明模型认为该手牌更有可能获胜。值是有替换地抽取的,因此可能会出现五张相同的牌。

如果我们使用 model.fit() 执行此操作,那么我们需要创建和存储一个包含 250,000 个示例的张量,以表示输入特征。这个例子中的数据相当小——每个实例只有几十个浮点数——但是对于上一章中的目标检测任务,250,000 个示例将需要 150 GB 的 GPU 内存,^([8]) 远远超出了 2019 年大多数浏览器的可用范围。

numExamples × width × height × colorDepth × sizeOfInt32 = 250,000 × 224 × 224 × 3 × 4 bytes 。

让我们深入了解此示例的相关部分。首先,让我们看一下如何生成我们的数据集。下面清单中的代码(从 tfjs-examples/data-generator/index.js 简化而来)与 清单 6.3 中的掷骰子生成器数据集类似,但更复杂一些,因为我们存储了更多信息。

清单 6.7. 为我们的卡片游戏构建 tf.data.Dataset
import * as game from './game';                    ***1***
  let numSimulationsSoFar = 0;
  function runOneGamePlay() {
    const player1Hand = game.randomHand();           ***2***
    const player2Hand = game.randomHand();           ***2***
    const player1Win = game.compareHands(            ***3***
        player1Hand, player2Hand);                   ***3***
    numSimulationsSoFar++;
    return {player1Hand, player2Hand, player1Win};   ***4***
  }
  function* gameGeneratorFunction() {
    while (true) {
      yield runOneGamePlay();
    }
  }
  export const GAME_GENERATOR_DATASET =
     tf.data.generator(gameGeneratorFunction);
  await GAME_GENERATOR_DATASET.take(1).forEach(
      e => console.log(e));
// Prints
// {player1Hand: [11, 9, 7, 8],
// player2Hand: [10, 9, 5, 1],
// player1Win: 1}
  • 1 游戏库提供了 randomHand()compareHands() 函数,用于从简化的类似扑克牌的卡片游戏生成手牌,以及比较两个这样的手牌以确定哪位玩家赢了。
  • 2 模拟简单的类似扑克牌的卡片游戏中的两名玩家
  • 3 计算游戏的赢家
  • 4 返回两名玩家的手牌以及谁赢了

一旦我们将基本生成器数据集连接到游戏逻辑,我们希望以对我们的学习任务有意义的方式格式化数据。具体来说,我们的任务是尝试从 player1Hand 预测 player1Win 位。为了做到这一点,我们需要使我们的数据集返回形式为 [batchOf-Features, batchOfTargets] 的元素,其中特征是从玩家 1 的手中计算出来的。下面的代码简化自 tfjs-examples/data-generator/index.js。

清单 6.8. 构建玩家特征的数据集
function gameToFeaturesAndLabel(gameState) {              ***1***
     return tf.tidy(() => {
      const player1Hand = tf.tensor1d(gameState.player1Hand, 'int32');
      const handOneHot = tf.oneHot(
          tf.sub(player1Hand, tf.scalar(1, 'int32')),
          game.GAME_STATE.max_card_value);
      const features = tf.sum(handOneHot, 0);               ***2***
      const label = tf.tensor1d([gameState.player1Win]);
      return {xs: features, ys: label};
    });
  }
  let BATCH_SIZE = 50;
  export const TRAINING_DATASET =
     GAME_GENERATOR_DATASET.map(gameToFeaturesAndLabel)     ***3***
                                 .batch(BATCH_SIZE);        ***4***
  await TRAINING_DATASET.take(1).forEach(
      e => console.log([e.shape, e.shape]));
// Prints the shape of the tensors:
// [[50, 13], [50, 1]]
  • 1 获取一局完整游戏的状态,并返回玩家 1 手牌的特征表示和获胜状态
  • 2 handOneHot 的形状为 [numCards, max_value_card]。此操作对每种类型的卡片进行求和,结果是形状为 [max_value_card] 的张量。
  • 3 将游戏输出对象格式的每个元素转换为两个张量的数组:一个用于特征,一个用于目标
  • 4 将 BATCH_SIZE 个连续元素分组成单个元素。如果它们尚未是张量,则还会将数据从 JavaScript 数组转换为张量。

现在我们有了一个符合规范的数据集,我们可以使用 model.fitDataset() 将其连接到我们的模型上,如下清单所示(简化自 tfjs-examples/data-generator/index.js)。

清单 6.9. 构建并训练数据集的模型
// Construct model.
  model = tf.sequential();
  model.add(tf.layers.dense({
    inputShape: [game.GAME_STATE.max_card_value],
    units: 20,
    activation: 'relu'
  }));
  model.add(tf.layers.dense({units: 20, activation: 'relu'}));
  model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));
  // Train model
  await model.fitDataset(TRAINING_DATASET, {                              ***1***
    batchesPerEpoch: ui.getBatchesPerEpoch(),                             ***2***
    epochs: ui.getEpochsToTrain(),
    validationData: TRAINING_DATASET,                                     ***3***
    validationBatches: 10,                                                ***4***
    callbacks: {
      onEpochEnd: async (epoch, logs) => {
        tfvis.show.history(
            ui.lossContainerElement, trainLogs, ['loss', 'val_loss'])
        tfvis.show.history(                                               ***5***
            ui.accuracyContainerElement, trainLogs, ['acc', 'val_acc'],
            {zoomToFitAccuracy: true})
      },
    }
  }
  • 1 此调用启动训练。
  • 2 一个 epoch 包含多少批次。由于我们的数据集是无限的,因此需要定义此参数,以告知 TensorFlow.js 何时执行 epoch 结束回调。
  • 3 我们将训练数据用作验证数据。通常这是不好的,因为我们会对我们的表现有偏见。但在这种情况下,由于训练数据和验证数据是由生成器保证独立的,所以这不是问题。
  • 4 我们需要告诉 TensorFlow.js 从验证数据集中取多少样本来构成一个评估。
  • 5 model.fitDataset() 创建与 tfvis 兼容的历史记录,就像 model.fit() 一样。

正如我们在前面的清单中看到的,将模型拟合到数据集与将模型拟合到一对 x、y 张量一样简单。只要我们的数据集以正确的格式产生张量值,一切都能正常工作,我们能从可能是远程来源的流数据中获益,而且我们不需要自己管理编排。除了传入数据集而不是张量对之外,在配置对象中还有一些差异值得讨论:

  • batchesPerEpoch—正如我们在 清单 6.9 中看到的,model.fitDataset() 的配置接受一个可选字段来指定构成一个周期的批次数。当我们把整个数据交给 model.fit() 时,计算整个数据集中有多少示例很容易。它就是 data.shape[0]!当使用 fitDataset() 时,我们可以告诉 TensorFlow.js 一个周期何时结束有两种方法。第一种方法是使用这个配置字段,fitDataset() 将在那么多批次之后执行 onEpochEndonEpochStart 回调。第二种方法是让数据集本身结束作为信号,表明数据集已经耗尽。在 清单 6.7 中,我们可以改变
while (true) { ... }
for (let i = 0; i<ui.getBatchesPerEpoch(); i++) { ... }
  • 模仿这种行为。
  • validationData—当使用 fitDataset() 时,validationData 也可以是一个数据集。但不是必须的。如果你想要的话,你可以继续使用张量作为 validationData。验证数据集需要符合返回元素格式的相同规范,就像训练数据集一样。
  • validationBatches—如果你的验证数据来自一个数据集,你需要告诉 TensorFlow.js 从数据集中取多少样本来构成一个完整的评估。如果没有指定值,那么 TensorFlow.js 将继续从数据集中提取,直到返回一个完成信号。因为 清单 6.7 中的代码使用一个永不结束的生成器来生成数据集,这永远不会发生,程序会挂起。

其余配置与 model.fit() API 完全相同,因此不需要进行任何更改。

JavaScript 深度学习(二)(5)https://developer.aliyun.com/article/1516957

相关文章
|
24天前
|
机器学习/深度学习 自然语言处理 JavaScript
JavaScript 深度学习(三)(4)
JavaScript 深度学习(三)
36 2
JavaScript 深度学习(三)(4)
|
24天前
|
机器学习/深度学习 数据可视化 JavaScript
JavaScript 深度学习(三)(1)
JavaScript 深度学习(三)
29 2
JavaScript 深度学习(三)(1)
|
24天前
|
机器学习/深度学习 JavaScript 前端开发
JavaScript 深度学习(二)(5)
JavaScript 深度学习(二)
30 2
|
24天前
|
机器学习/深度学习 JavaScript 前端开发
JavaScript 深度学习(五)(5)
JavaScript 深度学习(五)
8 0
|
24天前
|
机器学习/深度学习 JavaScript 前端开发
JavaScript 深度学习(五)(4)
JavaScript 深度学习(五)
24 0
|
24天前
|
存储 机器学习/深度学习 JavaScript
JavaScript 深度学习(五)(3)
JavaScript 深度学习(五)
22 0
|
24天前
|
机器学习/深度学习 并行计算 JavaScript
JavaScript 深度学习(五)(2)
JavaScript 深度学习(五)
29 0
|
24天前
|
机器学习/深度学习 人工智能 JavaScript
JavaScript 深度学习(五)(1)
JavaScript 深度学习(五)
25 0
|
24天前
|
机器学习/深度学习 前端开发 JavaScript
JavaScript 深度学习(四)(5)
JavaScript 深度学习(四)
12 1
|
24天前
|
机器学习/深度学习 存储 算法
JavaScript 深度学习(四)(4)
JavaScript 深度学习(四)
22 1