Run this notebook online:\ |Binder| or Colab: |Colab| .. |Binder| image:: https://mybinder.org/badge_logo.svg :target: https://mybinder.org/v2/gh/deepjavalibrary/d2l-java/master?filepath=chapter_preliminaries/ndarray.ipynb .. |Colab| image:: https://colab.research.google.com/assets/colab-badge.svg :target: https://colab.research.google.com/github/deepjavalibrary/d2l-java/blob/colab/chapter_preliminaries/ndarray.ipynb 数据操作 ======== 为了开始我们的工作,首先我们需要存储和处理数据。通常,有两个重要的步骤:一,获取数据;二,得到数据后处理数据。如果不能存储数据,那么获取数据将没有意义。让我们从处理合成数据开始吧!首先,我们将介绍一个n-维数组(ndarray), 它是 DJL 用来存储和转换数据的主要工具。在 DJL 中,\ ``NDArray`` 是一个类,任何示例被称为“an ndarray”。 如果您使用过NumPy(Python中使用最广泛的科学计算包),那么您将对此章节非常熟悉。在 DJL 的 ``NDArray`` 不仅支持 CPU, 还支持GPU以及分离式云端结构。并且支持自动微分。在本书中,当提及ndarray,除非特别声明,都意味着是 DJL 的 ``NDArray``\ 。 创建 ``NDArray`` ---------------- 在此小节,我们将带您上手,运行代码,为你准备好基础数学以及算数计算工具,使您通过本书步入学习正轨。不用为意会一些数学概念或者库的功能而烦恼。接下来的小结将会回顾在实际范例背景下的素材。另外,如果您已经具备相关背景,想要深入数学概念,请跳过本结。 首先,我们需要倒入 DJL 的核心软件包以及常用类库,为了避免重复,我们把 maven 下载和常用类库引入的代码存到 ``../utils/djl-import.ipynb`` 文件中,这里我们只要使用 ``%load`` 宏调用即可。 .. code:: java %load ../utils/djl-imports 一个ndarray代表一个(可能是多维)含有数值的数组。一个有且只有一轴的ndarray在数学中对应一个向量,那么具有两轴的ndarray则对应一个矩阵。含有多于两轴但没有具体数学命名的数组,我们称之为\ *张量*\ ,本书中我们统一使用 ``NDArray``\ 。 新手上路,我们可以用arange创建一个行向量\ :math:`\vec{x}`\ ,包含从0开始连续的12个整数。此处默认数据类型是浮点型。每一个数值都是一个ndarray,也是ndarray的\ *成员element*\ 。例如,ndarray :math:`\vec{x}`\ 中现在有12个\ *成员*\ 。除非特殊声明,一个新的ndarray将被存储在主内存中,并且将进行基于CPU的相关计算。 .. code:: java NDManager manager = NDManager.newBaseManager(); NDArray x = manager.arange(12); x .. parsed-literal:: :class: output ND: (12) gpu(0) int32 [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 这里我们将使用 `*NDManager* `__\ 来创建ndarray\ :math:`\vec{x}`\ 。\ *NDManager*\ 执行界面\ `AutoClosable `__\ 并管理由它创建的ndarray的声明周期。因为Java Garbage Collector无法监管本地内存消耗,我们需要\ *NDManager*\ 的帮助。通常我们会把NDManager封装在try blocks中,这样所有的ndarray可以被及时关掉。想要了解更多关于内存管理,请阅读DJL的\ `相关文档 `__\ 。 .. code:: java try(NDManager manager = NDManager.newBaseManager()){ NDArray x = manager.arange(12); } 我们可以通过查看\ **shape**\ 属性,获得 ``NDArray`` 的\ *shape维度*\ 信息(每个轴的长度)。 .. code:: java x.getShape() .. parsed-literal:: :class: output (12) 想要改变 ``NDArray`` 的维度并且不改变每个元素的数值,我们可以引用\ **reshape**\ 功能。例如,我们可以这样转换我们的ndarray\ :math:`\vec{x}`\ ,从维度(1, 12)的行向量转化成维度为(3, 4)的矩阵。这是一个新的ndarray,包含相同的数值但是是由3行和4列写成的。尽管\ *shape维度*\ 改变了,但是\ :math:`\vec{x}`\ 的成员没有改变。请注意,\ *size*\ 不会因\ *reshape*\ 而改变。 .. code:: java x = x.reshape(3, 4); x .. parsed-literal:: :class: output ND: (3, 4) gpu(0) int32 [[ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 8, 9, 10, 11], ] 手动明确每一个维度使用\ *reshape*\ ,是非常繁琐的过程。如果我们的目标维度是一个带有具体\ *shape形状*\ 矩阵,例如当我们已知的形状是用宽度来标记,那么高度信息就是隐含的已知条件。那么为什么我们要在此时再做一次除法运算?在上述的例子中,为了得到一个3行的矩阵,我们同时明确了该矩阵应该有3行和4列。现在,当已知其它的维度信息,ndarray可以自动计算。我们通过使用-1代替我们想要ndarray自动计算的维度。在DJL中,不用像使用x.reshape(3, 4)这样,x.reshape(-1, 4)或者x.reshape(3, -1)可以得到一样的结果。 通过\ *create创建*\ 的方法,只有\ *shape*\ 会占用一些内存然后返回一个矩阵,此过程不会改变矩阵中任何数值。这是非常高效但我们也需要谨慎使用,因为矩阵的成员很有可能是任意数值,包括很大的任意数值。 .. code:: java manager.create(new Shape(3, 4)) .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[ 1.12103877e-44, 1.26116862e-44, 1.40129846e-44, 1.54142831e-44], [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00], [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00], ] 通常,我们希望矩阵初始化要不成员是0,要不是1或者其他常数,或者是明确维度分布的随机数。我们创建一个ndarray代表一个\ *tensor张量*\ ,它的成员都是0,维度是(2, 3, 4),我们这样做: .. code:: java manager.zeros(new Shape(2, 3, 4)) .. parsed-literal:: :class: output ND: (2, 3, 4) gpu(0) float32 [[[0., 0., 0., 0.], [0., 0., 0., 0.], [0., 0., 0., 0.], ], [[0., 0., 0., 0.], [0., 0., 0., 0.], [0., 0., 0., 0.], ], ] 同样,我们可以创建一个成员都是1的\ *tensor张量*\ 。 .. code:: java manager.ones(new Shape(2, 3, 4)) .. parsed-literal:: :class: output ND: (2, 3, 4) gpu(0) float32 [[[1., 1., 1., 1.], [1., 1., 1., 1.], [1., 1., 1., 1.], ], [[1., 1., 1., 1.], [1., 1., 1., 1.], [1., 1., 1., 1.], ], ] 我们更多希望从一些特定概率分布中的ndarrya为每一个成员采样随机样本值。例如,当我们在一个神经网络中创建多个array作为参数时,我们一般会用随机数将它们初始化。接下来的示例演示如何创建一个维度为(3, 4)的ndarray。它每一个成员将从高斯分布中随机提取。 .. code:: java manager.randomNormal(0f, 1f, new Shape(3, 4), DataType.FLOAT32) .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[ 0.2925, -0.7184, 0.1 , -0.3932], [ 2.547 , -0.0034, 0.0083, -0.251 ], [ 0.129 , 0.3728, 1.0822, -0.665 ], ] 你也可以直接使用\ *shape*\ ,它会使用平均标准分布作为默认值。 .. code:: java manager.randomNormal(new Shape(3, 4)) .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[ 0.5434, -0.7168, -1.4913, 1.4805], [ 0.1374, -1.2208, 0.3072, 1.1135], [-0.0376, -0.7109, -1.2903, -0.8822], ] 我们也可以通过按照需要构造ndarray,只需要明确每个成员的算数值和期望维度即可。 .. code:: java manager.create(new float[]{2, 1, 4, 3, 1, 2, 3, 4, 4, 3, 2, 1}, new Shape(3, 4)) .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[2., 1., 4., 3.], [1., 2., 3., 4.], [4., 3., 2., 1.], ] 运算 ---- ``NDArray`` 支持大量的运算符(operator)。例如,我们可以对之前创建的两个形状为(3, 4)的 ``NDArray`` 做按元素加法。所得结果形状不变。 因为Java不支持运算符过载, 在DJL中,常见的标准算数运算符(+,—,\ :math:`*`\ ,/和\ :math:`**`\ )都通过函数来实现。可以对任意维度中任何同一维度的张量进行运算。我们可以对任意两个相同维度的张量进行基础运算。在接下来的例子中,我们用逗号构建一个含有5个元素的元组,其中每一个元素都是经过基础运算得到的结果。 .. code:: java NDArray x = manager.create(new float[]{1f, 2f, 4f, 8f}); NDArray y = manager.create(new float[]{2f, 2f, 2f, 2f}); x.add(y); .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [ 3., 4., 6., 10.] .. code:: java x.sub(y); .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [-1., 0., 2., 6.] .. code:: java x.mul(y); .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [ 2., 4., 8., 16.] .. code:: java x.div(y); .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [0.5, 1. , 2. , 4. ] .. code:: java x.pow(y); .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [ 1., 4., 16., 64.] DJL可以运行多种基础运算,比如一元操作符:指数函数。 .. code:: java x.exp() .. parsed-literal:: :class: output ND: (4) gpu(0) float32 [ 2.71828175e+00, 7.38905621e+00, 5.45981483e+01, 2.98095801e+03] 除了基础运算外,我们同样可以进行线性代数运算,包括向量点乘和矩阵相乘。稍后我们会在\ **sec\_linear-algebra**\ 解释线性代数的关键部分(无先学知识)。 我们也可以将多个ndarrays *concatenate*\ 联结起来,将它们首位相接地堆叠起来,生成一个更大的ndarray。我们只需要提供一个ndarray的列表然后告诉系统沿着哪个轴进行联结。下面的示例展示了当我们将两个矩阵分别沿0轴(行)和1轴(列)联结将会发生什么。我们可以看到第一种情况将输出ndarray的0轴长度为6,它是通过两个输入ndarray的0轴长度之和(3+3)得到的。与此同时,第二种情况将输出两个输入ndarray的1轴长度之和,4+4=8. .. code:: java x = manager.arange(12f).reshape(3, 4); y = manager.create(new float[]{2, 1, 4, 3, 1, 2, 3, 4, 4, 3, 2, 1}, new Shape(3, 4)); x.concat(y) // default axis = 0 .. parsed-literal:: :class: output ND: (6, 4) gpu(0) float32 [[ 0., 1., 2., 3.], [ 4., 5., 6., 7.], [ 8., 9., 10., 11.], [ 2., 1., 4., 3.], [ 1., 2., 3., 4.], [ 4., 3., 2., 1.], ] .. code:: java x.concat(y, 1) .. parsed-literal:: :class: output ND: (3, 8) gpu(0) float32 [[ 0., 1., 2., 3., 2., 1., 4., 3.], [ 4., 5., 6., 7., 1., 2., 3., 4.], [ 8., 9., 10., 11., 4., 3., 2., 1.], ] 然而有时,我们想通过逻辑声明构建一个二元ndarray。比如,x.eq(y)。对于每一个定位,x和y的数值都相等,那么在相对应的位置上将生成一个新的ndarray,值为\ **1**\ ,逻辑声明x.eq()表明在此定位为真。反之,此位置则为\ **0**. .. code:: java x.eq(y) .. parsed-literal:: :class: output ND: (3, 4) gpu(0) boolean [[false, true, false, true], [false, false, false, false], [false, false, false, false], ] 对 ``NDArray`` 中的所有元素求和将生成一个只含有一个元素的 ``NDArray``\ 。 .. code:: java x.sum() .. parsed-literal:: :class: output ND: () gpu(0) float32 66. 读者可以使用像np.sum(x)的形式调用x.sum() 广播机制 -------- 在上面的章节,我们看到了基础运算符是如何在两个相同维度的ndarray中进行运算的。在不确定的条件下,甚至不同维度下,我们一样可以通过引进传播机制进行基础运算。此工作机制将按如下顺序被执行:首先,通过将每个元素正确地复制来扩展一个或同时两个数组。经过这样的变换,两个ndarray将拥有相同维度。接下来,在执行基础运算输出结果。 在多数情况下,我们沿着一个初始长度为1的轴传播,像下面的例子一样: .. code:: java NDArray a = manager.arange(3f).reshape(3, 1); NDArray b = manager.arange(2f).reshape(1, 2); a .. parsed-literal:: :class: output ND: (3, 1) gpu(0) float32 [[0.], [1.], [2.], ] .. code:: java b .. parsed-literal:: :class: output ND: (1, 2) gpu(0) float32 [[0., 1.], ] 因为\ **a**\ 和\ **b**\ 分别是\ :math:`3\times 1`\ 和\ :math:`1\times 2`\ 的矩阵,它们不可以直接相加因为维度不匹配。所以我们将两个矩阵传播到一个更大的\ :math:`3\times 2`\ 的矩阵中:在进行加法基础运算之前,复制矩阵\ **a**\ 的列并复制矩阵\ **b**\ 的行。 .. code:: java a.add(b) .. parsed-literal:: :class: output ND: (3, 2) gpu(0) float32 [[0., 1.], [1., 2.], [2., 3.], ] 索引 ---- 索引和切片功能中,DJL使用与Python中的Numpy相同的语法结构。就像在任意一个Python中的数组一样,一个ndarray中的元素也可以通过索引获得。像在任意Python的array一样,第一个元素的索引是\ **0**,明确范围包括第一个到最后一个元素之前。因为Python的标准列表中,我们可以通过负标记数,得到元素与列表末尾的相对位置,从而获得元素本身。 因此,[-1]是最后一个元素。[1:3]表示第二个和第三个元素: .. code:: java x.get(":-1"); .. parsed-literal:: :class: output ND: (2, 4) gpu(0) float32 [[0., 1., 2., 3.], [4., 5., 6., 7.], ] .. code:: java x.get("1:3"); .. parsed-literal:: :class: output ND: (2, 4) gpu(0) float32 [[ 4., 5., 6., 7.], [ 8., 9., 10., 11.], ] 我们也可以根据标记数更改矩阵里的元素。 .. code:: java x.set(new NDIndex("1, 2"), 9); x .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[ 0., 1., 2., 3.], [ 4., 5., 9., 7.], [ 8., 9., 10., 11.], ] 如果我们想同时对多个元素赋相同的值,我们只要将它们一起索引然后赋值即可。比如,[0:2, :]获取第一至第二行,:表示沿着1轴(列)取所有元素。用索引的方法遍历矩阵,毫无疑问也可以遍历向量和张量。 .. code:: java x.set(new NDIndex("0:2, :"), 12); x .. parsed-literal:: :class: output ND: (3, 4) gpu(0) float32 [[12., 12., 12., 12.], [12., 12., 12., 12.], [ 8., 9., 10., 11.], ] 运算的内存开销 -------------- 运行运算符需要主机分配新的内存空间。例如,如果我们写入y = x.add(y),我们将间接引用y之前指向的ndarray并在新分配的内存中替代y。 这不是我们所期望的,原因如下:第一,我们不想总是在不必要的时候分配内存。在机器学习中,我们大概会有以兆字节为单位的参数,并且每秒中会多次更新它们。通常,我们只想它们在正确的时候运行更新。其次,多个变量会指向相同的参数。如果我们不能及时更新参数,其他间接引用的变量将会指向之前的内存地址,这样会使我们的部分代码有可能引用了之前的旧参数。 幸运地是在DJL中,使用原位运算符很简单。我们可以用原位运算符如addi,subi,muli或divi,将分派运算结果到之前分配的数组中。 .. code:: java NDArray original = manager.zeros(y.getShape()); NDArray actual = original.addi(x); original == actual .. parsed-literal:: :class: output true 小结 ---- - DJL中的ndarray是Numpy中ndarray的一种扩展,它具有一些强大功能使它更适合深度学习。 - DJL中的ndarray提供多种多样的功能,其中包括基础数学运算,\ *broadcasting广播*\ ,索引,切片,节省内存,转换其他Python对象。 练习 ---- - 运行本节中的代码。将本节中条件判别式 ``x.eq(y)`` 改为 ``x.lt(y)`` 或\ ``x.gt(y)``\ ,看看能够得到什么样的 ``NDArray``\ 。 - 将广播机制中按元素运算的两个 ``NDArray`` 替换成其他形状,结果是否和预期一样?