[学习笔记]Python for Data Analysis, 3E-附录A.高等Numpy

在本附录中,我们将更深入地介绍用于数组计算的NumPy库。这将包括有关ndarray类型的更多内部详细信息以及更高级的数组操作和算法。
本附录包含杂项主题,不一定需要线性阅读。在整个章节汇总,将有许多示例生成随机数据,这些示例将使用nump.random模块中的默认随机数生成器

rng = np.random.default_rng(seed=12345)

A.1ndarray对象内部

NumPy ndarray提供了一种将同类类型数据块(连续或跨步)解释为多维数组对象的方法。数据类型,或dtype,确定如何将数据解释为浮点、整数、布尔值或我们一直在研究的任何其他类型。
使用ndarray变得灵活的部分原因是,每个数组对象都是数据块上的跨步视图。例如,你可能想知道数组视图arr[::2, ::-1]如何不复制任何数据。原因是ndarray不仅仅是一块内存和一种数据类型;它还具有步幅信息,使数组能够以不同的步长在内存中移动。更准确的说,ndarray内部由以下内容组成:

  • 指向数据的指针,即RAM或内存映射文件中的数据块
  • 描述数组中固定大小值单元格的数据类型或dtype
  • 指示数组形状的元组
  • 步长元组-表示要’步进’的字节数的整数,以便沿维度推进一个元素

参看图A.1查看ndarray内部的简单模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oEbgJPvP-1669683481278)(https://secure2.wostatic.cn/static/hQsboLFmyJU1gmuC1W563a/image.png?auth_key=1669683468-c1yCdzA8iaipGj65Gw21Aq-0-742f9b250343aaffa9d6377382f3421f)]

例如,一个10*5的数组的形状为(10, 5):

np.ones((10, 5)).shape # 返回(10, 5)

一个典型的345的float64(8字节)值组成的数组由步幅(160, 40, 8)(了解步幅很有用,因为一般来说,在特定的轴上不服越大,沿该轴执行计算的成本就越高)

np.ones((3, 4, 5), dtype=np.float64).strides # 返回步幅(160, 40, 8)

虽然典型的NumPy用户很少对数组步幅感兴趣,但需要它们来构建’零拷贝’数组视图。步幅甚至可以是负数,这使得数组能够在内存中’向后’移动。(例如,在如obj[::-1]或obj[:, ::-1]的切片中的情形)

NumPy数据类型层次结构

有时需要检查数组是否包含整数、浮点数、字符串或Python对象的代码。由于存在多种类型的浮点数(float16到float128),因此检查数据类型是否属于类型列表将非常冗长。幸运的是,数据类型具有父类,例如np.integer和np.floating,它们可以被用于np.issubdtype函数:

ints = np.ones(10, dtype=np.uint16)
floats = np.ones(10, dtype=np.float32)
np.issubdtype(ints.dtype, np.integer) # 判断数据结构的数据类型是否为制定数据类型的子类
np.issubdtype(floats.dtype, np.floating)

你可以通过调用类型的mro方法查看特定数据类型的所有父类:

np.float64.mro() # 返回[numpy.float64, numpy.floating, numpy.inexact, numpy.number, numpy.generic, float, object]
# 因此还有
np.issubdtype(ints.dtype, np.number) # 返回True

大多数NumPy用户永远不必知道这一点,但它偶尔很有用。有关数据类型层次结构和父子类关系的图形,可以参看图A.2。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-51d8KWe4-1669683481279)(https://secure2.wostatic.cn/static/hY5vgKvL6qxHxrhCGdpPNQ/image.png?auth_key=1669683468-jtgxF72Ef49pYnbYQFKx9N-0-5cfb319d93630ade3e3a47199854af3a)]

A.2高级数组操作

除了花哨的索引、切片和布尔子集之外,还有许多方法可以使用数组。虽然数据分析应用程序的大部分繁重工作都由pandas中的更高级别函数处理,但在某些时间你可能需要编写现有库中找不到的数据算法。

重塑数组

在许多情况下,你可以将数组从一个形状转换为另一个形状,而无需复制任何数据。为此,请将指示新形状的元组传递给reshape数组实例方法。例如,假设我们有一个一维数组,我们希望将其重新排列成一个矩形(如图A.3所示)

arr = np.arange(8)
arr.reshape((4, 2)) # 默认按行优先重塑,但可以通过传递参数order='F'指定按列优先排列

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SvlFtQTw-1669683481280)(https://secure2.wostatic.cn/static/mWK1sMY8FVNT3P9FQHeVqE/image.png?auth_key=1669683468-dWPgkJDD8Eek8wAp98RGPd-0-4023fe7b3f833d634bd66fa99cac133d)]

多维数组也可以重塑

arr.reshape((4, 2)).reshape((2, 4))

传递的形状维度之一可以是-1,在这种情况下,将从数据推断用于该维度的值

arr = np.arange(15)
arr.reshape((5, -1)) # 由于数据长度为15,第0个维度为5,则第二个维度一定为3

由于数组的shape属性是一个元组,因此它也可以传递给reshape

other_arr = np.ones((3, 5))
other_arr.shape # 返回(3, 5)
arr.reshape(other_arr.shape)

从一维到更高维reshape操作的相反的操作通常称为展平(flattening)或拉平(raveling)

arr = np.arange(15).reshape((5, 3))
arr.ravel()   # ravel方法不会生成原数据的副本,对数据更改时,会影响原数组
arr.flatten() # flatten方法的行为类似于ravel,但它始终返回原数据的副本,对数据更改时,不会影响原数组

数据可以按照不同的顺序重塑或整理。对于新的NumPy用户来说,这是一个稍微微妙的主题,因此是下一个主题。

C与FORTRAN顺序

NumPy能够适应内存中数据的许多不同的布局。默认情况下,NumPy数组按行优先顺序创建。从空间上讲,这意味着如果你有一个二维数据数组,则数组中每行中的项将存储在相邻的内存位置。行优先顺序的代替方法是列优先顺序,这意味着每列数据中的值存储在相邻的内存位置。
由于历史原因,行和列优先顺序成为C和FORTRAN顺序。在FORTRAN77语言中,矩阵都是列优先顺序。
函数如reshape和ravel接受一个order参数,指示数组中数据使用的顺序。多数情况下,通常设置为’C’或’F’(也有不太常用的选项’A’和’K’,查看上面的图A.3了解这些选项的说明)

arr = np.arange(12).reshape((3, 4))
arr.ravel()    # 按行优先的顺序拉平,返回arrary([0, 1, 2, 3, 4, ...])
arr.ravel('F') # 按列优先的顺序拉平,返回arrary([0, 4, 8, 1, 5, ...])

重塑具有两个以上维度的数组可能有点令人费解,C和FORTRAN顺序之间的主要区别在于维度的遍历方式:

  • C/行优先顺序:首先遍历更高的维度(例如,首先沿着轴1遍历,然后沿着轴0遍历)
  • FORTRAN/列优先顺序:最后遍历更高的维度(例如,首先沿着轴0遍历,然后沿着轴1遍历)

串联和拆分数组

numpy.concatenate以数组的序列(元组、列表等)为参数,并沿着输入轴按顺序连接它们:

arr1 = np.array([[1, 2, 3], [4, 5, 6]])
arr2 = np.array([[7, 8, 9], [10, 11, 12]])
np.concatenate([arr1, arr2], axis=0) # 沿着行进行拼接
np.concatenate([arr1, arr2], axis=1) # 沿着列进行拼接

有一些方便的函数,如vstack和hstack,用于常见的串联类型。上述操作可以表示为

np.vstack((arr1, arr2)) # 沿着行(垂直)进行拼接
np.hstack((arr1, arr2)) # 沿着列(水平)进行拼接

另一方面,split函数将一个数组沿轴切成多个数组

arr = rng.standard_normal((5, 2)) # 构造5行2列的数组
first, second, third = np.split(arr, [1, 3]) # 传递给np.split的值[1, 3]指示要将数组拆分为多个部分的头索引。这里返回第0行、第1行、第3行开始的数组

表A.1:数组串联函数

堆叠帮助函数:r_和c_

NumPy命名空间中有两个特殊的对象,r_和c_,它们使堆叠数组更加简洁:

arr = np.arange(6)
arr1 = arr.reshape((3, 2))
arr2 = rng.standard_normal((3, 2))
np.r_[arr1, arr2] # 数组沿着行堆叠
np.c_[np.r_[arr1, arr2], arr] # 数组先沿行堆叠,再沿列堆叠

这些还可以将切片转换为数组

np.c_[1:6, -10:-5] # 两个切片沿列
重复元素:平铺和重复

用于重复或复制数组以生成更大数组的两个有用工具是repeat和tile函数。

# repeat函数将数组中的每个元素复制几次,从而生成一个更大的数组
arr = np.arange(3) # 返回array([0, 1, 2])
arr.repeat(3) # 返回array([0, 0, 0, 1, 1, 1, 2, 2, 2])

注意:复制或重复数组的需求在NumPy中可能不如其他数组编程框架(如MATLAB)常见。其中一个原因是广播通常可以更好地满足这一需求,这是下一节的主题。

# 默认情况,如果传递一个整数,则每个元素将重复该次数。如果传递整数数组,则每个元素可以重复不同的次数:
arr.repeat([2, 3, 4]) # 返回array([0, 0, 1, 1, 1, 2, 2, 2, 2])

# 多维数组可以沿特定轴重复其元素:
arr = rng.standard_normal((2, 2))
arr.repeat(2, axis=0) # 跨行重复
# 注意:如果没有传递轴,则数组将首先展平,这可能不是你想要的结果
# 同样地,在重复多维数组时,你可以传递整数数组,以将给定切片重复不同的次数:
arr.repeat([2, 3], axis=0)
arr.repeat([2, 3], axis=1)
# 另一方面,tile是沿轴堆叠数组副本的快捷方式。从视觉上看,它类似于'铺设瓷砖'(而不是元素的重复):
np.tile(arr, 2) # 第二个参数是图块数;使用标量时,平铺是逐行进行的,而不是逐列进行的
# tile函数的第二个参数可以是指示'平铺'布局的元组:
np.tile(arr, (2, 1)) # 将arr视为一个元素,通过复制,将其排列成2行1列的形式
np.tile(arr, (3, 2)) # 将arr视为一个元素,通过复制,将其排列成3行2列的形式

花式索引等价:take和put函数

你可能还记得’第四章:NumPy基础知识:数组和矢量化计算’中,获取和设置数组子集的一种方法是使用整数数组进行’花式索引’(fancy index):

arr = np.arange(10) * 100
inds = [7, 1, 2, 6]
arr[inds] # 返回array([700, 100, 200, 600])

在仅在单个轴上进行选择的特殊情况下,有一些替代的ndarray方法很有用:

arr.take(inds) # 提取对应索引上的值
arr.put(inds, 42) # 将对应索引上的值替换为42
arr.put(inds, [40, 41, 42, 43]) # 将对应索引上的值替换为[40, 41, 42, 43]

# 要沿其他轴使用take函数,你可以传递axis关键字
inds = [2, 0, 2, 1]
arr = rng.standard_normal((2, 4))
arr.take(inds, axis=1) # 跨列提取:这里提取第2列、第0列、第2列、第1列组成新数组

put函数不接受axis参数,而是索引到数组的平展版本(一维,C排序)中。因此,当你需要在其他轴上使用索引数组设置元素时,最好使用基于[]索引的索引。

A.3广播

广播控制着在不同形状的数组之间的操作的工作方式。它可能是一个强大的功能,但它可能会导致混乱,即使对于有经验的用户也是如此。最简单的广播示例发生在将标量值与数组组合时:

arr = np.arange(5) # 创建数组array([0, 1, 2, 3, 4])
arr*4 # 数组的每个元素*4,即得到素组array([0, 4, 8, 12, 16]),这样,我们就说标量值4已经广播到乘法运算中的所有其他元素

例如,我们可以通过减去列均值来对数组的每一列去平均。在这种情况下,只需要减去包含每列平均值的数组。

arr = rng.standard_normal((4, 3)) # 创建4行3列的矩阵
arr.mean(0) # 对每一列求均值
demeaned = arr - arr.mean(0) # 每一列都会减去对应那一列的均值,有关此操作的说明,见下图A.4

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9YHvQpOo-1669683481281)(https://secure2.wostatic.cn/static/fkd4QCCdAzoN96DKEzx41K/image.png?auth_key=1669683468-NjrD7zyyH9UbLkn6ZXzLf-0-6cb405cecfa8c8b58ccd3d5bdb8b3b84)]

对行做去平均广播操作时需要更加小心。幸运的是,只要遵循规则,就可以在数组的任何维度上广播潜在的较低维度值(例如从二维数组的每一列中减去其平均值)。这就引出了广播规则。
如果对于每个’后缘维度’(trailing dimension)(即从末端开始),轴长度匹配(轴长度相等),或者如果其中一个长度为1,则两个数组兼容广播。然后在缺失或长度为1的维度上执行广播。
即使作为一个经验丰富的NumPy用户,我也经常发现自己在考虑广播规则时不得不停下来画一个图表。考虑最后一个示例。因为arr.mean(0)的长度为3,arr的后缘维度也是3,它们匹配,因此这两个数组在沿轴0的广播兼容。假设我们希望从每行中减去一个平均值,根据规则,要沿轴1广播减法(即从每行中减去行平均值),较小的数组必须具有以下形状(4, 1):

row_means = arr.mean(1) # 沿着轴1(列)求平均(即求行平均值)
row_means.shape # 返回(4,)
row_means.reshape((4, 1)) # 通过reshape函数将其转化为(4, 1)的数组
demeaned = arr - row_means.reshape((4, 1)) # 有关此操作的说明见下图A.5
demeand.mean(1)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MTDf2SZf-1669683481281)(https://secure2.wostatic.cn/static/cHgJ5n4oEq91tZ5PweqbwC/image.png?auth_key=1669683468-bNKH5pYwKp6drJpGtRqn8A-0-e8860a11fbedbc30b7e9e1919a26cd3b)]

见图A.6的另一个说明,这次是沿第0轴将二维数组添加到三维数组

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gj9WA1eY-1669683481281)(https://secure2.wostatic.cn/static/2K7LXVV49ow843CSduxP8w/image.png?auth_key=1669683468-xiNH8k2bZAXvVH4v3dMK3n-0-4cad5a9b50cdd566cc71457ead43850c)]

在其他轴上广播

使用更高维度的数组进行广播似乎更加令人费解(上面使用(4,1)经过reshape的row_means,而不是用(4,)的arr.mean(1)),但这实际上是一个遵循规则的问题。如果不这么做,则会收到如下错误:

arr - arr.mean(1) # 报错:形状(4,3)和(4,)之间的操作无法进行广播

想要沿着第0轴以外的轴使用低维数组执行算术运算是很常见的,根据广播规则,'广播维度’在较小的数组中必须是1。在此处显示的行去平均示例中,这意味着将行重塑为(4,1)的形状,而不是(4,):

arr - arr.mean(1).reshape((4, 1))

在三维情况下,在三个维度中的任何一个上进行广播只是重塑数据以使其与形状兼容的问题。图A.7很好地可视化了在三维数组的每个轴上广播所需要的形状。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0onhNiB9-1669683481282)(https://secure2.wostatic.cn/static/shoFTZvH4jaxDED4s1SboC/image.png?auth_key=1669683468-udNpNPTFYUj7pNTAePHv48-0-0bc11e82c519475923830966bb7885c6)]

因此,一个常见的问题是需要添加一个长度为1的新轴,专门用于广播目的。使用reshape是一种选择,但插入轴需要构造一个指示新形状的元组,这通常可能是一项乏味的工作。因此,NumPy数组提供了一个特殊的语法,用于通过索引插入新轴。我们使用特殊的np.newaxis属性和完整’切片’来插入新轴:

arr = np.zeros((4, 4))
arr_3d = arr[:, np.newaxis, :] # 实现了新轴的插入
arr_3d.shape # 返回(4, 1, 4)
arr_1d = rng.standard_normal(3)
arr_1d[:, np.newaxis] # 返回(3, 1)的数组
arr_1d[np.newaxis, :] # 返回(1, 3)的数组

# 因此,如果我们有一个三维数组并想要沿着第2个轴去平均,我们需要:
arr = rng.standard_normal((3, 4, 5))
depth_means = arr.mean(2) # 返回3行4列的矩阵
demeaned = arr - depth_means[:, :, np.newaxis] # 沿第2个轴去平均

你可能想知道是否存在一种方法可以在不牺牲性能的情况下,一般化沿某个轴进行的去平均操作。这种方法是存在的, 但它需要用到一些索引技巧:

def demean_axis(arr, axis=0):
    means = arr.mean(axis) # 构造均值数组
    # 一般的情形如[:, :, np.newaxis]可以推广到N维
    indexer = [slice(None)] * arr.ndim # slice()函数是实现切片对应的python内置函数,一般用在复杂代码开头提前定义好切片样式,
                                       # slice(None)是切片':'的实例,arr.ndim返回的是数组的维度个数
    indexer[axis] = np.newaxis
    return arr - means[tuple(indexer)] # 必须有tuple函数进行转化才能被用于索引

通过广播设置数组值

管理算术运算的广播规则同样适用于通过数组索引设置值。在一个简单的例子中,我们可以做这样的事情:

arr = np.zeros((4, 3))
arr[:] = 5 # 将数组中的所有值设置为5

但是,如果我们有一个想要设置到数组列中的一维值数组,只要形状兼容,我们就可以这样做:

col = np.array([1.28, -0.42, 0.44, 1.6]) # 创建形状为(4,)的数组
arr[:] = col[:, np.newaxis]  # 将col切片成形状为(4,1)的数组,以使其与arr兼容广播,并设置到arr数组中
arr[:2] = [[-1.37], [0.509]] # 被赋值数组的形状为(2,3)(arr数组的前两行),赋值数组形状为(2,1),则沿着第1轴广播并赋值

A.4高级ufunc(通用函数)用法

虽然许多NumPy用户只会使用通用函数提供的快速元素操作,但偶尔会有一些附加功能可以帮助你编写更简洁的代码,而无需显式循环。

ufunc实例方法

每个NumPy的二进制ufuncs都有特殊的方法来执行某些类型的特殊矢量化操作。表A.2总结了这些内容,但我会举几个具体的例子来说明它们是如何工作的。

reduce函数获取单个数组为参数,并通过执行一系列二进制操作来沿着某个轴(可选项)聚合其值。例如,对数组中的元素求和的一种方法是使用np.add.reduce:

arr = np.arange(10)
np.add.reduce(arr)
arr.sum()

起始值取决于ufunc函数(例如,对add函数而言,0是起始值)。如果函数中传递了一个轴参数,则会沿着该轴执行缩减。这使你可以简洁地回答某些类型的问题。作为一个不太平凡的例子,我们可以用np.logical_and函数来检查数组每一行中的值是否排序:

my_rng = np.random.default_rng(12346) # 为了复用
arr = my_rng.standard_normal((5, 5))   # 构建(5, 5)的数组
arr[::2].sort(1) # 筛选第0行、第2行、第4行,这些行按第1轴排序
arr[:, :-1] < arr[:, 1:] # arr的前5行前4列构成的数组,与arr的前5行后4列构成的数组比较(即每行的元素和该行下一列的元素比较),得到5*4的布尔数组
np.logical_and.reduce(arr[:, :-1] < arr[:, 1:], axis=1) # 沿着第1轴,使用logical_and函数聚合值,如果值为True,说明该行已排序,否则,说明未排序
# 请注意:logical_and.reduce方法等价于all方法
np.all(arr[:, :-1] < arr[:, 1:], axis=1)

accumulate通用函数与reduce函数相关,它们的关系类似于cumsum和sum的关系。它生成一个具有中间’累积’值的相同大小的数组

arr = np.arange(15).reshape((3, 5))
np.add.accumulate(arr, axis=1) # 返回3*5的数组,元素值为沿着第1轴求和时的'累积和'

outer函数会在两个数组之间执行成对交叉乘积:

arr = np.arange(3).repeat([1, 2, 2]) # 返回数组:array([0, 1, 1, 2, 2])
np.multiply.outer(arr, np.arange(5)) # (5,)的数组与(5,)的数组做成对交叉乘积,得到(5,5)的数组
# outer函数的输出的维度是输入维度的拼接
x, y = rng.standard_normal((3, 4)), rng.standard_normal(5)
result = np.subtract.outer(x, y) # (3, 4)的数组与(5,)的数组做成对交叉相减,得到(3, 4, 5)的数组

最后一种reduceat方法执行’局部reduce’,它实质上是数组的’分组依据’操作,其中数组的切片聚合在一起。它接受一系列’分箱边缘’,指示如何拆分和聚合值

arr = np.arange(10)
np.add.reduceat(arr, [0, 5, 8]) # 先分组,arr[0:5]为1组,arr[5:8]为一组,arr[8:]为一组,组内执行求和运算
# 与其他方法一样,你可以传递axis参数
arr = np.multiply.outer(np.arange(4), np.arange(5))
np.add.reduceat(arr, [0, 2, 4], axis=1) # 沿着列分组,组内执行求和运算

有关ufunc方法的部分列表,请参看表A.2。

表A.2:ufunc方法

用Python编写新的ufuncs

有许多方法可以创建自己的NumPy通用函数。最一般的是使用NumPy的C API,但这超出了这本书的范围。在本节中,我们将介绍纯Python通用函数。
numpy.frompyfunc接受一个Python函数以及输入和输出数量的规范。例如,逐元素相加的简单函数将被指定为

def add_elements(x, y):
    return x+y

add_them = np.frompyfunc(add_elements, 2, 1) # 指定Python函数以及输入数量为2,输出数量为1
add_them(np.arange(8), np.arange(8))

创建的函数总是返回Python对象的数组,这可能很不方便。幸运的是,还有一个代替(但功能稍微不丰富)的函数,numpy.vectorize,它允许你指定输出类型

add_them = np.vectorize(add_elements, otypes=[np.float64])
add_them(np.arange(8), np.arange(8))

这些函数提供了一种创建类似ufunc函数的方法,但它们非常慢,因为它们需要Python函数调用来计算每个元素,这比NumPy的基于C的ufunc循环慢得多

arr = rng.standard_normal(10000)
%timeit add_them(arr, arr) # 返回:1.74 ms ± 95.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%timeit np.add(arr, arr)   # 返回:2.78 µs ± 243 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

在本附录的后面部分,我们将展示如何使用Numba库在Python中创建快速ufuncs。

A.5结构化和记录数组

到目前为止,你可能已经注意到ndarray是一个同构数据容器;也就是说,它表示一个内存块,其中每个元素占用相同数量的字节,由数据类型确定。从表面上看,这似乎不允许你表示异构或表格数据。一个结构化数组是一个ndarray,其中每个元素都可以被认为是表示C中的一个结构(因此称为’结构化’)或SQL表中具有多个命名字段的行:

dtype = [('x', np.float64), ('y', np.int32)] # 指定数组元素中的第一个数的字段名为'x',数据类型为'np.float64',第二个数可类似解读
sarr = np.array([(1.5, 6), (np.pi, -2)], dtype=dtype) # 返回:array([(1.5, 6), (3.14159265, -2)], dtype=[('x', '<f8'), ('y', '<i4')])

有几种方法可以指定结构化数据类型(请参阅在线NumPy文档)。一种典型的方法是(field_name, field_data_type)作为元组列表。现在,数组的元素是类似元组的对象,其元素可以像字典一样访问:

sarr['x'] # 返回array([1.5, 3.1416])

嵌套数据类型和多维字段

当指定结构化数据类型时,还可以传递形状(一个int或元组):

dtype = [('x', np.int64, 3), ('y', np.int32)] # 嵌套数据类型:每行记录的第一个元素是一个长为3的数组,其字段名为'x'
arr = np.zeros(4, dtype=dtype) # 生成5行记录,每行记录的类型为指定的嵌套数据类型
# 在这种情况下,字段'x'可以引用每条记录中的长度为3的数组
arr[0]['x'] # 返回array([0, 0, 0])     

方便的是,访问arr[‘x’]会返回一个二维数组(所有行记录的字段’x’对应的数据组成的数组),而不是前面示例中的一维数组

arr['x']    # 返回array([[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]])

这使你能够将更复杂的嵌套结构表示为数组中的单个内存块。你还可以嵌套数据类型以创建更复杂的结构。下面是一个示例:

dtype = [('x', [('a', 'f8'), ('b', 'f4')]), ('y', np.int32)] # 字段x内嵌套着字段'a'和'b'
data = np.array([((1, 2), 5), ((3, 4), 6)], dtype=dtype)
data['x'] # 返回array([(1., 2.), (3., 4.)], dtype=[('a', '<f8), ('b', '<f4')])
data['y']
data['x']['a'] # 返回array([1., 3.])

pandas的DataFrame不支持在相同方式下使用这个功能,尽管它类似于分层索引,

为什么使用结构化数组?

与DataFrame相比,NumPy结构化数组是一个较低水平的工具。它们提供了一种将内存块解释为具体嵌套列的表格结构的方法。由于数组中的每个元素在内存中表示为固定数量的字节,因此结构化数组提供了一种有效的方法来将数据写入磁盘和从磁盘(包括内存映射)写入数据,通过网络传输睡以及其他此类用途。结构化数组中每个值的内存布局基于C编程语言中结构数据类型的二进制表示形式。
作为结构化数组的另一个常见用途,将数据文件写入固定长度的记录字节流是用C和C++代码序列化数据的常用方法,这有时在工业遗留系统中可以找到。只要知道文件的格式(每条记录的大小以及每个元素的顺序、字节大小和数据类型),就可以用np.fromfile将数据读入内存。像这样的特殊用途超过了本书的范围,但值得知道的事,这样的事情是可能的。

A.6关于排序的更多信息

与Python的内置列表一样,ndarray的sort实例方法是一种就地排序,这意味着数组内容在不生成新数组的情况下重新排列:

arr = rng.standard_normal(6)
arr.sort()
arr # 返回顺序排列的arr数组

就地对数组进行排序时,请记住,如果数组是一个不同的ndarray上的视图,则将修改原始数组:

arr = rng.standard_normal((3, 5))
arr[:, 0].sort() # 对第0列切片视图排序,将修改原数组

另一方面,numpy.sort函数创建数组的新的排序副本。而且,它接受与ndarray的sort方法一样的参数(如kind)

arr = rng.standard_normal(5)
np.sort(arr)

所有这些排序方法都采用轴参数,用于沿传递的轴独立对数据部分进行排序:

arr = rng.standard_normal((3, 5))
arr.sort(axis=1) # 沿着列对数组进行排序

你可能注意到,没有一个排序方法可以选择按降序排列。这在实际中是一个问题,因为数组切片会产生视图,因此不会生成副本或需任何计算工作。许多Python用户都熟悉对于values列表的技巧,values[::-1]会以相反顺序返回列表。对于ndarrays也是如此:

arr[:, ::-1] # 选取所有行,所有列,其中列倒序排列

间接排序:参数排序和字典排序

在数据分析中,您可能需要按一个或多个键对数据集进行重新排序。例如,有关某些学生的数据表可能需要先按姓氏排序,然后按名字排序。这是一个间接排序的例子。给定一个或多个键(值数组或多个值数组),您希望获得一个整数索引数组(我通俗将它们称为索引),它能告诉告诉如何将数据进行重新排序。有两种方法,它们分别是argsort和numpy.lexsort。下面是一个例子:

values = np.array([5, 0, 1, 3, 2])
indexer = values.argsort() # 返回从小到大排序的值的索引组成的数组,如最小的值的索引为1,则1为数组的第一个数:array([1, 2, 4, 3, 0])
values[indexer] # 按照顺序索引选取数组的值,得到按顺序排序的数组

作为一个更复杂的示例,此代码按第一行对二维数组重新排序:

arr = rng.standard_normal((3, 5))
arr[0] = values # 第0行赋值数组
arr[:, arr[0].argsort()] # 根据第0的数据的顺序排序得到数组

lexsort与argsort类似,但它对多个键数组执行间接字典排序。假设我们想对一些由名字和姓氏标识的数据进行排序:

first_name = np.array(['Bob', 'Jane', 'Steve', 'Bill', 'Barbara'])
last_name = np.array(['Jones', 'Arnold', 'Arnold', 'Jones', 'Walters'])
sorter = np.lexsort((first_name, last_name)) # 返回:array([1, 2, 3, 0, 4])
list(zip(last_name[sorter], first_name[sorter])

第一次使用lexsort可能会有点混乱,因为它使用键对数据进行排序的顺序是从最后一个传递的数组开始的。这里,last_name先于first_name被使用。

可选择的排序算法

稳定的排序算法可以保留相等元素的相对位置。这在相对排序有意义的间接排序中尤其重要:

注意:所谓的排序算法的稳定性是指:设关键字Ki=Kj,且排序前的序列中Ki领先于Kj,若排序后Ki仍然领先于Kj,则称这个排序方法是稳定的。

values = np.array(['2:first', '2:second', '1:first', '1:second', '1:third'])
key = np.array([2, 2, 1, 1, 1])
indexer = key.argsort(kind='mergesort') # 返回:array([2, 3, 4, 0, 1])
values.take(indexer)

唯一可用的稳定排序是mergesort,它具有保证 O ( n l o g n ) O(nlogn) O(nlogn)的性能,但其平均性能比默认的快速排序方法要差。
有关可用方法及其相关性能(和性能保证)的摘要,见下表。这不是大多数用户必须考虑的事情,但知道它在那里很有用。

表A.3:数组排序方法

部分排序数组

排序的目标之一是确定数组中最大或最小的元素。NumPy具有快速的方法,numpy.partition和np.argpartition,用于围绕第k个最小元素对数组进行区分:

rng = np.random.default_rng(12345)
arr = rng.standard_normal(20)
np.partition(arr, 3)

调用partition(arr, 3)后,结果中的前三个元素是最小的三个值,但整个数组没有特定的顺序。numpy.argpartition,类似于numpy.argsort,会返回将数据重新排列为等效顺序的索引:

indices = np.argpartition(arr, 3) # 返回np.partition得到的数组对应的索引
arr.take(indices) # 利用索引得到数组

numpy.searchsorted:在排序数组中查找元素

searchsorted是一个数组方法,它对一个已排序数组执行二叉搜索,返回数组中需要插入值以保持排序的位置:

arr = np.array([0, 1, 7, 12, 15])
arr.searchsorted(9) # 9需要插入的位置为3,返回:3

你还可以传递值数组以返回索引数组:

arr.searchsorted([0, 8, 11, 16]) # 返回传递数组的值对应插入位置组成的数组

你可能已经注意到,searchsorted对于0元素,返回索引0。这是因为默认行为是返回一组相等值左侧的索引:

arr = np.array([0, 0, 0, 1, 1, 1, 1])
arr.searchsorted([0, 1]) # 由于默认返回左侧索引,所以这里返回:array([0, 3])
arr.searchsorted([0, 1], side='right') # 传递side='right'则返回右侧索引,所以这里返回:array([3, 7])

作为searchsorted的另一个应用,假设我们有一个介于0和10000之间的值数组,以及一个单独的’桶边’数组,我们想用它对数据进行装箱:

data = np.floor(rng.uniform(0, 100000, size=50))
bins = np.array([0, 100, 1000, 5000, 10000])

然后,要获得每个数据点所属间隔的标签(其中1表示桶(0, 100]),我们可以简单地使用searchsorted:

labels = bins.searchsorted(data) # 0表示(-inf, 0], 1表示(0, 100]

这可以和pandas的groupby结合,被用于bin数据:

pd.Series(data).groupby(labels).mean() # 按照labels分组,组内求平均值

A.7使用Numbda编写快速NumPy函数

Numba是一个开源项目,使用CPU、GPU或其他硬件为类似NumPy的数据创建快速函数。它使用LLVM项目将Python代码转换为已编译的机器代码。
要介绍Numba,让我们考虑一个纯Python函数,该函数使用for循环计算表达式(x-y).mean():

import numpy as np

def mean_distance(x, y):
    nx = len(x)
    result = 0.0
    count = 0
    for i in range(nx):
        result += x[i] - y[i]
    return result/count

这个函数很慢:

x = rng.standard_normal(10_000_000)
y = rng.standard_normal(10_000_000)
%timeit mean_distance(x, y) # 返回:3.07 s ± 93.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit (x-y).mean() # 返回:24.2 ms ± 166 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

NumPy版本速度快了100多倍。我们可以使用numba.jit函数将此函数转化为已编译的Numba函数:

import numba as nb
numba_mean_distance = nb.jit(mean_distance)

我们也可以将它改写成一个装饰器:

@nb.jit
def numba_mean_distance(x, y):
    nx = len(x)
    result = 0.0
    count = 0
    for i in range(nx):
        result += x[i] -y[i]
        count += 1
    return result/count

生成的函数实际上比矢量化的NumPy版本更快:

%timeit numba_mean_distance(x, y) # 13.1 ms ± 248 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

Numba无法编译所有纯Python代码,但它支持Python的一个重要子集,该子集对编写数值算法最有用。
Numba是一个深度库,支持不同类型的硬件、编译模式和用户扩展。它还能够编译NumPy Python API的实质性子集,而无需显式循环。Numba能够识别可以编译为机器代码的构造,同时用对Cpython API的调用替换它不知道如何编译的函数。Numba的jit函数选项,nopython=True,将允许的代码限制在无需任何Python C API 调用的情况下编译为LLVM的Python代码。jit(nopython=True)有一个较短的别名numba.njit。

# 对于前面的示例,我们也可以改写成
from numba import float64, njit

@njit(float64(float64[:], float64[:]))
def mean_distance(x, y):
    return (x-y).mean()

我鼓励你通过阅读Numba的在线文档了解更多信息。下一节展示了创建自定义NumPy通用函数对象的示例。

使用Numba创建自定义numpy.ufunc对象

numba.vectorize函数创建已编译的NumPy通用函数,其行为类似于内置的通用函数。让我们考虑一下numpy.add的Python实现:

from numba import vectorize

@vectorize
def nb_add(x, y):
    return x + y

现在我们有

x = np.arange(10)
nb_add(x, x)
nb_add.accumulate(x, 0)

A.8高级数组输入和输出

在’第四节:NumPy基础:数组和向量化计算’中,我们熟悉了np.save和np.load,它们用于在磁盘上以二进制格式存储数组。有一些额外的选项需要考虑,以便更复杂的使用。特别是,内存映射的额外好处是是你能够对不适合RAM的数据集进行某些操作。

内存映射文件

内存映射文件是一种与磁盘上的二进制数据交互的方法,使得数据就像存储在内存数组中一样。NumPy实现了一个类似于ndarray的memmap对象(memory map),使大文件的小部分可以读取和写入,而无需将整个数组读取到内存中。此外,memmap具有与内存数组相同的方法,因此可以替换为许多需要ndarray的算法。

# 使用np.memmap函数并传递二进制数据文件路径、数据类型、文件模式和形状,就可以创建新的内存映射(memmap)对象
mmap = np.memmap('mymmap', dtype='float64', mode='w+', shape=(10000, 10000))
# 如果创建memmap对象时使用的模式为'c',则更改不会保存到磁盘

# 对memmap进行切片将返回磁盘上数据的视图
section = mmap[:5] # memmap对象的切片

如果你分配数据,它将缓冲在内存中,这意味着如果你在其他应用程序中读取文件,更改将不会立即反映在磁盘文件中。任何修改都可以通过调用flush同步到磁盘:

section[:] = rng.standard_normal((5, 10000)) # 将数据写入memmap数组对象(切片)
mmap.flush() # 经过测试,好像没有任何作用
del mmap # 删除memmap实例以关闭memmap文件

每当内存映射超出范围并收集垃圾时,任何更改也将刷新到磁盘。打开现有内存映射时,您仍需要指定数据类型和形状,因为该文件只是二进制数据块,没有任何数据类型信息、形状或步幅:

mmap = np.memmap('mymmap', dtype='float64', shape=(10000, 10000))

内存映射也适用于结构化或嵌套数据类型,如结构化和记录数组所述。

如果你在计算机上运行此示例,你可能需要删除我们在上面创建的大文件:

%xdel mmap
!rm mymmap

HDF5和其他数组存储选项

PyTables和 h5py是两个Python项目,提供NumPy友好的界面,用于以高效和可压缩的HDF5格式存储数组数据(HDF代表分层数据格式)。您可以以HDF5格式安全地存储数百千兆字节甚至太字节的数据。要了解有关将HDF5与Python一起使用的更多信息,我建议阅读pandas在线文档。

A.9性能提示

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值