Run this notebook online: or 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属性,获得 NDArray
的shape维度信息(每个轴的长度)。
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)
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.],
]
因为a和b分别是\(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
替换成其他形状,结果是否和预期一样?