Run this notebook online:Binder or Colab: Colab

2.1. 数据操作

为了开始我们的工作,首先我们需要存储和处理数据。通常,有两个重要的步骤:一,获取数据;二,得到数据后处理数据。如果不能存储数据,那么获取数据将没有意义。让我们从处理合成数据开始吧!首先,我们将介绍一个n-维数组(ndarray), 它是 DJL 用来存储和转换数据的主要工具。在 DJL 中,NDArray 是一个类,任何示例被称为“an ndarray”。

如果您使用过NumPy(Python中使用最广泛的科学计算包),那么您将对此章节非常熟悉。在 DJL 的 NDArray 不仅支持 CPU, 还支持GPU以及分离式云端结构。并且支持自动微分。在本书中,当提及ndarray,除非特别声明,都意味着是 DJL 的 NDArray

2.1.1. 创建 NDArray

在此小节,我们将带您上手,运行代码,为你准备好基础数学以及算数计算工具,使您通过本书步入学习正轨。不用为意会一些数学概念或者库的功能而烦恼。接下来的小结将会回顾在实际范例背景下的素材。另外,如果您已经具备相关背景,想要深入数学概念,请跳过本结。

首先,我们需要倒入 DJL 的核心软件包以及常用类库,为了避免重复,我们把 maven 下载和常用类库引入的代码存到 ../utils/djl-import.ipynb 文件中,这里我们只要使用 %load 宏调用即可。

%load ../utils/djl-imports

一个ndarray代表一个(可能是多维)含有数值的数组。一个有且只有一轴的ndarray在数学中对应一个向量,那么具有两轴的ndarray则对应一个矩阵。含有多于两轴但没有具体数学命名的数组,我们称之为张量,本书中我们统一使用 NDArray

新手上路,我们可以用arange创建一个行向量\(\vec{x}\),包含从0开始连续的12个整数。此处默认数据类型是浮点型。每一个数值都是一个ndarray,也是ndarray的成员element。例如,ndarray \(\vec{x}\)中现在有12个成员。除非特殊声明,一个新的ndarray将被存储在主内存中,并且将进行基于CPU的相关计算。

NDManager manager = NDManager.newBaseManager();
NDArray x = manager.arange(12);
x
ND: (12) gpu(0) int32
[ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11]

这里我们将使用 *NDManager*来创建ndarray\(\vec{x}\)NDManager执行界面AutoClosable并管理由它创建的ndarray的声明周期。因为Java Garbage Collector无法监管本地内存消耗,我们需要NDManager的帮助。通常我们会把NDManager封装在try blocks中,这样所有的ndarray可以被及时关掉。想要了解更多关于内存管理,请阅读DJL的相关文档

try(NDManager manager = NDManager.newBaseManager()){
    NDArray x = manager.arange(12);
}

我们可以通过查看shape属性,获得 NDArrayshape维度信息(每个轴的长度)。

x.getShape()
(12)

想要改变 NDArray 的维度并且不改变每个元素的数值,我们可以引用reshape功能。例如,我们可以这样转换我们的ndarray\(\vec{x}\),从维度(1, 12)的行向量转化成维度为(3, 4)的矩阵。这是一个新的ndarray,包含相同的数值但是是由3行和4列写成的。尽管shape维度改变了,但是\(\vec{x}\)的成员没有改变。请注意,size不会因reshape而改变。

x = x.reshape(3, 4);
x
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会占用一些内存然后返回一个矩阵,此过程不会改变矩阵中任何数值。这是非常高效但我们也需要谨慎使用,因为矩阵的成员很有可能是任意数值,包括很大的任意数值。

manager.create(new Shape(3, 4))
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),我们这样做:

manager.zeros(new Shape(2, 3, 4))
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张量

manager.ones(new Shape(2, 3, 4))
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。它每一个成员将从高斯分布中随机提取。

manager.randomNormal(0f, 1f, new Shape(3, 4), DataType.FLOAT32, Device.defaultDevice())
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,它会使用平均标准分布作为默认值。

manager.randomNormal(new Shape(3, 4))
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,只需要明确每个成员的算数值和期望维度即可。

manager.create(new float[]{2, 1, 4, 3, 1, 2, 3, 4, 4, 3, 2, 1}, new Shape(3, 4))
ND: (3, 4) gpu(0) float32
[[2., 1., 4., 3.],
 [1., 2., 3., 4.],
 [4., 3., 2., 1.],
]

2.1.2. 运算

NDArray 支持大量的运算符(operator)。例如,我们可以对之前创建的两个形状为(3, 4)的 NDArray 做按元素加法。所得结果形状不变。

因为Java不支持运算符过载, 在DJL中,常见的标准算数运算符(+,—,\(*\),/和\(**\))都通过函数来实现。可以对任意维度中任何同一维度的张量进行运算。我们可以对任意两个相同维度的张量进行基础运算。在接下来的例子中,我们用逗号构建一个含有5个元素的元组,其中每一个元素都是经过基础运算得到的结果。

NDArray x = manager.create(new float[]{1f, 2f, 4f, 8f});
NDArray y = manager.create(new float[]{2f, 2f, 2f, 2f});
x.add(y);
ND: (4) gpu(0) float32
[ 3.,  4.,  6., 10.]
x.sub(y);
ND: (4) gpu(0) float32
[-1.,  0.,  2.,  6.]
x.mul(y);
ND: (4) gpu(0) float32
[ 2.,  4.,  8., 16.]
x.div(y);
ND: (4) gpu(0) float32
[0.5, 1. , 2. , 4. ]
x.pow(y);
ND: (4) gpu(0) float32
[ 1.,  4., 16., 64.]

DJL可以运行多种基础运算,比如一元操作符:指数函数。

x.exp()
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.

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
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.],
]
x.concat(y, 1)
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.

x.eq(y)
ND: (3, 4) gpu(0) boolean
[[false,  true, false,  true],
 [false, false, false, false],
 [false, false, false, false],
]

NDArray 中的所有元素求和将生成一个只含有一个元素的 NDArray

x.sum()
ND: () gpu(0) float32
66.

读者可以使用像np.sum(x)的形式调用x.sum()

2.1.3. 广播机制

在上面的章节,我们看到了基础运算符是如何在两个相同维度的ndarray中进行运算的。在不确定的条件下,甚至不同维度下,我们一样可以通过引进传播机制进行基础运算。此工作机制将按如下顺序被执行:首先,通过将每个元素正确地复制来扩展一个或同时两个数组。经过这样的变换,两个ndarray将拥有相同维度。接下来,在执行基础运算输出结果。

在多数情况下,我们沿着一个初始长度为1的轴传播,像下面的例子一样:

NDArray a = manager.arange(3f).reshape(3, 1);
NDArray b = manager.arange(2f).reshape(1, 2);
a
ND: (3, 1) gpu(0) float32
[[0.],
 [1.],
 [2.],
]
b
ND: (1, 2) gpu(0) float32
[[0., 1.],
]

因为ab分别是\(3\times 1\)\(1\times 2\)的矩阵,它们不可以直接相加因为维度不匹配。所以我们将两个矩阵传播到一个更大的\(3\times 2\)的矩阵中:在进行加法基础运算之前,复制矩阵a的列并复制矩阵b的行。

a.add(b)
ND: (3, 2) gpu(0) float32
[[0., 1.],
 [1., 2.],
 [2., 3.],
]

2.1.4. 索引

索引和切片功能中,DJL使用与Python中的Numpy相同的语法结构。就像在任意一个Python中的数组一样,一个ndarray中的元素也可以通过索引获得。像在任意Python的array一样,第一个元素的索引是0,明确范围包括第一个到最后一个元素之前。因为Python的标准列表中,我们可以通过负标记数,得到元素与列表末尾的相对位置,从而获得元素本身。

因此,[-1]是最后一个元素。[1:3]表示第二个和第三个元素:

x.get(":-1");
ND: (2, 4) gpu(0) float32
[[0., 1., 2., 3.],
 [4., 5., 6., 7.],
]
x.get("1:3");
ND: (2, 4) gpu(0) float32
[[ 4.,  5.,  6.,  7.],
 [ 8.,  9., 10., 11.],
]

我们也可以根据标记数更改矩阵里的元素。

x.set(new NDIndex("1, 2"), 9);
x
ND: (3, 4) gpu(0) float32
[[ 0.,  1.,  2.,  3.],
 [ 4.,  5.,  9.,  7.],
 [ 8.,  9., 10., 11.],
]

如果我们想同时对多个元素赋相同的值,我们只要将它们一起索引然后赋值即可。比如,[0:2, :]获取第一至第二行,:表示沿着1轴(列)取所有元素。用索引的方法遍历矩阵,毫无疑问也可以遍历向量和张量。

x.set(new NDIndex("0:2, :"), 12);
x
ND: (3, 4) gpu(0) float32
[[12., 12., 12., 12.],
 [12., 12., 12., 12.],
 [ 8.,  9., 10., 11.],
]

2.1.5. 运算的内存开销

运行运算符需要主机分配新的内存空间。例如,如果我们写入y = x.add(y),我们将间接引用y之前指向的ndarray并在新分配的内存中替代y。

这不是我们所期望的,原因如下:第一,我们不想总是在不必要的时候分配内存。在机器学习中,我们大概会有以兆字节为单位的参数,并且每秒中会多次更新它们。通常,我们只想它们在正确的时候运行更新。其次,多个变量会指向相同的参数。如果我们不能及时更新参数,其他间接引用的变量将会指向之前的内存地址,这样会使我们的部分代码有可能引用了之前的旧参数。

幸运地是在DJL中,使用原位运算符很简单。我们可以用原位运算符如addi,subi,muli或divi,将分派运算结果到之前分配的数组中。

NDArray original = manager.zeros(y.getShape());
NDArray actual = original.addi(x);
original == actual
true

2.1.6. 小结

  • DJL中的ndarray是Numpy中ndarray的一种扩展,它具有一些强大功能使它更适合深度学习。

  • DJL中的ndarray提供多种多样的功能,其中包括基础数学运算,broadcasting广播,索引,切片,节省内存,转换其他Python对象。

2.1.7. 练习

  • 运行本节中的代码。将本节中条件判别式 x.eq(y) 改为 x.lt(y)x.gt(y),看看能够得到什么样的 NDArray

  • 将广播机制中按元素运算的两个 NDArray 替换成其他形状,结果是否和预期一样?