数据操作

在深度学习中,我们通常会频繁地对数据进行操作。作为动手学深度学习的基础,本节将介绍如何对内存中的数据进行操作。

在 MXNet 中,NDArray 是存储和变换数据的主要工具。如果你之前用过 NumPy,你会发现 NDArray 和 NumPy 的多维数组非常类似。然而,NDArray 提供 GPU 计算和自动求梯度等更多功能,这些使得 NDArray 更加适合深度学习。

创建 NDArray

我们先介绍 NDArray 的最基本功能。如果你对我们用到的数学操作不是很熟悉,可以参阅附录中“数学基础”一节。

首先从 MXNet 导入ndarray模块。这里的ndndarray的缩写形式。

In [1]:
from mxnet import nd

然后我们用arange函数创建一个行向量。

In [2]:
x = nd.arange(12)
x
Out[2]:

[ 0.  1.  2.  3.  4.  5.  6.  7.  8.  9. 10. 11.]
<NDArray 12 @cpu(0)>

这时返回了一个 NDArray 实例,其中包含了从 0 开始的 12 个连续整数。从打印x时显示的属性<NDArray 12 @cpu(0)>可以看出,它是长度为 12 的一维数组,且被创建在 CPU 主内存上。其中“@cpu(0)”里的 0 没有特别的意义,并不代表特定的核。

我们可以通过shape属性来获取 NDArray 实例形状。

In [3]:
x.shape
Out[3]:
(12,)

我们也能够通过size属性得到 NDArray 实例中元素(element)的总数。

In [4]:
x.size
Out[4]:
12

下面使用reshape函数把行向量x的形状改为(3,4),也就是一个 3 行 4 列的矩阵。除了形状改变之外,x中的元素保持不变。

In [5]:
x = x.reshape((3, 4))
x
Out[5]:

[[ 0.  1.  2.  3.]
 [ 4.  5.  6.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

注意x属性中的形状发生了变化。上面x.reshape((3, 4))也可写成x.reshape((-1, 4))x.reshape((3, -1))。由于x的元素个数是已知的,这里的-1是能够通过元素个数和其他维度的大小推断出来的。

接下来,我们创建一个各元素为 0,形状为(2,3,4)的张量。实际上,之前创建的向量和矩阵都是特殊的张量。

In [6]:
nd.zeros((2, 3, 4))
Out[6]:

[[[0. 0. 0. 0.]
  [0. 0. 0. 0.]
  [0. 0. 0. 0.]]

 [[0. 0. 0. 0.]
  [0. 0. 0. 0.]
  [0. 0. 0. 0.]]]
<NDArray 2x3x4 @cpu(0)>

类似地,我们可以创建各元素为 1 的张量。

In [7]:
nd.ones((3, 4))
Out[7]:

[[1. 1. 1. 1.]
 [1. 1. 1. 1.]
 [1. 1. 1. 1.]]
<NDArray 3x4 @cpu(0)>

我们也可以通过 Python 的列表(list)指定需要创建的 NDArray 中每个元素的值。

In [8]:
y = nd.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
y
Out[8]:

[[2. 1. 4. 3.]
 [1. 2. 3. 4.]
 [4. 3. 2. 1.]]
<NDArray 3x4 @cpu(0)>

有些情况下,我们需要随机生成 NDArray 中每个元素的值。下面我们创建一个形状为(3,4)的 NDArray。它的每个元素都随机采样于均值为 0 标准差为 1 的正态分布。

In [9]:
nd.random.normal(0, 1, shape=(3, 4))
Out[9]:

[[ 2.2122064   0.7740038   1.0434405   1.1839255 ]
 [ 1.8917114  -1.2347414  -1.771029   -0.45138445]
 [ 0.57938355 -1.856082   -1.9768796  -0.20801921]]
<NDArray 3x4 @cpu(0)>

运算

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

In [10]:
x + y
Out[10]:

[[ 2.  2.  6.  6.]
 [ 5.  7.  9. 11.]
 [12. 12. 12. 12.]]
<NDArray 3x4 @cpu(0)>

按元素乘法:

In [11]:
x * y
Out[11]:

[[ 0.  1.  8.  9.]
 [ 4. 10. 18. 28.]
 [32. 27. 20. 11.]]
<NDArray 3x4 @cpu(0)>

按元素除法:

In [12]:
x / y
Out[12]:

[[ 0.    1.    0.5   1.  ]
 [ 4.    2.5   2.    1.75]
 [ 2.    3.    5.   11.  ]]
<NDArray 3x4 @cpu(0)>

按元素做指数运算:

In [13]:
y.exp()
Out[13]:

[[ 7.389056   2.7182817 54.59815   20.085537 ]
 [ 2.7182817  7.389056  20.085537  54.59815  ]
 [54.59815   20.085537   7.389056   2.7182817]]
<NDArray 3x4 @cpu(0)>

除了按元素计算外,我们还可以使用dot函数做矩阵运算。下面将xy的转置做矩阵乘法。由于x是 3 行 4 列的矩阵,y转置为 4 行 3 列的矩阵,两个矩阵相乘得到 3 行 3 列的矩阵。

In [14]:
nd.dot(x, y.T)
Out[14]:

[[ 18.  20.  10.]
 [ 58.  60.  50.]
 [ 98. 100.  90.]]
<NDArray 3x3 @cpu(0)>

我们也可以将多个 NDArray 合并。下面分别在行上(维度 0,即形状中的最左边元素)和列上(维度 1,即形状中左起第二个元素)连结(concatenate)两个矩阵。

In [15]:
nd.concat(x, y, dim=0), nd.concat(x, y, dim=1)
Out[15]:
(
 [[ 0.  1.  2.  3.]
  [ 4.  5.  6.  7.]
  [ 8.  9. 10. 11.]
  [ 2.  1.  4.  3.]
  [ 1.  2.  3.  4.]
  [ 4.  3.  2.  1.]]
 <NDArray 6x4 @cpu(0)>,
 [[ 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 3x8 @cpu(0)>)

使用条件判断式可以得到元素为 0 或 1 的新的 NDArray。以x == y为例,如果xy在相同位置的条件判断为真(值相等),那么新的 NDArray 在相同位置的值为 1;反之为 0。

In [16]:
x == y
Out[16]:

[[0. 1. 0. 1.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]]
<NDArray 3x4 @cpu(0)>

对 NDArray 中的所有元素求和得到只有一个元素的 NDArray。

In [17]:
x.sum()
Out[17]:

[66.]
<NDArray 1 @cpu(0)>

我们可以通过asscalar函数将结果变换为 Python 中的标量。下面例子中x\(L_2\) 范数结果同上例一样是单元素 NDArray,但最后结果变换成了 Python 中标量。

In [18]:
x.norm().asscalar()
Out[18]:
22.494444

我们也可以把y.exp()x.sum()x.norm()等分别改写为nd.exp(y)nd.sum(x)nd.norm(x)等。

广播机制

前面我们看到如何对两个形状相同的 NDArray 做按元素操作。当对两个形状不同的 NDArray 按元素操作时,可能会触发广播(broadcasting)机制:先适当复制元素使得这两个 NDArray 形状相同后再按元素操作。

定义两个 NDArray:

In [19]:
a = nd.arange(3).reshape((3, 1))
b = nd.arange(2).reshape((1, 2))
a, b
Out[19]:
(
 [[0.]
  [1.]
  [2.]]
 <NDArray 3x1 @cpu(0)>,
 [[0. 1.]]
 <NDArray 1x2 @cpu(0)>)

由于ab分别是 3 行 1 列和 1 行 2 列的矩阵,如果要计算a+b,那么a中第一列的三个元素被广播(复制)到了第二列,而b中第一行的两个元素被广播(复制)到了第二行和第三行。如此,我们就可以对两个 3 行 2 列的矩阵按元素相加。

In [20]:
a + b
Out[20]:

[[0. 1.]
 [1. 2.]
 [2. 3.]]
<NDArray 3x2 @cpu(0)>

索引

在 NDArray 中,索引(index)代表了元素的位置。NDArray 的索引从 0 开始逐一递增。例如一个 3 行 2 列的矩阵的行索引分别为 0、1 和 2,列索引分别为 0 和 1。

在下面的例子中,我们指定了 NDArray 的行索引截取范围[1:3]。依据左闭右开指定范围的惯例,它截取了矩阵x中行索引为 1 和 2 的两行。

In [21]:
x[1:3]
Out[21]:

[[ 4.  5.  6.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 2x4 @cpu(0)>

我们可以指定 NDArray 中需要访问的单个元素的位置,例如矩阵中行和列的索引,并为该元素重新赋值。

In [22]:
x[1, 2] = 9
x
Out[22]:

[[ 0.  1.  2.  3.]
 [ 4.  5.  9.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

当然,我们也可以截取一部分元素,并为它们重新赋值。下面例子中,我们为行索引为 1 的每一列元素重新赋值。

In [23]:
x[1:2, :] = 12
x
Out[23]:

[[ 0.  1.  2.  3.]
 [12. 12. 12. 12.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

运算的内存开销

前面例子里我们对每个操作新开内存来储存运算结果。举个例子,即使像y = x + y这样的运算,我们也会新创建内存,然后将y指向新内存。为了演示这一点,我们可以使用 Python 自带的id函数:如果两个实例的 ID 一致,那么它们所对应的内存地址相同;反之则不同。

In [24]:
before = id(y)
y = y + x
id(y) == before
Out[24]:
False

如果我们想指定结果到特定内存,我们可以使用前面介绍的索引来进行替换操作。在下面的例子中,我们先通过zeros_like创建和y形状相同且元素为 0 的 NDArray,记为z。接下来,我们把x + y的结果通过[:]写进z所对应的内存中。

In [25]:
z = y.zeros_like()
before = id(z)
z[:] = x + y
id(z) == before
Out[25]:
True

实际上,上例中我们还是为x + y创建了临时内存来存储计算结果,再复制到z所对应的内存。如果想避免这个临时内存开销,我们可以使用运算符全名函数中的out参数。

In [26]:
nd.elemwise_add(x, y, out=z)
id(z) == before
Out[26]:
True

如果x的值在之后的程序中不会复用,我们也可以用 x[:] = x + y 或者 x += y 来减少运算的内存开销。

In [27]:
before = id(x)
x += y
id(x) == before
Out[27]:
True

NDArray 和 NumPy 相互变换

我们可以通过arrayasnumpy函数令数据在 NDArray 和 NumPy 格式之间相互变换。下面将 NumPy 实例变换成 NDArray 实例。

In [28]:
import numpy as np

p = np.ones((2, 3))
d = nd.array(p)
d
Out[28]:

[[1. 1. 1.]
 [1. 1. 1.]]
<NDArray 2x3 @cpu(0)>

再将 NDArray 实例变换成 NumPy 实例。

In [29]:
d.asnumpy()
Out[29]:
array([[1., 1., 1.],
       [1., 1., 1.]], dtype=float32)

小结

  • NDArray 是 MXNet 中存储和变换数据的主要工具。
  • 我们可以轻松地对 NDArray 创建、运算、指定索引,并与 NumPy 之间相互变换。

练习

  • 运行本节代码。将本节中条件判断式x == y改为x < yx > y,看看能够得到什么样的 NDArray。
  • 将广播机制中按元素操作的两个 NDArray 替换成其他形状,结果是否和预期一样?

扫码直达讨论区