文章目录
《Python数据科学手册》读书笔记
高性能Pandas: eval()与query()
query()与eval()的设计动机: 复合代数式
NumPy 与 Pandas 都支持快速的向量化运算。 例如,
可以对下面两个数组进行求和:
import numpy as np
rng = np.random.RandomState(42)
x = rng.rand(1000000)
y = rng.rand(1000000)
%timeit x + y
4.01 ms ± 44.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
这样做比普通的 Python 循环或列表综合要快
很多:
%timeit np.fromiter((xi + yi for xi, yi in zip(x, y)), dtype=x.dtype, count=len(x))
563 ms ± 50.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
但是这种运算在处理复合代数式(compound expression) 问题时的效率
比较低, 例如下面的表达式:
mask = (x > 0.5) & (y < 0.5)
由于 NumPy 会计算每一个代数子式, 因此这个计算过程等价于:
tmp1 = (x > 0.5)
tmp2 = (y < 0.5)
mask = tmp1 & tmp2
也就是说, 每段中间过程都需要显式地分配内存。 如果 x 数组和 y 数
组非常大, 这么运算就会占用大量的时间和内存消耗。
import numexpr
mask_numexpr = numexpr.evaluate('(x > 0.5) & (y < 0.5)')
np.allclose(mask, mask_numexpr)
True
这么做的好处是, 由于 Numexpr 在计算代数式时不需要为临时数组分
配全部内存, 因此计算比 NumPy 更高效, 尤其适合处理大型数组。Pandas 的 eval() 和 query() 工具其实也是基于 Numexpr
实现的。
用pandas.eval()实现高性能运算
Pandas 的 eval() 函数用字符串代数式实现了 DataFrame 的高性能运
算, 例如下面的 DataFrame:
import pandas as pd
nrows, ncols = 100000, 100
rng = np.random.RandomState(42)
df1, df2, df3, df4 = (pd.DataFrame(rng.rand(nrows, ncols))
for i in range(4))
如果要用普通的 Pandas 方法计算四个 DataFrame 的和, 可以这么写:
%timeit df1 + df2 + df3 + df4
116 ms ± 2.23 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
也可以通过 pd.eval 和字符串代数式计算并得出相同的结果:
%timeit pd.eval('df1 + df2 + df3 + df4')
53.7 ms ± 1.36 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
这个 eval() 版本的代数式比普通方法快一倍(而且内存消耗更少) ,
结果也是一样的:
np.allclose(df1 + df2 + df3 + df4,
pd.eval('df1 + df2 + df3 + df4'))
True
pd.eval()支持的运算
从 Pandas v0.16 版开始, pd.eval() 就支持许多运算了。 为了演示这些
运算, 创建一个整数类型的 DataFrame:
df1, df2, df3, df4, df5 = (pd.DataFrame(rng.randint(0, 1000, (100, 3)))
for i in range(5))
- 算术运算符。
pd.eval() 支持所有的算术运算符, 例如:
result1 = -df1 * df2 / (df3 + df4) - df5
result2 = pd.eval('-df1 * df2 / (df3 + df4) - df5')
np.allclose(result1, result2)
True
- 比较运算符。
pd.eval() 支持所有的比较运算符, 包括链式代数式
(chained expression) :
result1 = (df1 < df2) & (df2 <= df3) & (df3 != df4)
result2 = pd.eval('df1 < df2 <= df3 != df4')
np.allclose(result1, result2)
True
- 位运算符。
pd.eval() 支持 &(与) 和|(或) 等位运算符:
result1 = (df1 < 0.5) & (df2 < 0.5) | (df3 < df4)
result2 = pd.eval('(df1 < 0.5) & (df2 < 0.5) | (df3 < df4)')
np.allclose(result1, result2)
True
另外, 还可以在布尔类型的代数式中使用 and 和 or 等字面值:
result3 = pd.eval('(df1 < 0.5) and (df2 < 0.5) or (df3 < df4)')
np.allclose(result1, result3)
True
- 对象属性与索引。
pd.eval() 可以通过 obj.attr 语法获取对象属
性, 通过 obj[index] 语法获取对象索引:
result1 = df2.T[0] + df3.iloc[1]
result2 = pd.eval('df2.T[0] + df3.iloc[1]')
np.allclose(result1, result2)
True
- 其他运算。
目前 pd.eval() 还不支持函数调用、 条件语句、 循环以
及更复杂的运算。 如果你想要进行这些运算, 可以借助 Numexpr 来实
现。
用DataFrame.eval()实现列间运算
由于 pd.eval() 是 Pandas 的顶层函数, 因此 DataFrame 有一个
eval() 方法可以做类似的运算。 使用 eval() 方法的好处是可以借助
列名称进行运算, 示例如下:
df = pd.DataFrame(rng.rand(1000, 3), columns=['A', 'B', 'C'])
df.head()
A | B | C | |
---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 |
1 | 0.069087 | 0.235615 | 0.154374 |
2 | 0.677945 | 0.433839 | 0.652324 |
3 | 0.264038 | 0.808055 | 0.347197 |
4 | 0.589161 | 0.252418 | 0.557789 |
如果用前面介绍的 pd.eval(), 就可以通过下面的代数式计算这三列:
result1 = (df['A'] + df['B']) / (df['C'] - 1)
result2 = pd.eval("(df.A + df.B) / (df.C - 1)")
np.allclose(result1, result2)
True
而 DataFrame.eval() 方法可以通过列名称实现简洁的代数式:
result3 = df.eval('(A + B) / (C - 1)')
np.allclose(result1, result3)
True
请注意, 这里用列名称作为变量来计算代数式, 结果同样是正确的。
- 用DataFrame.eval()新增列除了前面介绍的运算功能, DataFrame.eval() 还可以创建新的
列。 还用前面的 DataFrame 来演示, 列名是 ‘A’、 ‘B’ 和 ‘C’:
df.head()
A | B | C | |
---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 |
1 | 0.069087 | 0.235615 | 0.154374 |
2 | 0.677945 | 0.433839 | 0.652324 |
3 | 0.264038 | 0.808055 | 0.347197 |
4 | 0.589161 | 0.252418 | 0.557789 |
可以用 df.eval() 创建一个新的列 ‘D’, 然后赋给它其他列计算
的值:
df.eval('D = (A + B) / C', inplace=True)
df.head()
A | B | C | D | |
---|---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 | 11.187620 |
1 | 0.069087 | 0.235615 | 0.154374 | 1.973796 |
2 | 0.677945 | 0.433839 | 0.652324 | 1.704344 |
3 | 0.264038 | 0.808055 | 0.347197 | 3.087857 |
4 | 0.589161 | 0.252418 | 0.557789 | 1.508776 |
还可以修改已有的列:
df.eval('D = (A - B) / C', inplace=True)
df.head()
A | B | C | D | |
---|---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 | -0.449425 |
1 | 0.069087 | 0.235615 | 0.154374 | -1.078728 |
2 | 0.677945 | 0.433839 | 0.652324 | 0.374209 |
3 | 0.264038 | 0.808055 | 0.347197 | -1.566886 |
4 | 0.589161 | 0.252418 | 0.557789 | 0.603708 |
- DataFrame.eval()使用局部变量
DataFrame.eval() 方法还支持通过 @ 符号使用 Python 的局部变
量, 如下所示:
column_mean = df.mean(1)
result1 = df['A'] + column_mean
result2 = df.eval('A + @column_mean')
np.allclose(result1, result2)
True
@ 符号表示“这是一个变量名称而不是一个列名称”, 从而让你灵活
地用两个“命名空间”的资源(列名称的命名空间和 Python 对象的
命名空间) 计算代数式。 需要注意的是, @ 符号只能在
DataFrame.eval() 方法中使用, 而不能在 pandas.eval() 函数
中使用, 因为 pandas.eval() 函数只能获取一个(Python) 命名
空间的内容。
DataFrame.query()方法
DataFrame 基于字符串代数式的运算实现了另一个方法, 被称为
query(), 例如:
result1 = df[(df.A < 0.5) & (df.B < 0.5)]
result2 = pd.eval('df[(df.A < 0.5) & (df.B < 0.5)]')
np.allclose(result1, result2)
True
和前面介绍过的 DataFrame.eval() 一样, 这是一个用 DataFrame 列
创建的代数式, 但是不能用 DataFrame.eval() 语法 。 不过, 对于这
种过滤运算, 可以用 query() 方法:
result2 = df.query('A < 0.5 and B < 0.5')
np.allclose(result1, result2)
True
除了计算性能更优之外, 这种方法的语法也比掩码代数式语法更好理
解。 需要注意的是, query() 方法也支持用 @ 符号引用局部变量:
Cmean = df['C'].mean()
result1 = df[(df.A < Cmean) & (df.B < Cmean)]
result2 = df.query('A < @Cmean and B < @Cmean')
np.allclose(result1, result2)
True
性能决定使用时机
在考虑要不要用这两个函数时, 需要思考两个方面: 计算时间和内存消
耗, 而内存消耗是更重要的影响因素。 就像前面介绍的那样, 每个涉及
NumPy 数组或 Pandas 的 DataFrame 的复合代数式都会产生临时数组,
例如:
x = df[(df.A < 0.5) & (df.B < 0.5)]
它基本等价于:
tmp1 = df.A < 0.5
tmp2 = df.B < 0.5
tmp3 = tmp1 & tmp2
x = df[tmp3]
如果临时 DataFrame 的内存需求比你的系统内存还大(通常是几吉字
节) , 那么最好还是使用 eval() 和 query() 代数式。 你可以通过下面
的方法大概估算一下变量的内存消耗:
df.values.nbytes
32000
在性能方面, 即使你没有使用最大的系统内存, eval() 的计算速度也
比普通方法快。 现在的性能瓶颈变成了临时 DataFrame 与系统 CPU 的
L1 和 L2 缓存之间的对比了——如果系统缓存足够大, 那么 eval() 就可以避免在不同缓存间缓慢地移动临时
文件。 在实际工作中, 我发现普通的计算方法与 eval/query 计算方法
在计算时间上的差异并非总是那么明显, 普通方法在处理较小的数组时
反而速度更快! eval/query 方法的优点主要是节省内存, 有时语法也
更加简洁。