Python 数据科学手册第二版(二)
原文:
zh.annas-archive.org/md5/051facaf2908ae8198253e3a14b09ec1译者:飞龙
第六章:NumPy 数组上的计算:通用函数
到目前为止,我们已经讨论了 NumPy 的一些基本要点。在接下来的几章中,我们将深入探讨 NumPy 在 Python 数据科学世界中如此重要的原因:因为它提供了一个简单灵活的接口来优化数据数组的计算。
NumPy 数组上的计算可能非常快,也可能非常慢。使其快速的关键在于使用向量化操作,通常通过 NumPy 的通用函数(ufuncs)实现。本章阐述了使用 NumPy ufuncs 的必要性,它可以使对数组元素的重复计算更加高效。然后介绍了 NumPy 包中许多常见和有用的算术 ufuncs。
循环的缓慢性
Python 的默认实现(称为 CPython)有时会非常慢地执行某些操作。这在一定程度上是由于语言的动态解释性质造成的;类型灵活,因此无法像 C 和 Fortran 语言那样将操作序列编译成高效的机器码。近年来,有各种尝试来解决这一弱点:著名的例子有 PyPy 项目,一个即时编译的 Python 实现;Cython 项目,它将 Python 代码转换为可编译的 C 代码;以及 Numba 项目,它将 Python 代码片段转换为快速的 LLVM 字节码。每种方法都有其优势和劣势,但可以肯定的是,这三种方法都还没有超越标准 CPython 引擎的普及度和影响力。
Python 的相对缓慢通常在需要重复执行许多小操作的情况下显现出来;例如,循环遍历数组以对每个元素进行操作。例如,假设我们有一个值数组,并且想要计算每个值的倒数。一个直接的方法可能如下所示:
In [1]: import numpy as np
rng = np.random.default_rng(seed=1701)
def compute_reciprocals(values):
output = np.empty(len(values))
for i in range(len(values)):
output[i] = 1.0 / values[i]
return output
values = rng.integers(1, 10, size=5)
compute_reciprocals(values)
Out[1]: array([0.11111111, 0.25 , 1. , 0.33333333, 0.125 ])
这种实现对于来自 C 或 Java 背景的人来说可能感觉相当自然。但是如果我们测量这段代码在大输入下的执行时间,我们会发现这个操作非常慢——也许出乎意料地慢!我们将使用 IPython 的 %timeit 魔术命令(在“分析和计时代码”中讨论)进行基准测试:
In [2]: big_array = rng.integers(1, 100, size=1000000)
%timeit compute_reciprocals(big_array)
Out[2]: 2.61 s ± 192 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
计算这些百万次操作并存储结果需要几秒钟!即使是手机的处理速度也以亿次数的每秒运算来衡量,这看起来几乎是荒谬地慢。事实证明,这里的瓶颈不是操作本身,而是 CPython 在每个循环周期中必须进行的类型检查和函数调度。每次计算倒数时,Python 首先检查对象的类型,并动态查找正确的函数来使用该类型。如果我们在编译代码中工作,这种类型规范将在代码执行之前已知,并且结果可以更有效地计算。
引入 Ufuncs
对于许多类型的操作,NumPy 提供了一个便利的接口,用于这种静态类型化的、编译的例程。这被称为向量化操作。对于像这里的逐元素除法这样的简单操作,向量化操作只需直接在数组对象上使用 Python 算术运算符即可。这种向量化方法旨在将循环推入 NumPy 底层的编译层,从而实现更快的执行。
比较以下两个操作的结果:
In [3]: print(compute_reciprocals(values))
print(1.0 / values)
Out[3]: [0.11111111 0.25 1. 0.33333333 0.125 ]
[0.11111111 0.25 1. 0.33333333 0.125 ]
查看我们大数组的执行时间,我们看到它完成的速度比 Python 循环快了几个数量级:
In [4]: %timeit (1.0 / big_array)
Out[4]: 2.54 ms ± 383 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
NumPy 中的向量化操作是通过 ufuncs 实现的,其主要目的是在 NumPy 数组中快速执行重复操作。Ufuncs 非常灵活——我们之前看到了标量和数组之间的操作,但我们也可以在两个数组之间进行操作:
In [5]: np.arange(5) / np.arange(1, 6)
Out[5]: array([0. , 0.5 , 0.66666667, 0.75 , 0.8 ])
并且 ufunc 操作不仅限于一维数组。它们也可以作用于多维数组:
In [6]: x = np.arange(9).reshape((3, 3))
2 ** x
Out[6]: array([[ 1, 2, 4],
[ 8, 16, 32],
[ 64, 128, 256]])
通过 ufunc 的向量化计算几乎总是比使用 Python 循环实现的对应计算更有效率,特别是数组增长时。在 NumPy 脚本中看到这样的循环时,您应该考虑是否可以用向量化表达式替代它。
探索 NumPy 的 Ufuncs
Ufuncs 有两种类型:一元 ufuncs,作用于单个输入,和二元 ufuncs,作用于两个输入。我们将在这里看到这两种类型的函数示例。
数组算术
NumPy 的 ufunc 使用起来非常自然,因为它们利用了 Python 的本机算术运算符。可以使用标准的加法、减法、乘法和除法:
In [7]: x = np.arange(4)
print("x =", x)
print("x + 5 =", x + 5)
print("x - 5 =", x - 5)
print("x * 2 =", x * 2)
print("x / 2 =", x / 2)
print("x // 2 =", x // 2) # floor division
Out[7]: x = [0 1 2 3]
x + 5 = [5 6 7 8]
x - 5 = [-5 -4 -3 -2]
x * 2 = [0 2 4 6]
x / 2 = [0. 0.5 1. 1.5]
x // 2 = [0 0 1 1]
还有一个一元 ufunc 用于求反,一个**运算符用于指数运算,一个%运算符用于求模:
In [8]: print("-x = ", -x)
print("x ** 2 = ", x ** 2)
print("x % 2 = ", x % 2)
Out[8]: -x = [ 0 -1 -2 -3]
x ** 2 = [0 1 4 9]
x % 2 = [0 1 0 1]
此外,这些操作可以随意组合在一起,而且遵循标准的操作顺序:
In [9]: -(0.5*x + 1) ** 2
Out[9]: array([-1. , -2.25, -4. , -6.25])
所有这些算术操作都只是围绕着 NumPy 中特定 ufunc 的方便包装。例如,+运算符是add ufunc 的一个包装器。
In [10]: np.add(x, 2)
Out[10]: array([2, 3, 4, 5])
表 6-1 列出了 NumPy 中实现的算术运算符。
表 6-1. NumPy 中实现的算术运算符
| 运算符 | 等效 ufunc | 描述 |
|---|---|---|
+ | np.add | 加法(例如,1 + 1 = 2) |
- | np.subtract | 减法(例如,3 - 2 = 1) |
- | np.negative | 一元取反(例如,-2) |
* | np.multiply | 乘法(例如,2 * 3 = 6) |
/ | np.divide | 除法(例如,3 / 2 = 1.5) |
// | np.floor_divide | 地板除法(例如,3 // 2 = 1) |
** | np.power | 指数运算(例如,2 ** 3 = 8) |
% | np.mod | 取模/取余数(例如,9 % 4 = 1) |
此外,还有布尔/位运算符;我们将在第九章中探索这些。
绝对值
就像 NumPy 理解 Python 内置的算术运算符一样,它也理解 Python 内置的绝对值函数:
In [11]: x = np.array([-2, -1, 0, 1, 2])
abs(x)
Out[11]: array([2, 1, 0, 1, 2])
对应的 NumPy ufunc 是np.absolute,也可以用别名np.abs调用:
In [12]: np.absolute(x)
Out[12]: array([2, 1, 0, 1, 2])
In [13]: np.abs(x)
Out[13]: array([2, 1, 0, 1, 2])
这个 ufunc 也可以处理复杂数据,此时它返回幅度:
In [14]: x = np.array([3 - 4j, 4 - 3j, 2 + 0j, 0 + 1j])
np.abs(x)
Out[14]: array([5., 5., 2., 1.])
三角函数
NumPy 提供了大量有用的 ufunc,对于数据科学家来说,其中一些最有用的是三角函数。我们将从定义一组角度开始:
In [15]: theta = np.linspace(0, np.pi, 3)
现在我们可以在这些值上计算一些三角函数了:
In [16]: print("theta = ", theta)
print("sin(theta) = ", np.sin(theta))
print("cos(theta) = ", np.cos(theta))
print("tan(theta) = ", np.tan(theta))
Out[16]: theta = [0. 1.57079633 3.14159265]
sin(theta) = [0.0000000e+00 1.0000000e+00 1.2246468e-16]
cos(theta) = [ 1.000000e+00 6.123234e-17 -1.000000e+00]
tan(theta) = [ 0.00000000e+00 1.63312394e+16 -1.22464680e-16]
这些值计算精度达到了机器精度,这就是为什么应该是零的值并不总是确切为零。反三角函数也是可用的:
In [17]: x = [-1, 0, 1]
print("x = ", x)
print("arcsin(x) = ", np.arcsin(x))
print("arccos(x) = ", np.arccos(x))
print("arctan(x) = ", np.arctan(x))
Out[17]: x = [-1, 0, 1]
arcsin(x) = [-1.57079633 0. 1.57079633]
arccos(x) = [3.14159265 1.57079633 0. ]
arctan(x) = [-0.78539816 0. 0.78539816]
指数和对数
在 NumPy 的 ufunc 中还有其他常见的操作,如指数函数:
In [18]: x = [1, 2, 3]
print("x =", x)
print("e^x =", np.exp(x))
print("2^x =", np.exp2(x))
print("3^x =", np.power(3., x))
Out[18]: x = [1, 2, 3]
e^x = [ 2.71828183 7.3890561 20.08553692]
2^x = [2. 4. 8.]
3^x = [ 3. 9. 27.]
逆指数函数,对数函数也是可用的。基本的np.log给出自然对数;如果你偏好计算以 2 为底或以 10 为底的对数,这些也是可用的:
In [19]: x = [1, 2, 4, 10]
print("x =", x)
print("ln(x) =", np.log(x))
print("log2(x) =", np.log2(x))
print("log10(x) =", np.log10(x))
Out[19]: x = [1, 2, 4, 10]
ln(x) = [0. 0.69314718 1.38629436 2.30258509]
log2(x) = [0. 1. 2. 3.32192809]
log10(x) = [0. 0.30103 0.60205999 1. ]
还有一些专门用于保持极小输入精度的版本:
In [20]: x = [0, 0.001, 0.01, 0.1]
print("exp(x) - 1 =", np.expm1(x))
print("log(1 + x) =", np.log1p(x))
Out[20]: exp(x) - 1 = [0. 0.0010005 0.01005017 0.10517092]
log(1 + x) = [0. 0.0009995 0.00995033 0.09531018]
当x非常小时,这些函数比使用原始的np.log或np.exp给出更精确的值。
专用的 Ufuncs
NumPy 还有许多其他的 ufunc 可用,包括双曲三角函数,位运算,比较操作,弧度转角度的转换,取整和余数等等。查阅 NumPy 文档会发现许多有趣的功能。
另一个更专业的 ufunc 来源是子模块scipy.special。如果你想在数据上计算一些不常见的数学函数,很可能它已经在scipy.special中实现了。这里有太多函数无法一一列出,但以下代码片段展示了在统计上可能会遇到的一些函数:
In [21]: from scipy import special
In [22]: # Gamma functions (generalized factorials) and related functions
x = [1, 5, 10]
print("gamma(x) =", special.gamma(x))
print("ln|gamma(x)| =", special.gammaln(x))
print("beta(x, 2) =", special.beta(x, 2))
Out[22]: gamma(x) = [1.0000e+00 2.4000e+01 3.6288e+05]
ln|gamma(x)| = [ 0. 3.17805383 12.80182748]
beta(x, 2) = [0.5 0.03333333 0.00909091]
In [23]: # Error function (integral of Gaussian),
# its complement, and its inverse
x = np.array([0, 0.3, 0.7, 1.0])
print("erf(x) =", special.erf(x))
print("erfc(x) =", special.erfc(x))
print("erfinv(x) =", special.erfinv(x))
Out[23]: erf(x) = [0. 0.32862676 0.67780119 0.84270079]
erfc(x) = [1. 0.67137324 0.32219881 0.15729921]
erfinv(x) = [0. 0.27246271 0.73286908 inf]
NumPy 和scipy.special中还有许多其他的 ufunc 可用。由于这些包的文档可以在线获取,因此通过类似“gamma function python”的网络搜索通常可以找到相关信息。
高级 Ufunc 功能
许多 NumPy 用户在不完全了解它们的全部特性的情况下就使用了 ufuncs。我在这里概述一些 ufunc 的专门特性。
指定输出
对于大型计算,有时指定计算结果存储的数组是很有用的。对于所有 ufunc,这可以通过函数的out参数来完成:
In [24]: x = np.arange(5)
y = np.empty(5)
np.multiply(x, 10, out=y)
print(y)
Out[24]: [ 0. 10. 20. 30. 40.]
这甚至可以与数组视图一起使用。例如,我们可以将计算结果写入指定数组的每隔一个元素:
In [25]: y = np.zeros(10)
np.power(2, x, out=y[::2])
print(y)
Out[25]: [ 1. 0. 2. 0. 4. 0. 8. 0. 16. 0.]
如果我们改为写成y[::2] = 2 ** x,这将导致创建一个临时数组来保存2 ** x的结果,然后进行第二个操作将这些值复制到y数组中。对于这样一个小的计算来说,这并不会有太大的区别,但是对于非常大的数组来说,通过谨慎使用out参数可以节省内存。
聚合
对于二元 ufunc,聚合可以直接从对象中计算。例如,如果我们想要使用特定操作减少一个数组,我们可以使用任何 ufunc 的reduce方法。reduce 会重复地将给定操作应用于数组的元素,直到只剩下一个单一的结果。
例如,对add ufunc 调用reduce将返回数组中所有元素的总和:
In [26]: x = np.arange(1, 6)
np.add.reduce(x)
Out[26]: 15
类似地,对multiply ufunc 调用reduce将得到数组所有元素的乘积:
In [27]: np.multiply.reduce(x)
Out[27]: 120
如果我们想要存储计算的所有中间结果,我们可以使用accumulate:
In [28]: np.add.accumulate(x)
Out[28]: array([ 1, 3, 6, 10, 15])
In [29]: np.multiply.accumulate(x)
Out[29]: array([ 1, 2, 6, 24, 120])
请注意,对于这些特定情况,有专门的 NumPy 函数来计算结果(np.sum,np.prod,np.cumsum,np.cumprod),我们将在第七章中探讨。
外积
最后,任何 ufunc 都可以使用outer方法计算两个不同输入的所有对的输出。这使您可以在一行中执行诸如创建乘法表之类的操作:
In [30]: x = np.arange(1, 6)
np.multiply.outer(x, x)
Out[30]: array([[ 1, 2, 3, 4, 5],
[ 2, 4, 6, 8, 10],
[ 3, 6, 9, 12, 15],
[ 4, 8, 12, 16, 20],
[ 5, 10, 15, 20, 25]])
ufunc.at和ufunc.reduceat方法同样也是非常有用的,我们将在第十章中探讨它们。
我们还将遇到 ufunc 能够在不同形状和大小的数组之间执行操作的能力,这一组操作被称为广播。这个主题非常重要,我们将专门为其设立一整章(参见第八章)。
Ufuncs:了解更多
更多有关通用函数的信息(包括可用函数的完整列表)可以在NumPy和SciPy文档网站上找到。
请回忆,您还可以通过在 IPython 中导入包并使用 IPython 的 tab 补全和帮助(?)功能来直接访问信息,如第一章中所述。
第七章:聚合:最小值、最大值以及其他
探索任何数据集的第一步通常是计算各种摘要统计信息。也许最常见的摘要统计信息是均值和标准差,它们帮助你总结数据集中的“典型”值,但其他聚合也很有用(总和、乘积、中位数、最小值和最大值、分位数等)。
NumPy 具有用于处理数组的快速内置聚合函数;我们将在这里讨论并尝试其中一些。
对数组中的值求和
举个快速的例子,考虑计算数组中所有值的总和。Python 本身可以使用内置的sum函数来完成这个操作:
In [1]: import numpy as np
rng = np.random.default_rng()
In [2]: L = rng.random(100)
sum(L)
Out[2]: 52.76825337322368
语法与 NumPy 的sum函数非常相似,在最简单的情况下结果是相同的:
In [3]: np.sum(L)
Out[3]: 52.76825337322366
然而,由于它在编译代码中执行操作,NumPy 版本的操作速度要快得多:
In [4]: big_array = rng.random(1000000)
%timeit sum(big_array)
%timeit np.sum(big_array)
Out[4]: 89.9 ms ± 233 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
521 µs ± 8.37 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
不过要小心:sum函数和np.sum函数并不相同,这有时可能会导致混淆!特别是,它们的可选参数具有不同的含义(sum(x, 1)将总和初始化为1,而np.sum(x, 1)沿着轴1求和),而np.sum能够识别多个数组维度,正如我们将在接下来的部分看到的。
最小值和最大值
同样,Python 内置了min和max函数,用于找到任意给定数组的最小值和最大值:
In [5]: min(big_array), max(big_array)
Out[5]: (2.0114398036064074e-07, 0.9999997912802653)
NumPy 的相应函数具有类似的语法,并且在操作上也更快:
In [6]: np.min(big_array), np.max(big_array)
Out[6]: (2.0114398036064074e-07, 0.9999997912802653)
In [7]: %timeit min(big_array)
%timeit np.min(big_array)
Out[7]: 72 ms ± 177 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
564 µs ± 3.11 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
对于min、max、sum以及其他几个 NumPy 聚合函数,使用数组对象本身的方法可以简化语法:
In [8]: print(big_array.min(), big_array.max(), big_array.sum())
Out[8]: 2.0114398036064074e-07 0.9999997912802653 499854.0273321711
在操作 NumPy 数组时,尽可能确保使用 NumPy 版本的这些聚合函数!
多维度聚合
一种常见的聚合操作类型是沿着行或列进行聚合。假设你有一些数据存储在二维数组中:
In [9]: M = rng.integers(0, 10, (3, 4))
print(M)
Out[9]: [[0 3 1 2]
[1 9 7 0]
[4 8 3 7]]
NumPy 的聚合函数将应用于多维数组的所有元素:
In [10]: M.sum()
Out[10]: 45
聚合函数接受一个额外的参数,指定沿着哪个轴进行聚合计算。例如,我们可以通过指定axis=0找到每列中的最小值:
In [11]: M.min(axis=0)
Out[11]: array([0, 3, 1, 0])
该函数返回四个值,对应于四列数字。
类似地,我们可以找到每行中的最大值:
In [12]: M.max(axis=1)
Out[12]: array([3, 9, 8])
此处指定轴的方式可能会令从其他语言转过来的用户感到困惑。axis关键字指定将要折叠的数组维度,而不是将要返回的维度。因此,指定axis=0意味着将折叠轴 0:对于二维数组,将在每列内进行聚合。
其他聚合函数
NumPy 提供了几个其他具有类似 API 的聚合函数,此外大多数函数还有一个NaN安全的对应版本,用于在计算结果时忽略缺失值,这些值由特殊的 IEEE 浮点NaN值标记(参见第十六章)。
表 7-1 提供了 NumPy 中可用的一些有用的聚合函数列表。
表 7-1. NumPy 中可用的聚合函数
| 函数名 | NaN 安全版本 | 描述 |
|---|---|---|
np.sum | np.nansum | 计算元素的总和 |
np.prod | np.nanprod | 计算元素的乘积 |
np.mean | np.nanmean | 计算元素的均值 |
np.std | np.nanstd | 计算标准差 |
np.var | np.nanvar | 计算方差 |
np.min | np.nanmin | 查找最小值 |
np.max | np.nanmax | 查找最大值 |
np.argmin | np.nanargmin | 查找最小值的索引 |
np.argmax | np.nanargmax | 查找最大值的索引 |
np.median | np.nanmedian | 计算元素的中位数 |
np.percentile | np.nanpercentile | 计算元素的基于排名的统计信息 |
np.any | N/A | 评估是否有任何元素为真 |
np.all | N/A | 评估所有元素是否为真 |
你将经常看到这些汇总统计信息在本书的其余部分中。
示例:美国总统的平均身高是多少?
NumPy 中可用的聚合函数可以作为一组值的汇总统计信息。作为一个小例子,让我们考虑所有美国总统的身高。这些数据包含在文件president_heights.csv中,这是一个标签和值的逗号分隔列表:
In [13]: !head -4 data/president_heights.csv
Out[13]: order,name,height(cm)
1,George Washington,189
2,John Adams,170
3,Thomas Jefferson,189
我们将使用 Pandas 包,在第 III 部分中更全面地探讨,读取文件并提取这些信息(注意,身高以厘米为单位):
In [14]: import pandas as pd
data = pd.read_csv('data/president_heights.csv')
heights = np.array(data['height(cm)'])
print(heights)
Out[14]: [189 170 189 163 183 171 185 168 173 183 173 173 175 178 183 193 178 173
174 183 183 168 170 178 182 180 183 178 182 188 175 179 183 193 182 183
177 185 188 188 182 185 191 182]
现在我们有了这个数据数组,我们可以计算各种汇总统计信息:
In [15]: print("Mean height: ", heights.mean())
print("Standard deviation:", heights.std())
print("Minimum height: ", heights.min())
print("Maximum height: ", heights.max())
Out[15]: Mean height: 180.04545454545453
Standard deviation: 6.983599441335736
Minimum height: 163
Maximum height: 193
请注意,在每种情况下,聚合操作将整个数组减少为一个单一的汇总值,这为我们提供了关于值分布的信息。我们可能还希望计算分位数:
In [16]: print("25th percentile: ", np.percentile(heights, 25))
print("Median: ", np.median(heights))
print("75th percentile: ", np.percentile(heights, 75))
Out[16]: 25th percentile: 174.75
Median: 182.0
75th percentile: 183.5
我们看到美国总统的中位身高为 182 厘米,几乎等于六英尺。
当然,有时更有用的是查看这些数据的视觉表示,我们可以使用 Matplotlib 工具来实现这一目标(我们将在第 IV 部分中更全面地讨论 Matplotlib)。例如,以下代码生成图 7-1:
In [17]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
In [18]: plt.hist(heights)
plt.title('Height Distribution of US Presidents')
plt.xlabel('height (cm)')
plt.ylabel('number');
图 7-1. 美国总统身高的直方图
第八章:数组上的计算:广播
我们在第六章看到 NumPy 的通用函数如何用于向量化操作,从而消除缓慢的 Python 循环。本章讨论广播:这是 NumPy 允许你在不同大小和形状的数组之间应用二元操作(如加法、减法、乘法等)的一组规则。
引入广播
请记住,对于相同大小的数组,二元操作是逐元素执行的:
In [1]: import numpy as np
In [2]: a = np.array([0, 1, 2])
b = np.array([5, 5, 5])
a + b
Out[2]: array([5, 6, 7])
广播允许在不同大小的数组上执行这些类型的二元操作,例如,我们可以很容易地将标量(将其视为零维数组)加到数组中:
In [3]: a + 5
Out[3]: array([5, 6, 7])
我们可以将其视为一种操作,将值5拉伸或复制到数组[5, 5, 5]中,并添加结果。
我们可以类似地将这个想法扩展到更高维度的数组。观察当我们将一个一维数组加到一个二维数组时的结果:
In [4]: M = np.ones((3, 3))
M
Out[4]: array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
In [5]: M + a
Out[5]: array([[1., 2., 3.],
[1., 2., 3.],
[1., 2., 3.]])
在这里,一维数组a通过第二个维度被拉伸或广播,以匹配M的形状。
尽管这些示例相对容易理解,但更复杂的情况可能涉及广播两个数组。考虑以下例子:
In [6]: a = np.arange(3)
b = np.arange(3)[:, np.newaxis]
print(a)
print(b)
Out[6]: [0 1 2]
[[0]
[1]
[2]]
In [7]: a + b
Out[7]: array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4]])
就像之前我们将一个值拉伸或广播到另一个形状相匹配的数组一样,这里我们拉伸了a和b,使它们匹配一个公共形状,结果是一个二维数组!这些示例的几何形状在图 8-1 中可视化。
浅色框表示广播的值。这种关于广播的思考方式可能会引发关于其内存使用效率的疑问,但不用担心:NumPy 广播实际上不会在内存中复制广播的值。尽管如此,这种思维模型在我们思考广播时仍然很有用。
图 8-1. NumPy 广播可视化(改编自 astroML 文档,并获得许可使用)^(1)
Broadcasting 规则
NumPy 中的广播遵循一组严格的规则来确定两个数组之间的交互:
规则 1
如果两个数组在它们的维数上不同,维数较少的数组的形状将在其前导(左)侧填充1。
规则 2
如果两个数组在任何维度上的形状不匹配,则具有在该维度上形状等于 1 的数组将被拉伸以匹配另一个形状。
规则 3
如果在任何维度上大小不一致且都不等于 1,则会引发错误。
为了澄清这些规则,让我们详细考虑几个例子。
广播示例 1
假设我们想将一个二维数组加到一个一维数组中:
In [8]: M = np.ones((2, 3))
a = np.arange(3)
让我们考虑对这两个具有以下形状的数组进行操作:
-
M.shape是(2, 3) -
a.shape是(3,)
我们看到按照规则 1,数组 a 的维度较少,因此我们在左侧用 1 填充它:
-
M.shape仍然是(2, 3) -
a.shape变为(1, 3)
根据规则 2,我们现在看到第一个维度不匹配,所以我们拉伸这个维度以匹配:
-
M.shape仍然是(2, 3) -
a.shape变为(2, 3)
现在形状匹配了,我们可以看到最终的形状将是 (2, 3):
In [9]: M + a
Out[9]: array([[1., 2., 3.],
[1., 2., 3.]])
广播示例 2
现在让我们看一个需要广播两个数组的例子:
In [10]: a = np.arange(3).reshape((3, 1))
b = np.arange(3)
再次,我们将确定数组的形状:
-
a.shape是(3, 1) -
b.shape是(3,)
规则 1 表示我们必须用 1 填充 b 的形状:
-
a.shape仍然是(3, 1) -
b.shape变为(1, 3)
规则 2 告诉我们,我们必须将每个 1 扩展到与另一个数组的相应大小匹配:
-
a.shape变为(3, 3) -
b.shape变为(3, 3)
因为结果匹配,这些形状是兼容的。我们可以在这里看到:
In [11]: a + b
Out[11]: array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4]])
广播示例 3
接下来,让我们看一个两个数组不兼容的例子:
In [12]: M = np.ones((3, 2))
a = np.arange(3)
这只是比第一个例子略有不同的情况:矩阵 M 被转置了。这对计算有什么影响?数组的形状如下:
-
M.shape是(3, 2) -
a.shape是(3,)
再次,规则 1 告诉我们,我们必须用 1 填充 a 的形状:
-
M.shape仍然是(3, 2) -
a.shape变为(1, 3)
根据规则 2,a 的第一个维度被拉伸以匹配 M 的维度:
-
M.shape仍然是(3, 2) -
a.shape变为(3, 3)
现在我们遇到了规则 3——最终的形状不匹配,所以这两个数组是不兼容的,我们可以通过尝试这个操作来观察:
In [13]: M + a
ValueError: operands could not be broadcast together with shapes (3,2) (3,)
注意这里的潜在混淆:你可以想象通过在右侧而不是左侧用 1 填充 a 的形状来使 a 和 M 兼容。但这不是广播规则的工作方式!这种灵活性在某些情况下可能很有用,但它会导致潜在的歧义。如果你想要右侧填充,你可以通过显式地重新塑造数组来实现(我们将在第五章介绍 np.newaxis 关键字来实现这一点):
In [14]: a[:, np.newaxis].shape
Out[14]: (3, 1)
In [15]: M + a[:, np.newaxis]
Out[15]: array([[1., 1.],
[2., 2.],
[3., 3.]])
虽然我们在这里专注于 + 运算符,但这些广播规则适用于任何二元通用函数。例如,这是 logaddexp(a, b) 函数的示例,它计算 log(exp(a) + exp(b)) 比朴素方法更精确:
In [16]: np.logaddexp(M, a[:, np.newaxis])
Out[16]: array([[1.31326169, 1.31326169],
[1.69314718, 1.69314718],
[2.31326169, 2.31326169]])
欲知更多关于多个可用的通用函数的信息,请参阅第六章。
实际广播应用
广播操作是本书中许多示例的核心。现在让我们看看它们在哪些情况下可以派上用场。
数组居中
在第 6 章中,我们看到 ufunc 允许 NumPy 用户避免显式编写缓慢的 Python 循环。广播扩展了这种能力。数据科学中经常见到的一个例子是从数据数组中减去逐行均值。假设我们有一个由 10 个观测组成的数组,每个观测包含 3 个值。按照标准惯例(参见第 38 章),我们将其存储在一个10 × 3数组中:
In [17]: rng = np.random.default_rng(seed=1701)
X = rng.random((10, 3))
我们可以使用沿第一维度的 mean 聚合计算每列的均值:
In [18]: Xmean = X.mean(0)
Xmean
Out[18]: array([0.38503638, 0.36991443, 0.63896043])
现在我们可以通过减去均值来将 X 数组居中(这是一个广播操作):
In [19]: X_centered = X - Xmean
为了确保我们做得正确,我们可以检查居中数组的平均值是否接近零:
In [20]: X_centered.mean(0)
Out[20]: array([ 4.99600361e-17, -4.44089210e-17, 0.00000000e+00])
机器精度内,均值现在为零。
绘制二维函数
广播经常派上用场的一个地方是基于二维函数显示图像。如果我们想定义一个函数z = f ( x , y ),可以使用广播来计算整个网格上的函数:
In [21]: # x and y have 50 steps from 0 to 5
x = np.linspace(0, 5, 50)
y = np.linspace(0, 5, 50)[:, np.newaxis]
z = np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)
我们将使用 Matplotlib 绘制这个二维数组,如图 8-2 所示(这些工具将在第 28 章中全面讨论):
In [22]: %matplotlib inline
import matplotlib.pyplot as plt
In [23]: plt.imshow(z, origin='lower', extent=[0, 5, 0, 5])
plt.colorbar();
图 8-2. 二维数组的可视化
结果是二维函数引人注目的可视化。
^(1) 生成此图的代码可在在线附录中找到。
第九章:比较、掩码和布尔逻辑
本章介绍了使用布尔掩码来检查和操作 NumPy 数组中的值。当你想要基于某些条件提取、修改、计数或以其他方式操作数组中的值时,掩码就会出现:例如,你可能希望计算大于某个特定值的所有值,或者删除所有超过某个阈值的异常值。在 NumPy 中,布尔掩码通常是实现这些任务的最高效方式。
示例:统计下雨天数
想象一下,你有一系列数据,代表了一个城市一年中每天的降水量。例如,在这里我们将加载西雅图市 2015 年的每日降雨统计数据,使用 Pandas(参见第三部分):
In [1]: import numpy as np
from vega_datasets import data
# Use DataFrame operations to extract rainfall as a NumPy array
rainfall_mm = np.array(
data.seattle_weather().set_index('date')['precipitation']['2015'])
len(rainfall_mm)
Out[1]: 365
数组包含 365 个值,从 2015 年 1 月 1 日到 12 月 31 日的每日降雨量(以毫米为单位)。
首先快速可视化,让我们来看一下图 9-1 中的下雨天数直方图,这是使用 Matplotlib 生成的(我们将在第四部分中详细探讨这个工具):
In [2]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
In [3]: plt.hist(rainfall_mm, 40);
图 9-1. 西雅图 2015 年降雨直方图
这个直方图给了我们一个关于数据外观的概括性的想法:尽管西雅图以多雨而闻名,2015 年西雅图大部分日子都几乎没有测得的降水量。但这并没有很好地传达一些我们想要看到的信息:例如,整年有多少天下雨?在那些下雨的日子里,平均降水量是多少?有多少天降水量超过 10 毫米?
其中一种方法是通过手工来回答这些问题:我们可以遍历数据,每次看到在某个期望范围内的值时增加一个计数器。但是出于本章讨论的原因,从编写代码的时间和计算结果的时间来看,这种方法非常低效。我们在第六章中看到,NumPy 的通用函数(ufuncs)可以用来替代循环,在数组上进行快速的逐元素算术操作;同样地,我们可以使用其他通用函数在数组上进行逐元素的比较,然后可以操作结果来回答我们的问题。暂且把数据放在一边,讨论一下 NumPy 中一些常用工具,使用掩码来快速回答这类问题。
比较运算符作为通用函数
第六章介绍了 ufuncs,特别关注算术运算符。我们看到,在数组上使用 +, -, *, / 和其他运算符会导致逐元素操作。NumPy 还实现了比较运算符,如 <(小于)和 >(大于)作为逐元素的 ufuncs。这些比较运算符的结果始终是一个布尔数据类型的数组。标准的六种比较操作都是可用的:
In [4]: x = np.array([1, 2, 3, 4, 5])
In [5]: x < 3 # less than
Out[5]: array([ True, True, False, False, False])
In [6]: x > 3 # greater than
Out[6]: array([False, False, False, True, True])
In [7]: x <= 3 # less than or equal
Out[7]: array([ True, True, True, False, False])
In [8]: x >= 3 # greater than or equal
Out[8]: array([False, False, True, True, True])
In [9]: x != 3 # not equal
Out[9]: array([ True, True, False, True, True])
In [10]: x == 3 # equal
Out[10]: array([False, False, True, False, False])
还可以对两个数组进行逐元素比较,并包括复合表达式:
In [11]: (2 * x) == (x ** 2)
Out[11]: array([False, True, False, False, False])
就像算术运算符的情况一样,NumPy 中的比较运算符也被实现为 ufuncs;例如,当你写 x < 3 时,NumPy 内部使用 np.less(x, 3)。这里显示了比较运算符及其等效的 ufuncs 的摘要:
| 操作符 | 等效的 ufunc | 操作符 | 等效的 ufunc |
|---|---|---|---|
== | np.equal | != | np.not_equal |
< | np.less | <= | np.less_equal |
> | np.greater | >= | np.greater_equal |
就像算术 ufuncs 的情况一样,这些函数适用于任何大小和形状的数组。这里是一个二维数组的例子:
In [12]: rng = np.random.default_rng(seed=1701)
x = rng.integers(10, size=(3, 4))
x
Out[12]: array([[9, 4, 0, 3],
[8, 6, 3, 1],
[3, 7, 4, 0]])
In [13]: x < 6
Out[13]: array([[False, True, True, True],
[False, False, True, True],
[ True, False, True, True]])
在每种情况下,结果都是一个布尔数组,NumPy 提供了一些简单的模式来处理这些布尔结果。
使用布尔数组
给定一个布尔数组,你可以进行许多有用的操作。我们将使用我们之前创建的二维数组 x:
In [14]: print(x)
Out[14]: [[9 4 0 3]
[8 6 3 1]
[3 7 4 0]]
计数条目
要计算布尔数组中 True 条目的数量,可以使用 np.count_nonzero:
In [15]: # how many values less than 6?
np.count_nonzero(x < 6)
Out[15]: 8
我们看到有八个数组条目小于 6。获取这些信息的另一种方法是使用 np.sum;在这种情况下,False 被解释为 0,True 被解释为 1:
In [16]: np.sum(x < 6)
Out[16]: 8
np.sum 的好处在于,与其他 NumPy 聚合函数一样,这种求和可以沿着行或列进行:
In [17]: # how many values less than 6 in each row?
np.sum(x < 6, axis=1)
Out[17]: array([3, 2, 3])
这会统计矩阵每行中小于 6 的值的数量。
如果我们有兴趣快速检查任何或所有的值是否为 True,我们可以使用(你猜对了)np.any 或 np.all:
In [18]: # are there any values greater than 8?
np.any(x > 8)
Out[18]: True
In [19]: # are there any values less than zero?
np.any(x < 0)
Out[19]: False
In [20]: # are all values less than 10?
np.all(x < 10)
Out[20]: True
In [21]: # are all values equal to 6?
np.all(x == 6)
Out[21]: False
np.all 和 np.any 也可以沿着特定的轴使用。例如:
In [22]: # are all values in each row less than 8?
np.all(x < 8, axis=1)
Out[22]: array([False, False, True])
这里第三行的所有元素都小于 8,而其他行则不是这样。
最后,一个快速的警告:如第七章中提到的,Python 中有内置的 sum、any 和 all 函数。它们与 NumPy 版本的语法不同,特别是在多维数组上使用时可能会失败或产生意外的结果。确保在这些示例中使用 np.sum、np.any 和 np.all!
布尔运算符
我们已经看到如何计算例如所有降雨量小于 20 毫米的天数,或所有降雨量大于 10 毫米的天数。但是如果我们想知道降雨量大于 10 毫米且小于 20 毫米的天数有多少呢?我们可以用 Python 的位逻辑运算符 &, |, ^, 和 ~ 来实现这一点。与标准算术运算符一样,NumPy 将它们重载为 ufuncs,这些 ufuncs 在(通常是布尔)数组上逐元素工作。
例如,我们可以这样处理这种复合问题:
In [23]: np.sum((rainfall_mm > 10) & (rainfall_mm < 20))
Out[23]: 16
这告诉我们,有 16 天的降雨量在 10 到 20 毫米之间。
这里的括号很重要。由于操作符优先级规则,如果去掉括号,这个表达式将按照以下方式进行评估,从而导致错误:
rainfall_mm > (10 & rainfall_mm) < 20
让我们演示一个更复杂的表达式。使用德摩根定律,我们可以以不同的方式计算相同的结果:
In [24]: np.sum(~( (rainfall_mm <= 10) | (rainfall_mm >= 20) ))
Out[24]: 16
在数组上结合比较操作符和布尔操作符,可以进行广泛的高效逻辑操作。
以下表格总结了位运算布尔操作符及其等效的 ufuncs:
| 运算符 | 等效 ufunc | 运算符 | 等效 ufunc |
|---|---|---|---|
& | np.bitwise_and | np.bitwise_or | |
^ | np.bitwise_xor | ~ | np.bitwise_not |
使用这些工具,我们可以开始回答关于我们天气数据的许多问题。以下是将布尔操作与聚合结合时可以计算的一些结果示例:
In [25]: print("Number days without rain: ", np.sum(rainfall_mm == 0))
print("Number days with rain: ", np.sum(rainfall_mm != 0))
print("Days with more than 10 mm: ", np.sum(rainfall_mm > 10))
print("Rainy days with < 5 mm: ", np.sum((rainfall_mm > 0) &
(rainfall_mm < 5)))
Out[25]: Number days without rain: 221
Number days with rain: 144
Days with more than 10 mm: 34
Rainy days with < 5 mm: 83
布尔数组作为掩码
在前面的部分中,我们看过直接在布尔数组上计算的聚合。更强大的模式是使用布尔数组作为掩码,选择数据本身的特定子集。让我们回到之前的x数组:
In [26]: x
Out[26]: array([[9, 4, 0, 3],
[8, 6, 3, 1],
[3, 7, 4, 0]])
假设我们想要一个数组,其中所有值都小于,比如说,5。我们可以轻松地为这个条件获取一个布尔数组,就像我们已经看到的那样:
In [27]: x < 5
Out[27]: array([[False, True, True, True],
[False, False, True, True],
[ True, False, True, True]])
现在,要从数组中选择这些值,我们可以简单地在这个布尔数组上进行索引;这称为掩码操作:
In [28]: x[x < 5]
Out[28]: array([4, 0, 3, 3, 1, 3, 4, 0])
返回的是一个填充有所有满足条件值的一维数组;换句话说,所有掩码数组为True位置上的值。
然后我们可以自由地按照我们的意愿操作这些值。例如,我们可以计算我们西雅图降雨数据的一些相关统计信息:
In [29]: # construct a mask of all rainy days
rainy = (rainfall_mm > 0)
# construct a mask of all summer days (June 21st is the 172nd day)
days = np.arange(365)
summer = (days > 172) & (days < 262)
print("Median precip on rainy days in 2015 (mm): ",
np.median(rainfall_mm[rainy]))
print("Median precip on summer days in 2015 (mm): ",
np.median(rainfall_mm[summer]))
print("Maximum precip on summer days in 2015 (mm): ",
np.max(rainfall_mm[summer]))
print("Median precip on non-summer rainy days (mm):",
np.median(rainfall_mm[rainy & ~summer]))
Out[29]: Median precip on rainy days in 2015 (mm): 3.8
Median precip on summer days in 2015 (mm): 0.0
Maximum precip on summer days in 2015 (mm): 32.5
Median precip on non-summer rainy days (mm): 4.1
通过组合布尔操作、掩码操作和聚合,我们可以非常快速地回答关于我们数据集的这些问题。
使用关键词 and/or 与操作符 &/|
一个常见的混淆点是关键词 and 和 or 与操作符 & 和 | 之间的区别。什么情况下会使用其中一个而不是另一个?
区别在于:and 和 or 在整个对象上操作,而 & 和 | 在对象内的元素上操作。
当你使用and或or时,相当于要求 Python 将对象视为单个布尔实体。在 Python 中,所有非零整数都将被评估为True。因此:
In [30]: bool(42), bool(0)
Out[30]: (True, False)
In [31]: bool(42 and 0)
Out[31]: False
In [32]: bool(42 or 0)
Out[32]: True
当你在整数上使用&和|时,表达式将作用于元素的位表示,对构成数字的各个位应用与或或:
In [33]: bin(42)
Out[33]: '0b101010'
In [34]: bin(59)
Out[34]: '0b111011'
In [35]: bin(42 & 59)
Out[35]: '0b101010'
In [36]: bin(42 | 59)
Out[36]: '0b111011'
注意,要产生结果,需要按顺序比较二进制表示的相应位。
当你在 NumPy 中有一个布尔值数组时,可以将其视为一个比特串,其中1 = True,0 = False,&和|将类似于前面的示例中的操作:
In [37]: A = np.array([1, 0, 1, 0, 1, 0], dtype=bool)
B = np.array([1, 1, 1, 0, 1, 1], dtype=bool)
A | B
Out[37]: array([ True, True, True, False, True, True])
但是,如果你在这些数组上使用or,它将尝试评估整个数组对象的真假,这不是一个明确定义的值:
In [38]: A or B
ValueError: The truth value of an array with more than one element is
> ambiguous.
a.any() or a.all()
类似地,当对给定数组评估布尔表达式时,应该使用|或&而不是or或and:
In [39]: x = np.arange(10)
(x > 4) & (x < 8)
Out[39]: array([False, False, False, False, False, True, True, True, False,
False])
尝试评估整个数组的真假将会产生与我们之前看到的相同的ValueError:
In [40]: (x > 4) and (x < 8)
ValueError: The truth value of an array with more than one element is
> ambiguous.
a.any() or a.all()
因此,请记住:and和or对整个对象执行单个布尔评估,而&和|对对象的内容(各个位或字节)执行多个布尔评估。对于布尔 NumPy 数组,后者几乎总是期望的操作。
第十章:Fancy 索引
前面的章节讨论了如何使用简单索引(例如 arr[0])、切片(例如 arr[:5])和布尔掩码(例如 arr[arr > 0])来访问和修改数组的部分内容。在本章中,我们将看看另一种数组索引方式,称为fancy或向量化索引,在这种方式中,我们传递数组索引替代单个标量。这使得我们能够非常快速地访问和修改数组值的复杂子集。
探索 Fancy 索引
Fancy 索引在概念上很简单:它意味着传递一个索引数组以一次访问多个数组元素。例如,考虑以下数组:
In [1]: import numpy as np
rng = np.random.default_rng(seed=1701)
x = rng.integers(100, size=10)
print(x)
Out[1]: [90 40 9 30 80 67 39 15 33 79]
假设我们要访问三个不同的元素。我们可以这样做:
In [2]: [x[3], x[7], x[2]]
Out[2]: [30, 15, 9]
或者,我们可以传递一个单一的索引列表或数组来获取相同的结果:
In [3]: ind = [3, 7, 4]
x[ind]
Out[3]: array([30, 15, 80])
当使用索引数组时,结果的形状反映了索引数组的形状而不是被索引数组的形状:
In [4]: ind = np.array([[3, 7],
[4, 5]])
x[ind]
Out[4]: array([[30, 15],
[80, 67]])
fancy 索引也适用于多维度。考虑以下数组:
In [5]: X = np.arange(12).reshape((3, 4))
X
Out[5]: array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
就像标准索引一样,第一个索引指的是行,第二个指的是列:
In [6]: row = np.array([0, 1, 2])
col = np.array([2, 1, 3])
X[row, col]
Out[6]: array([ 2, 5, 11])
注意结果中的第一个值是 X[0, 2],第二个是 X[1, 1],第三个是 X[2, 3]。在 fancy 索引中索引的配对遵循所有广播规则,这些规则在第八章中已经提到。因此,例如,如果我们在索引中组合列向量和行向量,我们将得到一个二维结果:
In [7]: X[row[:, np.newaxis], col]
Out[7]: array([[ 2, 1, 3],
[ 6, 5, 7],
[10, 9, 11]])
在这里,每行的值与每列向量匹配,正如我们在算术操作的广播中看到的一样。例如:
In [8]: row[:, np.newaxis] * col
Out[8]: array([[0, 0, 0],
[2, 1, 3],
[4, 2, 6]])
使用 fancy 索引时,始终重要的是记住返回值反映了广播后的索引形状,而不是被索引数组的形状。
结合索引
对于更强大的操作,可以将 fancy 索引与我们之前看到的其他索引方案结合使用。例如,给定数组 X:
In [9]: print(X)
Out[9]: [[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
我们可以将 fancy 索引与简单索引结合使用:
In [10]: X[2, [2, 0, 1]]
Out[10]: array([10, 8, 9])
我们还可以将 fancy 索引与切片结合使用:
In [11]: X[1:, [2, 0, 1]]
Out[11]: array([[ 6, 4, 5],
[10, 8, 9]])
并且我们可以将 fancy 索引与掩码结合使用:
In [12]: mask = np.array([True, False, True, False])
X[row[:, np.newaxis], mask]
Out[12]: array([[ 0, 2],
[ 4, 6],
[ 8, 10]])
所有这些索引选项的结合为有效访问和修改数组值提供了非常灵活的操作集。
示例:选择随机点
fancy 索引的一个常见用途是从矩阵中选择行的子集。例如,我们可能有一个表示N × D维度的矩阵,如从二维正态分布中抽取的以下点:
In [13]: mean = [0, 0]
cov = [[1, 2],
[2, 5]]
X = rng.multivariate_normal(mean, cov, 100)
X.shape
Out[13]: (100, 2)
使用我们将在第四部分讨论的绘图工具,我们可以将这些点可视化为散点图(Figure 10-1)。
In [14]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
plt.scatter(X[:, 0], X[:, 1]);
图 10-1。正态分布的点
让我们使用高级索引选择 20 个随机点。我们将首先选择 20 个随机索引,而不重复,然后使用这些索引来选择原始数组的一部分:
In [15]: indices = np.random.choice(X.shape[0], 20, replace=False)
indices
Out[15]: array([82, 84, 10, 55, 14, 33, 4, 16, 34, 92, 99, 64, 8, 76, 68, 18, 59,
80, 87, 90])
In [16]: selection = X[indices] # fancy indexing here
selection.shape
Out[16]: (20, 2)
现在,为了查看选定的点,请在选定点的位置上添加大圆圈(请参阅图 10-2)。
In [17]: plt.scatter(X[:, 0], X[:, 1], alpha=0.3)
plt.scatter(selection[:, 0], selection[:, 1],
facecolor='none', edgecolor='black', s=200);
图 10-2。点之间的随机选择
这种策略通常用于快速分割数据集,就像在统计模型验证中经常需要的训练/测试拆分那样(见第三十九章),以及回答统计问题的抽样方法。
使用高级索引修改值
正如高级索引可用于访问数组的部分一样,它也可以用于修改数组的部分。例如,想象一下我们有一个索引数组,我们想将数组中的相应项目设置为某个值:
In [18]: x = np.arange(10)
i = np.array([2, 1, 8, 4])
x[i] = 99
print(x)
Out[18]: [ 0 99 99 3 99 5 6 7 99 9]
我们可以使用任何赋值类型的运算符。例如:
In [19]: x[i] -= 10
print(x)
Out[19]: [ 0 89 89 3 89 5 6 7 89 9]
注意,使用这些操作的重复索引可能会导致一些可能意想不到的结果。请考虑以下情况:
In [20]: x = np.zeros(10)
x[[0, 0]] = [4, 6]
print(x)
Out[20]: [6. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
4 去哪了?此操作首先将x[0] = 4赋值,然后是x[0] = 6。结果,当然是x[0]包含值 6。
够公平了,但请考虑以下操作:
In [21]: i = [2, 3, 3, 4, 4, 4]
x[i] += 1
x
Out[21]: array([6., 0., 1., 1., 1., 0., 0., 0., 0., 0.])
您可能期望x[3]包含值 2,x[4]包含值 3,因为这是每个索引重复的次数。为什么不是这种情况?从概念上讲,这是因为x[i] += 1被理解为x[i] = x[i] + 1的简写形式。x[i] + 1被计算,然后结果被赋值给x中的索引。考虑到这一点,增加发生的次数不是多次,而是分配,这导致了相当非直观的结果。
那么,如果您想要重复操作的其他行为呢?对此,您可以使用 ufuncs 的at方法,并执行以下操作:
In [22]: x = np.zeros(10)
np.add.at(x, i, 1)
print(x)
Out[22]: [0. 0. 1. 2. 3. 0. 0. 0. 0. 0.]
at方法在指定的索引(这里是i)处以指定的值(这里是 1)进行给定操作的就地应用。另一种在精神上类似的方法是 ufuncs 的reduceat方法,您可以在NumPy 文档中阅读有关它的信息。
例:分箱数据
您可以使用这些思想来有效地对数据进行自定义分组计算。例如,假设我们有 100 个值,并且想要快速找到它们在一个箱子数组中的位置。我们可以像这样使用ufunc.at来计算:
In [23]: rng = np.random.default_rng(seed=1701)
x = rng.normal(size=100)
# compute a histogram by hand
bins = np.linspace(-5, 5, 20)
counts = np.zeros_like(bins)
# find the appropriate bin for each x
i = np.searchsorted(bins, x)
# add 1 to each of these bins
np.add.at(counts, i, 1)
现在,计数反映了每个箱中的点数——换句话说,是一个直方图(请参阅图 10-3)。
In [24]: # plot the results
plt.plot(bins, counts, drawstyle='steps');
图 10-3。手动计算的直方图
当然,每次想要绘制直方图时都这样做是很不方便的。这就是为什么 Matplotlib 提供了plt.hist例程,它可以在一行代码中完成相同的操作:
plt.hist(x, bins, histtype='step');
这个函数将创建一个几乎与刚刚显示的图表完全相同的图。为了计算分箱,Matplotlib 使用了np.histogram函数,这个函数与我们之前做过的计算非常相似。让我们在这里比较一下两者:
In [25]: print(f"NumPy histogram ({len(x)} points):")
%timeit counts, edges = np.histogram(x, bins)
print(f"Custom histogram ({len(x)} points):")
%timeit np.add.at(counts, np.searchsorted(bins, x), 1)
Out[25]: NumPy histogram (100 points):
33.8 µs ± 311 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
Custom histogram (100 points):
17.6 µs ± 113 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
我们自己的一行代码算法比 NumPy 中优化算法快两倍!这怎么可能呢?如果你深入查看np.histogram的源代码(你可以在 IPython 中键入np.histogram??来做到这一点),你会看到它比我们做的简单的搜索和计数要复杂得多;这是因为 NumPy 的算法更灵活,特别是在数据点数量变大时性能更好的设计:
In [26]: x = rng.normal(size=1000000)
print(f"NumPy histogram ({len(x)} points):")
%timeit counts, edges = np.histogram(x, bins)
print(f"Custom histogram ({len(x)} points):")
%timeit np.add.at(counts, np.searchsorted(bins, x), 1)
Out[26]: NumPy histogram (1000000 points):
84.4 ms ± 2.82 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Custom histogram (1000000 points):
128 ms ± 2.04 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
这个比较显示的是算法效率几乎从来都不是一个简单的问题。一个对大数据集有效的算法并不总是小数据集的最佳选择,反之亦然(见第十一章)。但是自己编写此算法的优势在于,掌握了这些基本方法之后,天空就是你的极限:你不再局限于内置程序,而是可以创造自己的方法来探索数据。在数据密集型应用中高效使用 Python 的关键不仅在于了解像np.histogram这样的通用方便函数及其适用时机,还在于知道如何在需要更具针对性行为时利用低级功能。
第十一章:数组排序
到目前为止,我们主要关注使用 NumPy 访问和操作数组数据的工具。本章涵盖了与 NumPy 数组中值排序相关的算法。这些算法是计算机科学导论课程的热门话题:如果你曾经参加过这样的课程,你可能曾经梦想过(或者,根据你的性格,噩梦)插入排序、选择排序、归并排序、快速排序、冒泡排序等等。所有这些方法都是完成相似任务的手段:对列表或数组中的值进行排序。
Python 有几个用于排序列表和其他可迭代对象的内置函数和方法。sorted函数接受一个列表并返回其排序版本:
In [1]: L = [3, 1, 4, 1, 5, 9, 2, 6]
sorted(L) # returns a sorted copy
Out[1]: [1, 1, 2, 3, 4, 5, 6, 9]
相比之下,列表的sort方法会就地对列表进行排序:
In [2]: L.sort() # acts in-place and returns None
print(L)
Out[2]: [1, 1, 2, 3, 4, 5, 6, 9]
Python 的排序方法非常灵活,可以处理任何可迭代对象。例如,这里我们对一个字符串进行排序:
In [3]: sorted('python')
Out[3]: ['h', 'n', 'o', 'p', 't', 'y']
这些内置的排序方法很方便,但正如前面讨论的那样,Python 值的动态性意味着它们的性能比专门设计用于均匀数组的例程要差。这就是 NumPy 排序例程的用武之地。
NumPy 中的快速排序: np.sort 和 np.argsort
np.sort函数类似于 Python 的内置sorted函数,并且能够高效地返回数组的排序副本:
In [4]: import numpy as np
x = np.array([2, 1, 4, 3, 5])
np.sort(x)
Out[4]: array([1, 2, 3, 4, 5])
类似于 Python 列表的sort方法,你也可以使用数组的sort方法原地对数组进行排序:
In [5]: x.sort()
print(x)
Out[5]: [1 2 3 4 5]
相关的函数是argsort,它返回排序元素的索引:
In [6]: x = np.array([2, 1, 4, 3, 5])
i = np.argsort(x)
print(i)
Out[6]: [1 0 3 2 4]
结果的第一个元素给出了最小元素的索引,第二个值给出了第二小的索引,依此类推。如果需要的话,这些索引可以用(通过花式索引)来构造排序后的数组:
In [7]: x[i]
Out[7]: array([1, 2, 3, 4, 5])
你将在本章后面看到argsort的应用。
沿行或列排序
NumPy 排序算法的一个有用特性是可以使用axis参数沿着多维数组的特定行或列进行排序。例如:
In [8]: rng = np.random.default_rng(seed=42)
X = rng.integers(0, 10, (4, 6))
print(X)
Out[8]: [[0 7 6 4 4 8]
[0 6 2 0 5 9]
[7 7 7 7 5 1]
[8 4 5 3 1 9]]
In [9]: # sort each column of X
np.sort(X, axis=0)
Out[9]: array([[0, 4, 2, 0, 1, 1],
[0, 6, 5, 3, 4, 8],
[7, 7, 6, 4, 5, 9],
[8, 7, 7, 7, 5, 9]])
In [10]: # sort each row of X
np.sort(X, axis=1)
Out[10]: array([[0, 4, 4, 6, 7, 8],
[0, 0, 2, 5, 6, 9],
[1, 5, 7, 7, 7, 7],
[1, 3, 4, 5, 8, 9]])
请注意,这将把每行或列视为独立的数组,行或列值之间的任何关系都将丢失!
部分排序: 分区
有时候我们并不想对整个数组进行排序,而只是想找出数组中最小的k个值。NumPy 通过np.partition函数实现了这一点。np.partition接受一个数组和一个数k;结果是一个新数组,最小的k个值位于分区的左侧,剩余的值位于右侧:
In [11]: x = np.array([7, 2, 3, 1, 6, 5, 4])
np.partition(x, 3)
Out[11]: array([2, 1, 3, 4, 6, 5, 7])
注意结果数组中的前三个值是数组中最小的三个值,剩下的数组位置包含剩余的值。在这两个分区中,元素的顺序是任意的。
类似于排序,我们也可以沿着多维数组的任意轴进行分区:
In [12]: np.partition(X, 2, axis=1)
Out[12]: array([[0, 4, 4, 7, 6, 8],
[0, 0, 2, 6, 5, 9],
[1, 5, 7, 7, 7, 7],
[1, 3, 4, 5, 8, 9]])
结果是一个数组,其中每行的前两个槽包含该行的最小值,其余值填充其余槽位。
最后,就像有一个计算排序索引的np.argsort函数一样,有一个计算分区索引的np.argpartition函数。我们将在接下来的部分中看到这两者的作用。
示例:k-最近邻算法
让我们快速看看如何沿着多个轴使用argsort函数来找到集合中每个点的最近邻居。我们将从在二维平面上创建的随机 10 个点集开始。按照标准约定,我们将这些点排列在一个10 × 2数组中:
In [13]: X = rng.random((10, 2))
为了了解这些点的外观,让我们生成一个快速的散点图(见图 11-1)。
In [14]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
plt.scatter(X[:, 0], X[:, 1], s=100);
图 11-1. k-最近邻示例中的点的可视化
现在我们将计算每对点之间的距离。回想一下,两点之间的平方距离是每个维度上平方差的和;使用 NumPy 提供的高效广播(第八章)和聚合(第七章)例程,我们可以在一行代码中计算出平方距离矩阵:
In [15]: dist_sq = np.sum((X[:, np.newaxis] - X[np.newaxis, :]) ** 2, axis=-1)
这个操作包含了很多内容,如果你不熟悉 NumPy 的广播规则,可能会感到有些困惑。当你遇到这样的代码时,将其分解为各个步骤可能会很有用:
In [16]: # for each pair of points, compute differences in their coordinates
differences = X[:, np.newaxis] - X[np.newaxis, :]
differences.shape
Out[16]: (10, 10, 2)
In [17]: # square the coordinate differences
sq_differences = differences ** 2
sq_differences.shape
Out[17]: (10, 10, 2)
In [18]: # sum the coordinate differences to get the squared distance
dist_sq = sq_differences.sum(-1)
dist_sq.shape
Out[18]: (10, 10)
作为我们逻辑的快速检查,我们应该看到这个矩阵的对角线(即每个点与自身之间的距离集合)全为零:
In [19]: dist_sq.diagonal()
Out[19]: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
一旦转换为成对的平方距离,我们现在可以使用np.argsort沿着每一行排序。最左边的列将给出最近邻居的索引:
In [20]: nearest = np.argsort(dist_sq, axis=1)
print(nearest)
Out[20]: [[0 9 3 5 4 8 1 6 2 7]
[1 7 2 6 4 8 3 0 9 5]
[2 7 1 6 4 3 8 0 9 5]
[3 0 4 5 9 6 1 2 8 7]
[4 6 3 1 2 7 0 5 9 8]
[5 9 3 0 4 6 8 1 2 7]
[6 4 2 1 7 3 0 5 9 8]
[7 2 1 6 4 3 8 0 9 5]
[8 0 1 9 3 4 7 2 6 5]
[9 0 5 3 4 8 6 1 2 7]]
注意,第一列按顺序给出了数字 0 到 9:这是因为每个点的最近邻居是它自己,这是我们预期的结果。
在这里使用完全排序,实际上做了比需要的更多的工作。如果我们只是对最近的k个邻居感兴趣,我们只需对每一行进行分区,使得最小的k + 1个平方距离首先出现,剩余的距离填充数组的其余位置。我们可以使用np.argpartition函数实现这一点:
In [21]: K = 2
nearest_partition = np.argpartition(dist_sq, K + 1, axis=1)
为了可视化这些邻居的网络,让我们快速绘制这些点以及代表从每个点到其两个最近邻居的连接的线条(见图 11-2)。
In [22]: plt.scatter(X[:, 0], X[:, 1], s=100)
# draw lines from each point to its two nearest neighbors
K = 2
for i in range(X.shape[0]):
for j in nearest_partition[i, :K+1]:
# plot a line from X[i] to X[j]
# use some zip magic to make it happen:
plt.plot(*zip(X[j], X[i]), color='black')
图 11-2. 每个点的最近邻居的可视化
每个图中的点都有线连接到其两个最近的邻居。乍一看,一些点有超过两条线连接可能会显得奇怪:这是因为如果点 A 是点 B 的两个最近邻之一,这并不一定意味着点 B 是点 A 的两个最近邻之一。
尽管这种方法的广播和行排序可能比编写循环不那么直观,但事实证明这是一种非常高效的在 Python 中处理这些数据的方法。您可能会尝试通过手动循环遍历数据并逐个排序每组邻居来执行相同类型的操作,但这几乎肯定会导致比我们使用的向量化版本更慢的算法。这种方法的美妙之处在于它以一种对输入数据大小不可知的方式编写:我们可以轻松地在任意维度中计算 100 个或 1,000,000 个点之间的邻居,代码看起来都一样。
最后,我要注意的是,在进行非常大的最近邻搜索时,有基于树的和/或近似算法可以扩展为𝒪 [ N log N ]或更好,而不是粗暴算法的𝒪 [ N 2 ]。这种算法的一个例子是 KD-Tree,在 Scikit-Learn 中实现。
第十二章:结构化数据:NumPy 的结构化数组
虽然通常我们的数据可以用值的同类数组很好地表示,但有时情况并非如此。本章演示了 NumPy 的结构化数组和记录数组的使用,它们为复合异构数据提供了高效的存储。虽然这里展示的模式对于简单操作很有用,但像这样的情景通常适合使用 Pandas 的DataFrame,我们将在第三部分中探讨。
In [1]: import numpy as np
假设我们有几类数据关于一些人(比如,姓名、年龄和体重),我们想要存储这些值以供在 Python 程序中使用。可以将它们分别存储在三个单独的数组中:
In [2]: name = ['Alice', 'Bob', 'Cathy', 'Doug']
age = [25, 45, 37, 19]
weight = [55.0, 85.5, 68.0, 61.5]
但这有点笨拙。这里没有告诉我们这三个数组是相关的;NumPy 的结构化数组允许我们通过使用单一结构更自然地存储所有这些数据。
回顾之前我们用这样的表达式创建了一个简单的数组:
In [3]: x = np.zeros(4, dtype=int)
我们可以类似地使用复合数据类型规范来创建结构化数组:
In [4]: # Use a compound data type for structured arrays
data = np.zeros(4, dtype={'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
print(data.dtype)
Out[4]: [('name', '<U10'), ('age', '<i4'), ('weight', '<f8')]
这里的'U10'被翻译为“最大长度为 10 的 Unicode 字符串”,'i4'被翻译为“4 字节(即 32 位)整数”,而'f8'被翻译为“8 字节(即 64 位)浮点数”。我们将在下一节讨论这些类型代码的其他选项。
现在我们创建了一个空的容器数组,我们可以用我们的值列表填充这个数组:
In [5]: data['name'] = name
data['age'] = age
data['weight'] = weight
print(data)
Out[5]: [('Alice', 25, 55. ) ('Bob', 45, 85.5) ('Cathy', 37, 68. )
('Doug', 19, 61.5)]
正如我们所希望的那样,数据现在方便地排列在一个结构化数组中。
结构化数组的方便之处在于,我们现在既可以按索引也可以按名称引用值:
In [6]: # Get all names
data['name']
Out[6]: array(['Alice', 'Bob', 'Cathy', 'Doug'], dtype='<U10')
In [7]: # Get first row of data
data[0]
Out[7]: ('Alice', 25, 55.)
In [8]: # Get the name from the last row
data[-1]['name']
Out[8]: 'Doug'
使用布尔遮罩,我们甚至可以进行一些更复杂的操作,例如按年龄进行过滤:
In [9]: # Get names where age is under 30
data[data['age'] < 30]['name']
Out[9]: array(['Alice', 'Doug'], dtype='<U10')
如果您想要进行比这些更复杂的操作,您可能应该考虑 Pandas 包,详细介绍请参阅第四部分。正如您将看到的,Pandas 提供了一个DataFrame对象,它是建立在 NumPy 数组上的结构,提供了许多有用的数据操作功能,类似于您在这里看到的,以及更多更多。
探索结构化数组的创建
结构化数组数据类型可以用多种方式指定。早些时候,我们看到了字典方法:
In [10]: np.dtype({'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
Out[10]: dtype([('name', '<U10'), ('age', '<i4'), ('weight', '<f8')])
为了清晰起见,数值类型可以使用 Python 类型或 NumPy dtype来指定:
In [11]: np.dtype({'names':('name', 'age', 'weight'),
'formats':((np.str_, 10), int, np.float32)})
Out[11]: dtype([('name', '<U10'), ('age', '<i8'), ('weight', '<f4')])
复合类型也可以作为元组列表来指定:
In [12]: np.dtype([('name', 'S10'), ('age', 'i4'), ('weight', 'f8')])
Out[12]: dtype([('name', 'S10'), ('age', '<i4'), ('weight', '<f8')])
如果类型名称对您不重要,您可以单独在逗号分隔的字符串中指定这些类型:
In [13]: np.dtype('S10,i4,f8')
Out[13]: dtype([('f0', 'S10'), ('f1', '<i4'), ('f2', '<f8')])
缩短的字符串格式代码可能并不直观,但它们基于简单的原则构建。第一个(可选)字符 < 或 >,表示“小端”或“大端”,分别指定了显著位的排序约定。接下来的字符指定数据类型:字符、字节、整数、浮点数等(参见表 12-1)。最后一个字符或多个字符表示对象的字节大小。
表 12-1. NumPy 数据类型
| 字符 | 描述 | 示例 |
|---|---|---|
'b' | 字节 | np.dtype('b') |
'i' | 有符号整数 | np.dtype('i4') == np.int32 |
'u' | 无符号整数 | np.dtype('u1') == np.uint8 |
'f' | 浮点数 | np.dtype('f8') == np.int64 |
'c' | 复数浮点数 | np.dtype('c16') == np.complex128 |
'S', 'a' | 字符串 | np.dtype('S5') |
'U' | Unicode 字符串 | np.dtype('U') == np.str_ |
'V' | 原始数据(空) | np.dtype('V') == np.void |
更高级的复合类型
可以定义更高级的复合类型。例如,您可以创建每个元素包含值数组或矩阵的类型。在这里,我们将创建一个数据类型,其 mat 组件由一个 3 × 3 浮点矩阵组成:
In [14]: tp = np.dtype([('id', 'i8'), ('mat', 'f8', (3, 3))])
X = np.zeros(1, dtype=tp)
print(X[0])
print(X['mat'][0])
Out[14]: (0, [[0., 0., 0.], [0., 0., 0.], [0., 0., 0.]])
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
现在 X 数组中的每个元素包含一个 id 和一个 3 × 3 矩阵。为什么您会使用这个而不是简单的多维数组,或者可能是 Python 字典?一个原因是这个 NumPy dtype 直接映射到 C 结构定义,因此包含数组内容的缓冲区可以在适当编写的 C 程序中直接访问。如果您发现自己编写一个 Python 接口来操作结构化数据的传统 C 或 Fortran 库,结构化数组可以提供一个强大的接口。
记录数组:带有变化的结构化数组
NumPy 还提供了记录数组(np.recarray 类的实例),几乎与上述结构化数组相同,但有一个附加功能:可以将字段作为属性而不是字典键访问。回顾我们之前通过编写来访问样本数据集中的年龄:
In [15]: data['age']
Out[15]: array([25, 45, 37, 19], dtype=int32)
如果我们将数据视为记录数组,我们可以用稍少的按键操作访问它:
In [16]: data_rec = data.view(np.recarray)
data_rec.age
Out[16]: array([25, 45, 37, 19], dtype=int32)
缺点在于,对于记录数组,即使使用相同的语法,访问字段时也涉及一些额外的开销:
In [17]: %timeit data['age']
%timeit data_rec['age']
%timeit data_rec.age
Out[17]: 121 ns ± 1.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
2.41 µs ± 15.7 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
3.98 µs ± 20.5 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
是否更便利的表示形式值得(轻微的)开销将取决于您自己的应用程序。
转向 Pandas
本章节关于结构化数组和记录数组被特意放置在本书的这一部分的末尾,因为它很好地过渡到我们将要介绍的下一个包:Pandas。结构化数组在某些情况下非常有用,比如当你使用 NumPy 数组来映射到 C、Fortran 或其他语言的二进制数据格式时。但对于日常使用结构化数据,Pandas 包是一个更好的选择;我们将在接下来的章节深入探讨它。
第三部分:使用 Pandas 进行数据操作
在 第二部分 中,我们详细介绍了 NumPy 及其 ndarray 对象,该对象使得在 Python 中高效存储和操作密集类型数组成为可能。在这里,我们将通过深入研究 Pandas 库提供的数据结构来构建这些知识。Pandas 是一个建立在 NumPy 之上的较新的包,提供了 DataFrame 的高效实现。DataFrame 本质上是带有附加行和列标签的多维数组,通常具有异构类型和/或缺失数据。除了为标记数据提供方便的存储接口外,Pandas 还实现了许多强大的数据操作,这些操作对数据库框架和电子表格程序的用户来说都很熟悉。
正如我们所见,NumPy 的 ndarray 数据结构为在数值计算任务中通常看到的整洁、良好组织的数据类型提供了基本功能。虽然它非常适合这个目的,但当我们需要更多的灵活性时(例如,将标签附加到数据、处理缺失数据等)以及当尝试的操作无法很好地映射到逐元素广播时(例如,分组、数据透视等),其中每个操作都是分析周围许多形式的不太结构化数据中的重要组成部分时,其局限性变得明显。Pandas,特别是其 Series 和 DataFrame 对象,基于 NumPy 数组结构,并提供了有效的访问这些“数据清洗”任务的方法,这些任务占据了数据科学家大部分时间。
在本书的这一部分中,我们将重点介绍如何有效地使用 Series、DataFrame 和相关结构的机制。我们将在适当的情况下使用从真实数据集中提取的示例,但这些示例并不一定是重点。
注意
在您的系统上安装 Pandas 需要 NumPy,如果您正在从源代码构建库,则需要适当的工具来编译 Pandas 构建的 C 和 Cython 源代码。有关安装过程的详细信息可以在 Pandas 文档 中找到。如果您遵循了 前言 中概述的建议并使用了 Anaconda 栈,您已经安装了 Pandas。
一旦安装了 Pandas,你就可以导入它并检查版本;以下是本书使用的版本:
In [1]: import pandas
pandas.__version__
Out[1]: '1.3.5'
就像我们通常将 NumPy 导入为别名 np 一样,我们将 Pandas 导入为别名 pd:
In [2]: import pandas as pd
在本书的剩余部分将使用此导入约定。
第十三章:介绍 Pandas 对象
在非常基本的层面上,Pandas 对象可以被认为是 NumPy 结构化数组的增强版本,其中的行和列被标签而不是简单的整数索引所标识。正如我们将在本章课程中看到的那样,Pandas 在基本数据结构之上提供了一系列有用的工具、方法和功能,但几乎所有接下来的内容都需要理解这些结构。因此,在我们继续之前,让我们看看这三种基本的 Pandas 数据结构:Series、DataFrame 和 Index。
我们将从标准的 NumPy 和 Pandas 导入开始我们的代码会话:
In [1]: import numpy as np
import pandas as pd
Pandas Series 对象
Pandas Series 是一个索引数据的一维数组。它可以从列表或数组创建如下:
In [2]: data = pd.Series([0.25, 0.5, 0.75, 1.0])
data
Out[2]: 0 0.25
1 0.50
2 0.75
3 1.00
dtype: float64
Series 将一系列值与显式的索引序列结合在一起,我们可以使用 values 和 index 属性来访问。values 简单地是一个熟悉的 NumPy 数组:
In [3]: data.values
Out[3]: array([0.25, 0.5 , 0.75, 1. ])
index 是一个 pd.Index 类型的类似数组的对象,我们稍后会更详细地讨论:
In [4]: data.index
Out[4]: RangeIndex(start=0, stop=4, step=1)
与 NumPy 数组类似,数据可以通过相关的索引使用熟悉的 Python 方括号符号进行访问:
In [5]: data[1]
Out[5]: 0.5
In [6]: data[1:3]
Out[6]: 1 0.50
2 0.75
dtype: float64
正如我们将看到的,Pandas Series 比它模拟的一维 NumPy 数组要更加一般化和灵活。
作为广义的 NumPy 数组的 Series
从目前我们所见,Series 对象可能看起来基本上可以与一维 NumPy 数组互换。本质上的区别在于,虽然 NumPy 数组具有用于访问值的隐式定义整数索引,但 Pandas Series 具有与值关联的显式定义索引。
这种显式索引定义赋予了 Series 对象额外的能力。例如,索引不必是整数,而可以是任何所需类型的值。所以,如果我们希望,我们可以使用字符串作为索引:
In [7]: data = pd.Series([0.25, 0.5, 0.75, 1.0],
index=['a', 'b', 'c', 'd'])
data
Out[7]: a 0.25
b 0.50
c 0.75
d 1.00
dtype: float64
并且项目访问按预期工作:
In [8]: data['b']
Out[8]: 0.5
我们甚至可以使用非连续或非顺序的索引:
In [9]: data = pd.Series([0.25, 0.5, 0.75, 1.0],
index=[2, 5, 3, 7])
data
Out[9]: 2 0.25
5 0.50
3 0.75
7 1.00
dtype: float64
In [10]: data[5]
Out[10]: 0.5
作为专用字典的 Series
以这种方式,你可以把 Pandas 的 Series 想象成 Python 字典的一个特殊版本。字典是一个将任意键映射到一组任意值的结构,而 Series 是一个将类型化键映射到一组类型化值的结构。这种类型化很重要:正如 NumPy 数组背后的特定类型的编译代码使其在某些操作上比 Python 列表更高效一样,Pandas Series 的类型信息使其在某些操作上比 Python 字典更高效。
Series-作为字典的类比可以通过直接从 Python 字典构造 Series 对象来更清晰地解释,例如根据 2020 年人口普查得到的五个最多人口的美国州:
In [11]: population_dict = {'California': 39538223, 'Texas': 29145505,
'Florida': 21538187, 'New York': 20201249,
'Pennsylvania': 13002700}
population = pd.Series(population_dict)
population
Out[11]: California 39538223
Texas 29145505
Florida 21538187
New York 20201249
Pennsylvania 13002700
dtype: int64
从这里,可以执行典型的字典式项目访问:
In [12]: population['California']
Out[12]: 39538223
不同于字典,Series 也支持数组式的操作,比如切片:
In [13]: population['California':'Florida']
Out[13]: California 39538223
Texas 29145505
Florida 21538187
dtype: int64
我们将在第十四章讨论 Pandas 索引和切片的一些怪癖。
构建 Series 对象
我们已经看到了几种从头开始构建 Pandas Series的方法。所有这些方法都是以下版本的某种形式:
pd.Series(data, index=index)
其中index是一个可选参数,data可以是多个实体之一。
例如,data可以是一个列表或 NumPy 数组,此时index默认为整数序列:
In [14]: pd.Series([2, 4, 6])
Out[14]: 0 2
1 4
2 6
dtype: int64
或者data可以是一个标量,它被重复以填充指定的索引:
In [15]: pd.Series(5, index=[100, 200, 300])
Out[15]: 100 5
200 5
300 5
dtype: int64
或者它可以是一个字典,此时index默认为字典的键:
In [16]: pd.Series({2:'a', 1:'b', 3:'c'})
Out[16]: 2 a
1 b
3 c
dtype: object
在每种情况下,都可以显式设置索引以控制使用的键的顺序或子集:
In [17]: pd.Series({2:'a', 1:'b', 3:'c'}, index=[1, 2])
Out[17]: 1 b
2 a
dtype: object
Pandas DataFrame 对象
Pandas 中的下一个基本结构是DataFrame。与前一节讨论的Series对象类似,DataFrame可以被视为 NumPy 数组的泛化,或者 Python 字典的特殊化。我们现在将看看每种观点。
DataFrame 作为广义的 NumPy 数组
如果Series是具有显式索引的一维数组的类比,那么DataFrame就是具有显式行和列索引的二维数组的类比。正如你可能把二维数组看作一系列对齐的一维列的有序序列一样,你可以把DataFrame看作一系列对齐的Series对象。这里,“对齐”意味着它们共享相同的索引。
为了演示这一点,让我们首先构建一个新的Series,列出前一节讨论的五个州的面积(以平方公里为单位):
In [18]: area_dict = {'California': 423967, 'Texas': 695662, 'Florida': 170312,
'New York': 141297, 'Pennsylvania': 119280}
area = pd.Series(area_dict)
area
Out[18]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
dtype: int64
现在,我们已经有了与之前的population系列一起的信息,我们可以使用字典构造一个包含此信息的单个二维对象:
In [19]: states = pd.DataFrame({'population': population,
'area': area})
states
Out[19]: population area
California 39538223 423967
Texas 29145505 695662
Florida 21538187 170312
New York 20201249 141297
Pennsylvania 13002700 119280
与Series对象类似,DataFrame还有一个index属性,用于访问索引标签:
In [20]: states.index
Out[20]: Index(['California', 'Texas', 'Florida', 'New York', 'Pennsylvania'],
> dtype='object')
此外,DataFrame还有一个columns属性,它是一个Index对象,保存列标签:
In [21]: states.columns
Out[21]: Index(['population', 'area'], dtype='object')
因此,DataFrame可以被视为二维 NumPy 数组的泛化,其中行和列都有用于访问数据的泛化索引。
DataFrame 作为特殊的字典
同样,我们也可以把DataFrame视为字典的特殊情况。在字典将键映射到值的情况下,DataFrame将列名映射到包含列数据的Series。例如,请求'area'属性将返回包含我们之前看到的面积的Series对象:
In [22]: states['area']
Out[22]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
Name: area, dtype: int64
注意这里可能的混淆点:在一个二维 NumPy 数组中,data[0] 将返回第一行。对于 DataFrame,data['col0'] 将返回第一列。因此,最好将 DataFrame 视为广义的字典,而不是广义的数组,尽管两种视角都是有用的。我们将在 第十四章 探讨更灵活的 DataFrame 索引方式。
构造 DataFrame 对象
Pandas DataFrame 可以以多种方式构建。这里我们将探讨几个例子。
从单个 Series 对象
DataFrame 是 Series 对象的集合,一个单列的 DataFrame 可以从一个单独的 Series 构建出来:
In [23]: pd.DataFrame(population, columns=['population'])
Out[23]: population
California 39538223
Texas 29145505
Florida 21538187
New York 20201249
Pennsylvania 13002700
从字典列表
任何字典列表都可以转换成 DataFrame。我们将使用一个简单的列表推导来创建一些数据:
In [24]: data = [{'a': i, 'b': 2 * i}
for i in range(3)]
pd.DataFrame(data)
Out[24]: a b
0 0 0
1 1 2
2 2 4
即使字典中有些键是缺失的,Pandas 也会用 NaN 值(即“Not a Number”;参见 第十六章)来填充它们:
In [25]: pd.DataFrame([{'a': 1, 'b': 2}, {'b': 3, 'c': 4}])
Out[25]: a b c
0 1.0 2 NaN
1 NaN 3 4.0
从字典的 Series 对象
正如我们之前所看到的,一个 DataFrame 也可以从一个字典的 Series 对象构建出来:
In [26]: pd.DataFrame({'population': population,
'area': area})
Out[26]: population area
California 39538223 423967
Texas 29145505 695662
Florida 21538187 170312
New York 20201249 141297
Pennsylvania 13002700 119280
从二维 NumPy 数组
给定一个二维数据数组,我们可以创建一个带有指定列和索引名称的 DataFrame。如果省略,将使用整数索引:
In [27]: pd.DataFrame(np.random.rand(3, 2),
columns=['foo', 'bar'],
index=['a', 'b', 'c'])
Out[27]: foo bar
a 0.471098 0.317396
b 0.614766 0.305971
c 0.533596 0.512377
从 NumPy 结构化数组
我们在 第十二章 中讨论了结构化数组。Pandas DataFrame 的操作方式与结构化数组非常相似,可以直接从结构化数组创建一个:
In [28]: A = np.zeros(3, dtype=[('A', 'i8'), ('B', 'f8')])
A
Out[28]: array([(0, 0.), (0, 0.), (0, 0.)], dtype=[('A', '<i8'), ('B', '<f8')])
In [29]: pd.DataFrame(A)
Out[29]: A B
0 0 0.0
1 0 0.0
2 0 0.0
Pandas Index 对象
正如你所见,Series 和 DataFrame 对象都包含了一个明确的索引,让你可以引用和修改数据。这个 Index 对象本身就是一个有趣的结构,它可以被看作是一个不可变数组或者一个有序集合(技术上是一个多重集合,因为 Index 对象可能包含重复的值)。这些视角在 Index 对象上的操作上产生了一些有趣的后果。举个简单的例子,让我们从一个整数列表构造一个 Index:
In [30]: ind = pd.Index([2, 3, 5, 7, 11])
ind
Out[30]: Int64Index([2, 3, 5, 7, 11], dtype='int64')
作为不可变数组的 Index
Index 在许多方面都像一个数组。例如,我们可以使用标准的 Python 索引表示法来检索值或切片:
In [31]: ind[1]
Out[31]: 3
In [32]: ind[::2]
Out[32]: Int64Index([2, 5, 11], dtype='int64')
Index 对象也具有许多与 NumPy 数组相似的属性:
In [33]: print(ind.size, ind.shape, ind.ndim, ind.dtype)
Out[33]: 5 (5,) 1 int64
Index 对象和 NumPy 数组之间的一个区别是索引是不可变的——也就是说,它们不能通过正常的方式修改:
In [34]: ind[1] = 0
TypeError: Index does not support mutable operations
这种不可变性使得在多个 DataFrame 和数组之间共享索引更加安全,避免了因无意中修改索引而产生的副作用。
作为有序集合的 Index
Pandas 对象旨在简化诸如跨数据集连接等操作,这些操作依赖于集合算术的许多方面。Index 对象遵循 Python 内置 set 数据结构使用的许多约定,因此可以以熟悉的方式计算并集、交集、差集和其他组合:
In [35]: indA = pd.Index([1, 3, 5, 7, 9])
indB = pd.Index([2, 3, 5, 7, 11])
In [36]: indA.intersection(indB)
Out[36]: Int64Index([3, 5, 7], dtype='int64')
In [37]: indA.union(indB)
Out[37]: Int64Index([1, 2, 3, 5, 7, 9, 11], dtype='int64')
In [38]: indA.symmetric_difference(indB)
Out[38]: Int64Index([1, 2, 9, 11], dtype='int64')