“矢量化”操作:对NumPy数组进行优化过的计算

在本节中,我们将会:

  • 在Python/NumPy的环境中定义矢量化(vectorization)一词。

  • 指示如何使用NumPy矢量化的函数来进行优化过的数组计算。

  • 对比未矢量化的计算和矢量化的计算效率。

  • 描述NumPy数组的一元,二元,和序列函数是如何定义的。

  • 快速地过一下线性代数函数和逻辑操作。

使用数组进行基本数学操作

你可以在数学表达式中使用N维数组来对数组的成员来进行数学运算。一般来讲,NumPy实现的数学函数会在作用于数组时对数组的每个成员进行该数学操作。

# 演示对NumPy数组进行常用的数学操作
>>> import numpy as np

>>> x = np.array([[ 0.,  1.,  2.],
...               [ 3.,  4.,  5.],
...               [ 6.,  7.,  8.]])

# `x ** 2` 将数组 `x` 的每个成员都求平方
>>> x ** 2
array([[  0.,   1.,   4.],
       [  9.,  16.,  25.],
       [ 36.,  49.,  64.]])

# `np.sqrt(x)` 求数组 `x` 的每个成员的平方根
>>> np.sqrt(x)
array([[ 0.        ,  1.        ,  1.41421356],
       [ 1.73205081,  2.        ,  2.23606798],
       [ 2.44948974,  2.64575131,  2.82842712]])

# 切片也返回数组,因此你也可以对切片进行操作
# 向 `x` 行0的所有成员加.5
>>> .5 + x[0, :]
array([ 0.5,  1.5,  2.5])

相似的,对两个数组进行的数学操作会将数组的成员一一对应并进行操作:

# 演示两个数组之间的数学操作
>>> x = np.array([[ 0.,  1.,  2.],
...               [ 3.,  4.,  5.],
...               [ 6.,  7.,  8.]])

>>> y = np.array([[-4. , -3.5, -3. ],
...               [-2.5, -2. , -1.5],
...               [-1. , -0.5, -0. ]])

# `x + y` 会将数组 `x` 和 `y` 对应的数字相加
>>> x + y
array([[-4. , -2.5, -1. ],
       [ 0.5,  2. ,  3.5],
       [ 5. ,  6.5,  8. ]])

>>> x * y
array([[-0. , -3.5, -6. ],
       [-7.5, -8. , -7.5],
       [-6. , -3.5, -0. ]])

Python有着旨在对数字序列进行操作的数学函数。相应的,NumPy的序列函数对数组成员进行操作时会假装所有成员都在一个序列中,或会根据数组的轴对相应的子序列进行操作。

# 对数组使用序列函数 `np.sum`
>>> x = np.array([[ 0.,  1.,  2.],
...               [ 3.,  4.,  5.],
...               [ 6.,  7.,  8.]])

# 这将求数组所有成员的和
>>> np.sum(x)
36.0

# 求数组每一列中所有行的和
>>> np.sum(x, axis=0)
array([  9.,  12.,  15.])

我们将在本节中提供更加细致的对NumPy提供的数学函数以及NumPy的序列数学操作的解析。但是,我们必须首先理解NumPy使用高效的“矢量化操作”来达到纯Python代码无法对比的效率这一点。在本节最后,“矢量化操作”将会成为对NumPy表达爱意的词汇。

矢量化操作

请回忆,NumPy的N维数组是同质的:一个数组只能存储单个类型的数据。比如说,单个数组可以存储8比特整数或32比特浮点数,但是不能这两种数都有。这和Python的完全不限制成员种类数的列表和元组极其不同;一个列表可以同时存储字符串,整数,和其它对象。这个对数组成员的限制带来着大量的好处;因为NumPy提前“知道”数组成员的种类是同质的,它可以将对数组成员进行的数学操作代理给优化过,提前编译的C代码。这个过程叫做矢量化(vectorization)。这么做的结果是和纯Python代码运算相比巨大的速度提升。Python需要在迭代数组成员时一个个检查成员的数据类型,因为Python一般操作的列表是不限制数据类型的。

定义

在像Python,Matlab,和R这类的高级语言里,矢量化一词指使用高效,提前编译的低级语言(如C)代码来对数据序列进行数学操作。这代替了在同语言中写的显性迭代代码(如Python中的for循环)。

比如说,假设我们想要求数组中存储的整数0-9,999的和,那调用NumPy的 sum 函数将使用高效的C代码来迭代数组中的整数并求和。因此,np.sum 是一个“矢量化”的函数。让我们计算一下求这个和花了多少时间:

>>> import numpy as np

# 使用NumPy的矢量化 'sum' 函数求和
>>> np.sum(np.arange(10000))  # 在我的机器上花了11微秒
49995000

现在,让我们将这个对比在Python中显性迭代这个数组并求和花的时间。Python无法利用函数成员都是同一数据类型这一信息——它必须在每一轮迭代都检查数据的类型,就像在迭代列表一样。这回大幅度地降低运算速度。

# 在Python中显性迭代数组来求和sum an array by explicitly looping over the array in Python
# 在我的机器上花了822微秒
>>> total = 0
>>> for i in np.arange(10000):
...     total = i + total
>>> total
49995000

根据我机器上花的时间,求和在使用NumPy矢量化的函数时快了超过50倍!我希望这澄清了我们在计算速度重要时应避免对Python长序列数据(不管是列表还是NumPy数组)使用显性for循环一事。NumPy提供了一整套的矢量化函数。事实上,使用NumPy对数组进行计算的中心围绕着仅仅使用矢量化函数。以下运算全部都调用矢量化函数:

>>> import numpy as np

# 对数组中每个数乘以2
>>> 2 * np.array([2, 3, 4])
array([4, 6, 8])

# 将两个数组中的对应成员相减
>>> np.array([10.2, 3.5, -0.9]) - np.array([8.2, 3.5, 6.5])
array([ 2. ,  0. , -7.4])

# 求两个数组的“点积”(dot product)
# 点积指的是:将数组对应成员相乘并求乘积的和
>>> np.dot(np.array([1, -3, 4]), np.array([2, 0, 1]))
6

所有本节之后介绍的数学函数都是矢量化的操作。

经验

NumPy提供了高度优化的函数来对数字数组进行数学运算。使用Python进行大量的迭代(如使用“for循环”)来达到重复的数学运算的地方基本都应该由对数组进行矢量化函数代替。这就是使用NumPy的设计方法。

NumPy的数学函数

我们将花一些时间来探索NumPy提供的多种矢量化数学函数,以及这些传统上仅仅处理单个数字的数学操作是如何处理数组的。我们将会讨论

  • 一元(unary)函数:\(f(x)\)

  • 二元(binary)函数:\(f(x,y)\)

  • 操作数字序列的函数:\(f(\{x_i\}_{i=0}^{n-1})\)

这些函数代表着NumPy模组核心数学工具中的很大一部分。完整的NumPy数学函数列表可以在官方说明文档中找到。

一元函数

一元函数是只接受一个操作数(也就是参数)的数学函数:\(f(x)\)。NumPy提供了很多熟悉的一元函数:

一元函数:\(f(x)\)

NumPy函数

\(\vert x \vert\)

np.absolute

\(\sqrt{x}\)

np.sqrt

三角函数

\(\sin{x}\)

np.sin

\(\cos{x}\)

np.cos

\(\tan{x}\)

np.tan

对数函数

\(\ln{x}\)

np.log

\(\log_{10}{x}\)

np.log10

\(\log_{2}{x}\)

np.log2

指数函数

\(e^{x}\)

np.exp

当然,这完全不是所有可用一元函数的完整列表。比如说,双曲函数和逆三角函数也有着对应的NumPy版本。这些熟悉的函数是为单个数字(也就是“标量”),而不是数字序列,定义的。那么DumPy是如何实现这些函数使得它们对数组操作时依然有条理呢?答案是NumPy将这个函数映射(map)到数组上——使用 \(f(x)\) 对数组中的每个成员进行操作,并将结果返回为一个新数组(也就是说输入数组并没有被重写)。

import numpy as np
>>> x = np.array([0., 1., 2.])

# 返回 array([exp(0.), exp(1.), exp(2.)])
# x 并没有被次重写;这创建了一个新数组
>>> np.exp(x)
array([ 1. ,  2.71828183,  7.3890561 ])

这个规则可以拓展到任何维度和形状的数组上。

# 范例一元函数对2维数组进行操作
>>> x = np.array([[-1, 2], [-3, 4]])
>>> x
array([[-1,  2],
       [-3,  4]])

>>> np.square(x)  # 等值于:`x**2`
array([[ 1,  4],
       [ 9, 16]])

因为切片返回一个数组,你可以对切片进行数学操作

# 求 `x` 第一列的平方
>>> x[:, 0] ** 2
array([1, 9])

经验

将一元NumPy函数 \(f(x)\) 应用于N维数组将会将 \(f(x)\) 作用于数组的每一个成员。

阅读理解:一元函数

设以下2维数组:

>>> x = np.array([[ 0,  1,  2,  3],
...               [ 4,  5,  6,  7],
...               [ 8,  9, 10, 11],
...               [12, 13, 14, 15]])

x 第三行的第一和第三个成员的自然对数以得到形状为 (2,) 的结果。

二元函数

二元函数的形式为 \(f(x,y)\)。算数操作都是二元函数:

二元函数:\(f(x, y)\)

NumPy函数

Python操作符

\(x\cdot y\)

np.multiply

*

\(x\div y\)

np.divide

/

\(x+y\)

np.add

+

\(x-y\)

np.subtract

-

\(x^{y}\)

np.power

**

\(x \% y\)

np.mod

%

回忆

符号是 \(\%\) 的 “模”函数(modulo,简写为mod)的定义使其返回除法的余数\(5 \% 3 = 2\)

如上表所示,这些NumPy函数可以通过对NumPy数组使用熟悉的Python数学操作符来调用。

以下是另外一些常见的二元函数:

二元函数:f(x, y)$

NumPy函数

\(\max(x, y)\)

np.maximum

\(\min(x, y)\)

np.minimum

在用二元函数操作NumPy函数时,我们需要考虑两种情况:

  1. 当函数两个操作数都是(相同形状的)数组时。

  2. 当函数的一个操作数是标量(也就是单个数字),另外一个操作数是数组时。

和对数组运用一元函数时的行为相似,二元数组会对两个形状相同的数组的对应成员进行操作。

>>> x = np.array([0., 1., 2.])
>>> y = np.array([-1., 1., -2.])

# 对 `x` 和 `y` 对应的成员对进行操作
>>> x + y  # 这等值于:`np.add(x, y)`
array([-1.,  2.,  0.])

这个过程可以拓展到任何维度和形状的数组,只需要两个操作数的形状一样。

重要的注释

可以将二元NumPy函数运用到形状不同的数组上。比如说,你可以会希望将一个形状为 (2,) 的数组加到十个形状相似的数组(被存在一个形状为 (10,2) 的数组中)中。这个过程叫做广播(broadcasting)。我们将在后面一节中对其讨论。

# 二元函数操作两个2维数组的例子
>>> x = np.array([[10,  2],
...               [ 3,  5]])

>>> y = np.array([[ 1,   0],
...               [ -4,  -1]])

>>> np.add(x, y)  # 等值于 `x + y`
array([[11,  2],
       [-1,  4]])

# 将 `x` 的列0和 `y` 的行1相加
>>> x[:, 0] + y[1, :]
array([6, 2])

经验

将一个二元NumPy函数 \(f(x,y)\) 运用于两个形状相同的数组上会将 \(f(x,y)\) 应用于两个数组的所有对应成员对,并将讲过作为一个形状相同的数组返回。

阅读理解:二元函数

设2维数组:

>>> x = np.array([[ 0,  1,  2,  3],
...               [ 4,  5,  6,  7],
...               [ 8,  9, 10, 11],
...               [12, 13, 14, 15]])

x 的四个象限(比如说 [[ 0, 1], [ 4, 5]] 就是一个象限) 相加,返回形状为 (2, 2) 的输出。

现在,你可能已经可以猜出NumPy二元函数操作一个标量(也就是单个数字)和一个数组时的行为。函数将对数组的每个成员进行操作;每次运算将会提供数组的某一个成员和不变的向量。这和传统的线性代数的行为完全一样。

>>> 3 * np.array([0., 1., 2.])  # 等值于:`np.multiply(3, x)`
array([ 0.,  3.,  6.])

>>> np.array([1., 2., 3.]) ** 2  # 等值于:`np.power(x, 2)`
array([ 1.,  4.,  9.])

这个过程可以扩展到任何维度和形状的数组。

# 二元函数操作一个标量和一个数组的范例
>>> x = np.array([[10,  2],
...               [ 3,  5]])

>>> np.maximum(4, x)
array([[10,  4],
       [ 4,  5]])

# 一个形状为 (2, 2, 8) 的3维数组
>>> y = np.array([[[ 0,  1,  2,  3,  4,  5,  6,  7],
...                [ 8,  9, 10, 11, 12, 13, 14, 15]],
...
...               [[16, 17, 18, 19, 20, 21, 22, 23],
...                [24, 25, 26, 27, 28, 29, 30, 31]]])

>>> y[0, :, ::2] * -1
array([[  0,  -2,  -4,  -6],
       [ -8, -10, -12, -14]])

经验

将二维NumPy函数 \(f(x,y)\) 应用到一个数组和一个标量将会导致函数将标量“分配”到数组的每一个成员并以此进行二元运算。

序列函数

序列函数接受任意长度的数字序列并返回一个数字:\(f(\{x_i\}_{i=0}^{n-1})\)。以下是一些NumPy的序列函数:

序列函数:\(f(\{x_i\}_{i=0}^{n-1})\)

NumPy函数

\(\{x_i\}_{i=0}^{n-1}\) 的平均值

np.mean

\(\{x_i\}_{i=0}^{n-1}\) 的中间值

np.median

\(\{x_i\}_{i=0}^{n-1}\) 的差额

np.var

\(\{x_i\}_{i=0}^{n-1}\) 的标准差

np.std

\(\{x_i\}_{i=0}^{n-1}\) 的最大值

np.max

\(\{x_i\}_{i=0}^{n-1}\) 的最小值

np.min

\(\{x_i\}_{i=0}^{n-1}\) 最大值的索引

np.argmax

\(\{x_i\}_{i=0}^{n-1}\) 最小值的索引

np.argmin

\(\{x_i\}_{i=0}^{n-1}\) 的和

np.sum

序列NumPy函数的实现在操作1维数组时很简单易懂:

# 演示序列函数
>>> x = np.array([0., 2., 4.])
>>> np.sum(x)  # 等值于:`x.sum()`
6.
>>> np.mean(x)  # 等值于:`x.mean()`
2.

这些函数在操作多维数组时的行为会是如何呢?NumPy的序列函数默认把任何多维数组当作重塑为1维数组的版本对待。比如说:

>>> x = np.array([[0, 1],
...               [2, 3],
...               [4, 5]])

# `sum` 默认将会将多维数组当成一个数字序列对待
>>> np.sum(x)
15

你可以通过关键词参数 axis 来覆盖NumPy序列函数的默认行为。这是一个很有用和常见的功能。我们将会仔细学习axis参数在这些和其它NumPy函数中是如何使用的。

在NumPy序列函数中提供 axis 关键词参数

让我们通过一些例子来探索 axis 参数的意义:

# 创建一个形状为 (3,2) 的数组
>>> x = np.array([[0, 1],
...               [2, 3],
...               [4, 5]])

# 在轴1内对轴0求值
# 也就是说,在每一列中求其中行的和
>>> np.sum(x, axis=0)  # 等值于:x.sum(axis=0)
array([6, 9])

# 在轴0内对轴1求值
# 也就是说,在每一行中求其中列的和
>>> np.sum(x, axis=1)  # 等值于:x.sum(axis=1)
array([1, 5, 9])

# 你也可以使用负的轴索引
>>> np.sum(x, axis=-1)  # 等值于:np.sum(x, axis=1)
array([1, 5, 9])

# 在轴0和轴1内求值
# 也就是说,假装数组是1维序列来求值(默认行为)
>>> np.sum(x, axis=(0, 1))  # 等值于:x.sum(axis=(0, 1))
15

如上,axis 参数提供了在为序列函数创建输入序列时遍历哪个或哪几个轴。每个成员为没有提供的轴的组合都会创建一个序列。比如说,np.sum(x, axis=0) 等于在说:“对 x 的每一列求其行的和”。因此,以下序列将会被求和:

x[:, 0] -> array([0, 2, 4])  # 遍历列0中的所有行
x[:, 1] -> array([1, 3, 5])  # 遍历列1中的所有行

如此,x 的每一列都会被求和,最终返回一个形状为 (2,) 的数组,其成员为这两个和。相似的, np.sum(x, axis=1) 返回一个形状为 (3,) 的数组,其成员为 x 三行求和的结果。

你也可以通过提供整数“元组”(如果是列表而不是元组的话会被接受)提供多个轴。np.sum(x, axis=(0,1)) 会告诉NumPy去遍历 x 的全部两个轴,并将 x 全部的成员当作一个序列来求和,返回一个数字。请回忆,这和不提供 axis 关键词参数时的默认行为一样。

经验

所有序列性的函数都可以接受关键词参数 axisaxis 可以接受单个整数或一元组的整数来描述在指定数组数据序列时要遍历哪些轴。每个合法的未被遍历的轴的索引的组合都会生成一个序列。默认情况下,所有输入元组的轴都会被包含,因此数组的所有成员都会被当作一个序列对待。

理解多维数组中的 axis 参数

理解处理多维数组时的 axis 关键词参数的秘密在于熟悉NumPy遍历数组的方法。如果需要复习一下这个话题,请查阅本模组的第五节。设以下形状为 (4,2,3) 的数组:

>>> x = np.arange(24).reshape(4,2,3)
>>> x
array([[[ 0,  1,  2],
        [ 3,  4,  5]],

       [[ 6,  7,  8],
        [ 9, 10, 11]],

       [[12, 13, 14],
        [15, 16, 17]],

       [[18, 19, 20],
        [21, 22, 23]]])

我们可以认为这个数组有着4页2x3的纸张。遍历 x 的轴0相当于根据每个合理的轴1和轴2索引组合在纸张之间跳跃。如此,np.mean(x, axis=0) 相当于在说:“为每一个行和列的组合求 x 在不同页的平均值”。所以这将在 x 中设定6个不同的序列来让这个序列函数进行操作:

x[:, 0, 0] -> array([ 0,  6, 12, 18])  {平均值 =  9}
x[:, 0, 1] -> array([ 1,  7, 13, 19])  {平均值 = 10}
x[:, 0, 2] -> array([ 2,  8, 14, 20])  {平均值 = 11}
x[:, 1, 0] -> array([ 3,  9, 15, 21])  {平均值 = 12}
x[:, 1, 1] -> array([ 4, 10, 16, 22])  {平均值 = 13}
x[:, 1, 2] -> array([ 5, 11, 17, 23])  {平均值 = 14}

同时,请注意,轴1和轴2的合理组合的集对应着数组一页的2x3格位。NumPy会将这6个平均值作为形状为 (2,3) 的数组返回,使得序列和它平均值之间的对应关系没有歧义:

>>> np.mean(x, axis=0)
array([[  9.,  10.,  11.],
       [ 12.,  13.,  14.]])

回忆

NumPy使用行优先顺序(也叫做C顺序)来遍历数组。

假设我们提供了两个轴,轴0和轴2,遍历这两个轴相当于为每一个轴1的索引遍历 x 的页和列。因此,这将创建两个序列:

x[:, 0, :] -> array([ 0,  1,  2,  6,  7,  8, 12, 13, 14, 18, 19, 20])  {平均值 = 10}
x[:, 1, :] -> array([ 3,  4,  5,  9, 10, 11, 15, 16, 17, 21, 22, 23])  {平均值 = 13}
>>> np.mean(x, axis=(0, 2))
array([ 10.,  13.])

以上这些注意到的点让我们可以进行一下总结:

结果

如果 \(X\) 是一个 \(N\) 维数组,axis 关键词参数为一个NumPy序列函数提供了 \(j\)(满足 \(j \leq N\))个轴,那么这个函数将会返回一个 \(N-j\) 维数组。返回数组的形状是 \(X\) 的形状除去那 \(j\) 个轴的结果。

阅读理解:基本序列函数

一张电子图片其实就是一个数字数组,其指示显示器的像素根据数组的值去发射具体颜色的光。

因此,一张RGB图片可以作为一个3维NumPy数组储存,其形状为 \((V, H, 3)\)\(V\) 是竖向的像素数量,\(H\) 是横向的像素数量,大小为3的维度储存了像素的红,蓝,绿颜色。因此,一个 \((32, 32, 3)\) 的数组将会是一张RGB图片。

我们经常会需要处理图片集。假设我们想要将N张图片储存为一个数组,那么我们可以将其当作一个形状为 \((N, V, H, 3)\) 的4维数组。

让我们收集一些关于这个图片集的统计数据。方便起见,让我们先产生成员为随机数字的4维数组来代替真实的图片数据。我们将会生成100张32x32的RGB图片:

>>> images = np.random.rand(100, 32, 32, 3)

现在,计算以下:

  1. 所有图片平均下来的 32x32 RGB图.

  2. 数组中所有值的和。

  3. 每张图片的最小的蓝色值。

  4. 每个像素位置在所有图片的RGB值的标准偏差(所以说你应该返回形状为 (32, 32) 的数组值)。

  5. 每张图片左上象限的最大红色值。

逻辑运算

NumPy提供了的一系列逻辑运算来操作数组。很多这些函数使用和NumPy的数学函数一样的方式将逻辑操作衍射到每个数组操作。这些函数返回一个布尔对象或一个布尔类的数组。

# 检查 `x` 的哪些成员小于6
>>> x = np.array([[ 0,  1,  2,  3],
...               [ 4,  5,  6,  7],
...               [ 8,  9, 10, 11],
...               [12, 13, 14, 15]])

# 返回布尔类的数组
>>> x < 6  # 等值于:`np.less(x, 6)`
array([[ True,  True,  True,  True],
       [ True,  True, False, False],
       [False, False, False, False],
       [False, False, False, False]], dtype=bool)

# 对两个数组进行逻辑对比
>>> np.array([1, 5, 10]) <=  np.array([1, 5, -1])
array([ True,  True, False], dtype=bool)

请从Python基础模组回忆,因为浮点数的精度有限,你永远不应该假设两个浮点数会完全相同。反而,你应该检查它们的值是否足够“接近”。相似的,你不应该检查两个浮点类数组是否完全相同。为了达到这个目的,函数 allclose 可以用来检查两个数组的对应值是否足够接近:

# 使用 `np.allclose` 检查两个数组是否对应
>>> x = np.array([0.1, 0.2, 0.3])
>>> y = np.array([1., 2., 3.]) / 10

>>> np.allclose(x, y)
True

线性代数

最后,请注意,NumPy有一组可以用来进行优化过的线性代数运算和常规工作的函数。这组工具包括进行矩阵乘法和张量乘法,解特征值问题,矩阵求逆,和矢量归一化的函数。请查看官方NumPy说明文档来阅读这些函数的完整列表。

总结

NumPy为用户提供了各式各样的对数组数据进行操作的函数。它对矢量化的使用使得这些函数相比同样功能的纯Python代码非常快。虽然之前的讨论列出了很多这些函数如何工作的规则,读者不应该去尝试记忆它们。反而,最好的做法是将这些函数运用于各种维度的数组上来发展对其的直观理解。你可能会惊讶于通过实践学习这些材料有多简单。

阅读理解答案:

一元函数:解

x 第三行的第一和第三个成员的自然对数以得到形状为 (2,) 的结果。

>>> x = np.array([[ 0,  1,  2,  3],
...               [ 4,  5,  6,  7],
...               [ 8,  9, 10, 11],
...               [12, 13, 14, 15]])

>>> np.log(x[2, 0::2])
array([ 2.07944154,  2.30258509])

二元函数:解

x 的四个象限相加,返回形状为 (2, 2) 的输出。

>>> x = np.array([[ 0,  1,  2,  3],
...               [ 4,  5,  6,  7],
...               [ 8,  9, 10, 11],
...               [12, 13, 14, 15]])

    #  左上         右上          左下          右下
>>> x[:2, :2] + x[:2, -2:] + x[-2:, :2] + x[-2:, -2:]
array([[20, 24],
       [36, 40]])

基本序列函数:解

>>> images = np.random.rand(100, 32, 32, 3)

# 1. 所有图片平均下来的 32x32 RGB图.
>>> mean_imag = images.mean(axis=0)
>>> mean_imag.shape
(32, 32, 3)

# 2. 数组中所有值的和。
>>> images.sum()
153422.97903817348

# 3. 每张图片的最小的蓝色值。
# 在轴3的颜色顺序为红绿蓝
>>> min_blue = images[:, :, :, 2].min(axis=(1, 2))
>>> min_blue.shape
(100,)

# 4. 每个像素位置在所有图片的RGB值的标准偏差。
>>> pixel_std_dev = images.std(axis=(0, 3))
>>> pixel_std_dev.shape
(32, 32)

# 5. 每张图片左上象限的最大红色值。
>>> max_red_quad = images[:, :16, :16, 0].max(axis=(1, 2))
>>> max_red_quad.shape
(100,)