最近在写深度学习开源库时,遇到了读取语料并预处理的 API,于是趁此机会整理一下之前积累的关于高性能的内容。全文包括两个部分:第一部分主要聚焦在常用工具 Pandas 处理数据时不同操作方法的性能;第二部分主要介绍一些加速数值计算的工具;第三部分主要介绍一些能够辅助增加性能的工具。
如果懒得看过程,可以直接看结论:
- 数值计算任务:
numba或Cython加速;使用.values计算 - 遍历的非数值计算任务:
df.itertuples或df.apply方法 - 数据太大 Pandas 处理不了的可以使用 Arrow 和 Polars,再不行了使用 Spark
所有测试代码在:Note_DS/Performance.ipynb
Pandas 性能对比
数据集
本文的数据集使用 今日头条中文新闻(文本)分类数据集,此数据集样例如下:
1 | $ head -2 toutiao_cat_data.txt |
该数据集是短文本分类任务,包含 15 个类别,共 382688 条样本。每行为一条数据,以_!_分割的个字段,从前往后分别是 新闻 ID,分类 code,分类名称,新闻字符串(仅含标题),新闻关键词。为了提升处理效率,我们只选择 1 万条数据。
循环任务
假设我们需要得到 title 的分词结果,这里我们使用结巴分词。
For循环
最基本直观的操作:
1 | def tokenize_forloop(df): |
执行结果如下:
1 | CPU times: user 3.06 s, sys: 220 ms, total: 3.28 s |
iterrows方法
1 | def tokenize_iterrows(df): |
执行结果如下:
1 | CPU times: user 2.76 s, sys: 45.3 ms, total: 2.8 s |
itertuple方法
1 | def tokenize_itertuples(df): |
执行结果如下:
1 | CPU times: user 1.26 s, sys: 13.8 ms, total: 1.28 s |
Apply方法
1 | %time df["tokens"] = df["title"].apply(lambda x: jieba.lcut(x)) |
执行结果如下:
1 | CPU times: user 1.41 s, sys: 19.1 ms, total: 1.43 s |
可以发现 itertuple 方法出奇的快,甚至超过了 apply 方法。
批量任务
假设我们需要对长度进行最小最大值范围限定:
1 | def reset_len(df, series, min_len, max_len): |
这种情况可以进行批量处理,也有两种不同的方式:
Series
1 | %timeit reset_len(df, df["length"], 5, 20) |
Numpy
1 | %timeit reset_len(df, df["length"].values, 5, 20) |
可以发现转成 Numpy 快了将近一倍。我们再尝试用 apply 处理一下:
1 | %timeit df["length"] = df["length"].apply(lambda x: deal_length(x, 5, 20)) |
这个差距有 10 倍了。
小结一下:如果是循环任务(非数字、需要其他复杂处理的),可以使用 apply 或 itertuple,后者对于处理数据集尤其有用;如果是批量任务,可以充分利用矩阵计算快速处理,唯一要做的仅仅只是添加一个 .values 的后缀而已。
加速数据任务
如果任务中有大量数值计算和循环,可以使用 numba,JAX,Cython 等进行加速,其性能会远超循环方法。
加速器
构造一个简单的例子:
1 | import numpy as np |
numba
不使用 numba:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 numba.jit:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
再看一个批量的例子:
1 | def double_every_value_nonumba(x): |
不使用 numba:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 numba.jit 和 apply:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
直接批量计算:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 .values 批量计算:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 numba.vectorize:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 numba.vectorize 和 .values:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 numba.jit 和 .values:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
JAX
依然使用上面批量的例子:
1 | from jax import jit |
使用 jax:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
使用 jax 和 .values:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
通过简单的例子对比可以发现,numba 比 JAX 要快一些。
此外还有其他一些加速器,可参考:Make your Python code fly at transonic speeds!
Cython
我们使用前面 numba 的 integrate 方法:
1 | %load_ext Cython |
纯 Cython:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
比直接使用 Python 稍微快了一点。
添加类型:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
这性能已经超过了使用 numba.jit,至少也是相差无几了。
使用 ndarray:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
可以看到已经很好了,这里还是用了 numpy 的 array,总而言之,.values 值得被使用。
eval
这是一个实验功能,看起来还是很强大,我们使用 double_every_value 方法进行测试:
1 | df = pd.DataFrame(np.random.randint(1,100,size=(100000, 3)),columns=['a', 'b', 'N']) |
可以看到这个比使用了 numba 的 apply 还是快很多,但和 .values 和批量方法相比还是有差距。
并发
我们使用最一开始的文本处理任务:
1 | from pnlp import concurring |
不使用并发:
1 | df = get_data() |
可以看到和 apply 差不多:
1 | df = get_data() |
多进程:
1 | df = get_data() |
多线程:
1 | df = get_data() |
多线程不适用并发快一些,但多进程反而会慢,每个进程导入包可能会有影响。
最后看下 Pandarallel:
1 | df = get_data() |
还是有一定的提升。
其他数据处理工具
除了 Pandas,还有两个工具值得推荐:
- 如果数据对 Pandas 来说太大,但对 Spark 来说又太小,可以使用: Polars
- 当然,还有它使用的高性能数据处理引擎:Apache Arrow