JavaScript 深度学习(五)(4)

简介: JavaScript 深度学习(五)

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

B.2.基础张量操作

如果我们无法在张量上执行操作,那么张量就不会有什么用处。TensorFlow.js 支持大量的张量操作。您可以在 js.tensorflow.org/api/latest 查看它们的列表以及它们的文档。描述每一个操作都会很枯燥而冗余。因此,我们将突出一些常用的操作作为示例。常用操作可以分为两种类型:一元和二元。一元运算以一个张量作为输入并返回一个新张量,而二元运算以两个张量作为输入并返回一个新张量。

B.2.1.一元操作

让我们考虑将张量取反的操作——即使用每个输入张量元素的负值——并形成一个具有相同形状和数据类型的新张量。这可以使用 tf.neg() 完成:

> const x = tf.tensor1d([-1, 3, 7]);
> const y = tf.neg(x);
> y.print();
Tensor
    [1, -3, -7]
函数式 API 与链式 API

在上一个示例中,我们使用张量x作为输入参数调用函数tf.neg()。TensorFlow.js 提供了一种更简洁的执行数学等价操作的方法:使用张量对象本身的neg()方法,而不是tf.*命名空间下的函数:

> const y = x.neg();

在这个简单的例子中,由于新 API 的存在,由于键入次数较少而节省的打字量可能不会显得那么令人印象深刻。然而,在需要一个接一个地应用多个操作的情况下,第二个 API 将比第一个 API 表现出更大的优势。例如,考虑一个假设的算法,您想要将x取反,计算倒数(每个元素都被 1 除),并在上面应用relu激活函数。这是在第一个 API 中实现算法所需的代码:

> const y = tf.relu(tf.reciprocal(tf.neg(x)));

相比之下,在第二个 API 中,实现代码如下:

> const y = x.neg().reciprocal().relu();

第二种实现在以下几个方面优于第一种实现:

  • 字符较少,输入更少,因此制造错误的机会更小。
  • 没有必要平衡嵌套的开放和关闭括号(尽管大多数现代代码编辑器都会帮助您完成此操作)。
  • 更重要的是,方法出现在代码中的顺序与底层数学操作发生的顺序相匹配。(注意在第一种实现中,顺序被颠倒了。)这在第二种实现中会导致更好的代码可读性。

我们将第一个 API 称为函数式API,因为它基于在tf.命名空间下调用函数。第二个 API 将称为链式API,因为操作按照链式顺序出现(正如您在前面的示例中所看到的)。在 TensorFlow.js 中,大多数操作都可以作为tf.*命名空间下的函数版本和作为张量对象方法的链式版本来访问。您可以根据自己的需求选择这两个 API。在本书中,我们在不同的地方同时使用这两个 API,但对于涉及连续操作的情况,我们更偏向于使用链式 API。

元素级与约减操作

我们提到的一元操作的示例(tf.neg()tf.reciprocal()tf.relu())具有一个共同特点,即操作独立地应用在输入张量的每个元素上。因此,这类操作返回的张量保留了输入张量的形状。然而,在 TensorFlow.js 中,其他一元操作会导致张量形状比原来的更小。在张量形状的背景下,“更小” 是什么意思呢?在某些情况下,它表示较低的秩。例如,一元操作可能返回一个标量(秩为 0 的张量),而原来的张量是一个 3D 张量(秩为 3)。在其他情况下,它表示某个维度的大小比原来的更小。例如,一元操作可能对一个形状为[3, 20]的输入返回一个形状为[3, 1]的张量。无论形状如何收缩,这些操作都被称为约减操作

tf.mean()是最常用的约减操作之一。在链式 API 中,它作为Tensor类的mean()方法出现。当没有附加参数时,它计算输入张量的所有元素的算术平均值,而无论其形状如何,并返回一个标量。在链式 API 中,使用它的方式如下所示:

> const x = tf.tensor2d([[0, 10], [20, 30]]);
> x.mean().print();
Tensor
    15

有时,我们需要单独计算 2D 张量(矩阵)的每一行的均值,而不是整个张量上的均值。可以通过向mean()方法提供附加参数来实现:

> x.mean(-1).print();
Tensor
    [5, 25]

参数-1表示mean()方法应该计算张量的最后一个维度的算术平均值。([4]) 这个维度被称为约减维度,因为它将在输出张量中被“减少”,输出张量的秩变为 1。指定约减维度的另一种方式是使用实际的维度索引:

这遵循了 Python 的索引约定。

> x.mean(1).print();

请注意,mean()还支持多个约减维度。例如,如果您有一个形状为[10, 6, 3]的 3D 张量,并且希望计算其算术平均值在最后两个维度上进行计算,得到一个形状为[10]的 1D 张量,则可以调用mean()方法,如x.mean([-2, -1])x.mean([1, 2])。我们在附录的最后给出这个方法作为一个练习。

其他经常使用的约简一元操作包括

  • tf.sum() 几乎与 tf.mean() 相同,但它计算的是和,而不是算术平均值,而是元素。
  • tf.norm(),用于计算元素的范数。有不同类型的范数。例如,1-范数是元素绝对值的总和。2-范数通过对平方元素求和然后取平方根来计算。换句话说,它是欧几里德空间中向量的长度。tf.norm()可用于计算一组数字的方差或标准差。
  • tf.min()tf.max(),分别计算元素的最小值和最大值。
  • tf.argMax() 返回沿减少轴的最大元素的索引。此操作经常用于将分类模型的概率输出转换为获胜类别的索引(例如,请参阅第 3.3.2 节中的鸢尾花分类问题)。tf.argMin() 提供了类似的功能以找到最小值。

我们提到逐元素操作保留输入张量的形状。但反之不成立。有些保持形状的操作不是逐元素的。例如,tf.transpose() 操作可以执行矩阵转置,其中输入 2D 张量中的索引 [i, j] 的元素被映射到输出 2D 张量中的索引 [j, i]。如果输入是一个方阵,tf.transpose() 的输入和输出形状将相同,但这不是一个逐元素操作,因为输出张量中 [i, j] 处的值不仅取决于输入张量中 [i, j] 处的值,而是取决于其他索引处的值。

B.2.2. 二元操作

与一元操作不同,二元操作需要两个输入参数。tf.add() 可能是最常用的二元操作。它可能也是最简单的,因为它只是简单地将两个张量相加。例如,

> const x = tf.tensor2d([[0, 2], [4, 6]]);
> const y = tf.tensor2d([[10, 20], [30, 46]]);
> tf.add(x, y).print();
Tensor
    [[10, 22],
     [34, 52]]

类似的二元操作包括

  • tf.sub() 用于两个张量的减法
  • tf.mul() 用于两个张量的乘法
  • tf.matMul() 用于计算两个张量之间的矩阵积
  • tf.logicalAnd()tf.logicalOr()tf.logicalXor() 用于在布尔类型张量上执行 AND、OR 和 XOR 操作。

一些二元操作支持 广播,或者对不同形状的两个输入张量进行操作,并根据某种规则将较小形状的输入元素应用于另一个输入的多个元素。详细讨论请参阅信息框 2.4 中的第二章。

B.2.3. 张量的拼接和切片

一元和二元操作是张量输入张量输出(TITO)的,它们以一个或多个张量作为输入,并返回一个张量作为输出。 TensorFlow.js 中的一些常用操作不是 TITO,因为它们将张量与另一个非张量参数一起作为输入。tf.concat()可能是这类函数中最常用的函数。它允许你将多个形状兼容的张量连接成一个单一的张量。只有当张量的形状满足某些约束条件时才能进行连接。例如,可以将[5, 3]张量和[4, 3]张量沿第一个轴合并以得到[9, 3]张量,但如果它们的形状分别为[5, 3][4, 2],则无法将它们组合在一起!在给定的形状合法性的情况下,你可以使用tf.concat()函数来连接张量。例如,以下代码沿第一个轴连接一个全零[2, 2]张量和一个全一[2, 2]张量,则得到一个[4, 2]张量,其中“顶部”是全零,而“底部”则是全一:

> const x = tf.zeros([2, 2]);
> const y = tf.ones([2, 2]);
> tf.concat([x, y]).print();
Tensor
    [[0, 0],
     [0, 0],
     [1, 1],
     [1, 1]]

由于两个输入张量的形状相同,可以以不同的方式对它们进行连接:即沿第二个轴进行连接。轴可以作为第二个输入参数传递给tf.concat()。这将给我们一个[2, 4]张量,在这个张量中,左半部分都是零,右半部分都是一:

> tf.concat([x, y], 1).print();
Tensor
    [[0, 0, 1, 1],
     [0, 0, 1, 1]]

除了将多个张量连接成一个以外,有时我们希望执行“反向”操作,检索张量的一部分。例如,假设你创建了一个形状为[3, 2]的二维张量(矩阵),

> const x = tf.randomNormal([3, 2]);
> x.print();
Tensor
    [[1.2366893 , 0.6011682 ],
     [-1.0172369, -0.5025602],
     [-0.6265425, -0.0009868]]

而你想要获取矩阵的第二行。为此,可以使用tf.slice()的链式版本:

> x.slice([1, 0], [1, 2]).print();
Tensor
     [[-1.0172369, -0.5025602],]

slice()的第一个参数指示我们想要的输入张量部分从第一个维度的索引 1 和第二个维度的索引 0 开始。换句话说,它应该从第二行和第一列开始,因为我们在这里处理的二维张量是一个矩阵。第二个参数指定所需输出的形状:[1, 2]或在矩阵语言中,1 行 2 列。

如您所看到的,通过查看打印的值,我们成功地检索了 3×2 矩阵的第二行。输出的形状与输入的秩相同(2),但第一个维度的大小为 1。在这种情况下,我们检索第二个维度的全部(所有列)和第一个维度的子集(行的一部分)。这是一种特殊情况,可以使用更简单的语法实现相同的效果:

> x.slice(1, 1).print();
Tensor
     [[-1.0172369, -0.5025602],]

在这个更简单的语法中,我们只需要指定请求的块沿第一个维度的起始索引和大小。如果将第二个输入参数传递为 1 而不是 2,则输出将包含矩阵的第一行和第二行:

> x.slice(1, 2).print();
Tensor
    [[-1.0172369, -0.5025602],
     [-0.6265425, -0.0009868]]

正如你可能猜到的那样,这种更简单的语法与批处理约定有关。它使得从批处理张量中获取单个示例的数据更容易。

但是如果我们想要访问矩阵的 呢?在这种情况下,我们将不得不使用更复杂的语法。例如,假设我们想要矩阵的第二列。可以通过以下方式实现

> x.slice([0, 1], [-1, 1]).print();
Tensor
    [[0.6011682 ],
     [-0.5025602],
     [-0.0009868]]

这里,第一个参数([0, 1])是表示我们想要的切片的起始索引的数组。它是沿第一维的第一个索引和第二维的第二个索引。更简单地说,我们希望我们的切片从第一行和第二列开始。第二个参数([-1, 1])指定了我们想要的切片的大小。第一个数字(-1)表示我们想要沿第一维的所有索引(我们想要所有起始行),而第二个数字(1)表示我们只想要沿第二维的一个索引(我们只想要一列)。结果是矩阵的第二列。

看一下 slice() 的语法,你可能已经意识到 slice() 不仅限于检索行或列。事实上,如果开始索引和大小数组被正确指定,它足够灵活,可以让你检索输入二维张量中的任何“子矩阵”(矩阵内的任何连续矩形区域)。更一般地,对于秩大于 0 的张量,slice() 允许你检索输入张量中的任何连续子张量。我们将这留作附录末尾的练习。

除了 tf.slice()tf.concat(),另外两个经常用于将张量分割成部分或将多个张量合并成一个的操作是 tf.unstack()tf.stack()tf.unstack() 将张量沿着第一维分割成多个“pieces”。每个片段在第一维上的尺寸为 1。例如,我们可以使用 tf.unstack() 的链式 API:

> const x = tf.tensor2d([[1, 2], [3, 4], [5, 6]]);
> x.print();
Tensor
    [[1, 2],
     [3, 4],
     [5, 6]]
> const pieces = x.unstack();
> console.log(pieces.length);
  3
> pieces[0].print();
Tensor
    [1, 2]
> pieces[1].print();
Tensor
    [3, 4]
> pieces[2].print();
Tensor
    [5, 6]

你可能已经注意到,unstack() 返回的“pieces”比输入张量的秩少一。

tf.stack()tf.unstack() 的反向操作。顾名思义,它将具有相同形状的多个张量“堆叠”到一个新张量中。根据先前的示例代码片段,我们将片段重新堆叠在一起:

> tf.stack(pieces).print();
Tensor
    [[1, 2],
     [3, 4],
     [5, 6]]

tf.unstack() 用于从批处理张量中获取与各个示例对应的数据;tf.stack() 用于将各个示例的数据合并成一个批处理张量。

B.3. TensorFlow.js 中的内存管理:tf.dispose()tf.tidy()

在 TensorFlow.js 中,如果你直接处理张量对象,你需要对它们执行内存管理。特别是在创建和使用张量后,张量需要被释放,否则它将继续占用分配给它的内存。如果未释放的张量数量过多或者总大小过大,它们最终将导致浏览器标签页耗尽 WebGL 内存或导致 Node.js 进程耗尽系统或 GPU 内存(取决于是否使用 tfjs-node 的 CPU 或 GPU 版本)。TensorFlow.js 不会自动对用户创建的张量进行垃圾回收。[5] 这是因为 JavaScript 不支持对象终结。TensorFlow.js 提供了两个内存管理函数:tf.dispose()tf.tidy()

然而,在 TensorFlow.js 函数和对象方法中创建的张量由库本身管理,因此你不需要担心在调用这些函数或方法时包装它们在tf.tidy()中。其中的示例函数包括tf.confusionMatrix()tf.Model.predict()tf.Model.fit()

例如,考虑使用for循环对 TensorFlow.js 模型进行重复推理的示例:

const model = await tf.loadLayersModel(                                    ***1***
    'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json'); ***1***
const x = tf.randomUniform([1, 4]);                                        ***2***
for (let i = 0; i < 3; ++i) {
  const y = model.predict(x);
  y.print();
  console.log(`# of tensors: ${tf.memory().numTensors}` );                 ***3***
}
  • 1 从网络上加载预先训练好的模型
  • 2 创建一个虚拟输入张量
  • 3 检查当前已分配张量的数量

输出将如下所示

Tensor
     [[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 14
Tensor
     [[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 15
Tensor
     [[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 16

正如你在控制台日志中看到的那样,每次调用model.predict()都会生成一个额外的张量,在迭代结束后不会被释放。如果允许for循环运行足够数量的迭代,它最终会导致内存不足错误。这是因为输出张量y没有被正确释放,导致张量内存泄漏。有两种方法可以修复这个内存泄漏。

在第一种方法中,你可以在不再需要输出张量时调用tf.dispose()

for (let i = 0; i < 3; ++i) {
  const y = model.predict(x);
  y.print();
  tf.dispose(y);                                          ***1***
  console.log(`# of tensors: ${tf.memory().numTensors}` );
}
  • 1 在使用后释放输出张量

在第二种方法中,你可以在for循环的主体部分使用tf.tidy()

for (let i = 0; i < 3; ++i) {
  tf.tidy(() => {                          ***1***
    const y = model.predict(x);
    y.print();
    console.log(`# of tensors: ${tf.memory().numTensors}` );
  });
}
  • 1 tf.tidy()自动释放传递给它的函数中创建的所有张量,除了由该函数返回的张量。

无论选用哪种方法,你应该看到迭代中分配的张量数量变为常数,表明不再有张量内存泄漏。哪种方法应该优先使用呢?通常,你应该使用tf.tidy()(第二种方法),因为它消除了跟踪需要释放哪些张量的需要。tf.tidy()是一个智能函数,它释放传递给它的匿名函数中创建的所有张量(除了由该函数返回的张量-稍后再说),即使这些张量没有绑定到任何 JavaScript 对象。例如,假设我们稍微修改先前的推理代码,以便使用argMax()获得获胜类别的索引:

const model = await tf.loadLayersModel(
    'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json');
const x = tf.randomUniform([1, 4]);
for (let i = 0; i < 3; ++i) {
  const winningIndex =
           model.predict(x).argMax().dataSync()[0];
  console.log(`winning index: ${winningIndex}`);
  console.log(`# of tensors: ${tf.memory().numTensors}` );
}

当这段代码运行时,你会发现它每次迭代泄漏两个张量:

winning index: 0
# of tensors: 15
winning index: 0
# of tensors: 17
winning index: 0
# of tensors: 19

为什么每次迭代泄漏两个张量?因为这行代码:

const winningIndex =
       model.predict(x).argMax().dataSync()[0];

生成两个新的张量。第一个是model.predict()的输出,第二个是argMax()的返回值。这两个张量都没有绑定到任何 JavaScript 对象上。它们被创建后立即使用。这两个张量在某种意义上“丢失”——没有 JavaScript 对象可供您用来引用它们。因此,tf.dispose()不能用于清理这两个张量。但是,tf.tidy()仍然可以用来修复内存泄漏,因为它会对新张量执行簿记,无论它们是否绑定到 JavaScript 对象上:

const model = await tf.loadLayersModel(
    'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json');
const x = tf.randomUniform([1, 4]);
for (let i = 0; i < 3; ++i) {
  tf.tidy(() => {                                                  ***1***
    const winningIndex = model.predict(x).argMax().dataSync()[0];
    console.log(`winning index: ${winningIndex}`);
    console.log(`# of tensors: ${tf.memory().numTensors}` );       ***1***
  });
}
  • 1 tf.tidy()会自动处理传递给它作为参数的匿名函数中创建的张量,即使这些张量没有绑定到 JavaScript 对象上。

tf.tidy()的示例用法操作的是不返回任何张量的函数。如果该函数返回张量,则不希望将它们处理掉,因为它们需要在后续使用。这种情况在使用 TensorFlow.js 提供的基本张量操作编写自定义张量操作时经常遇到。例如,假设我们想编写一个函数来计算输入张量的标准化值——即,减去平均值并将标准差缩放为 1 的张量:

function normalize(x) {
  const mean = x.mean();
  const sd = x.norm(2);
  return x.sub(mean).div(sd);
}

这个实现有什么问题?^([6]) 就内存管理而言,它泄漏了三个张量:1)均值,2)SD 和 3)一个更微妙的泄漏:sub()调用的返回值。为了修复内存泄漏问题,我们将函数体包装在tf.tidy()中:

这个实现还有其他问题。例如,它没有对输入张量进行健全性检查,以确保它至少有两个元素,使 SD 不为零,否则将导致除以零和无限结果。但是这些问题与此处的讨论无直接关联。

function normalize(x) {
  return tf.tidy(() => {
    const mean = x.mean();
    const sd = x.norm(2);
    return x.sub(mean).div(sd);
       });
}

在这里,tf.tidy()为我们完成了三个操作:

  • 它会自动处理在匿名函数中创建但未被它返回的张量,包括之前提到的所有泄漏。我们在之前的例子中已经看到这一点了。
  • 它检测到div()调用的输出由匿名函数返回,因此将其转发到自己的返回值。
  • 在此期间,它将避免处理该特定张量,以便它可以在tf.tidy()调用之外使用。

如我们所见,tf.tidy() 是一种智能而强大的内存管理函数。它在 TensorFlow.js 代码库中被广泛使用。在本书的示例中,您还将经常看到它。然而,它有以下重要限制:作为参数传递给 tf.tidy() 的匿名函数 能是异步的。如果您有一些需要内存管理的异步代码,您应该使用 tf.dispose() 并手动跟踪待处理的张量。在这种情况下,您可以使用 tf.memory().numTensor 来检查泄漏张量的数量。一个好的做法是编写单元测试来断言不存在内存泄漏。

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

相关文章
|
23天前
|
机器学习/深度学习 JavaScript 前端开发
JavaScript 深度学习(五)(5)
JavaScript 深度学习(五)
8 0
|
23天前
|
存储 机器学习/深度学习 JavaScript
JavaScript 深度学习(五)(3)
JavaScript 深度学习(五)
22 0
|
23天前
|
机器学习/深度学习 并行计算 JavaScript
JavaScript 深度学习(五)(2)
JavaScript 深度学习(五)
29 0
|
23天前
|
机器学习/深度学习 人工智能 JavaScript
JavaScript 深度学习(五)(1)
JavaScript 深度学习(五)
25 0
|
23天前
|
机器学习/深度学习 前端开发 JavaScript
JavaScript 深度学习(四)(5)
JavaScript 深度学习(四)
12 1
|
23天前
|
机器学习/深度学习 存储 算法
JavaScript 深度学习(四)(4)
JavaScript 深度学习(四)
22 1
|
23天前
|
机器学习/深度学习 算法 JavaScript
JavaScript 深度学习(四)(3)
JavaScript 深度学习(四)
26 1
|
23天前
|
机器学习/深度学习 编解码 JavaScript
JavaScript 深度学习(四)(2)
JavaScript 深度学习(四)
22 1
|
23天前
|
机器学习/深度学习 自然语言处理 JavaScript
JavaScript 深度学习(四)(1)
JavaScript 深度学习(四)
12 2
|
2天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp小程序的校园竞赛管理系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp小程序的校园竞赛管理系统附带文章源码部署视频讲解等
148 63