Pandas pd.Series.isin性能与集合与数组

时间:2018-06-10 00:25:46

标签: python performance pandas numpy series

在Python中,一般来说,可以通过set对可散列集合的成员资格进行最佳测试。我们知道这一点,因为使用散列为我们提供了O(1)查找复杂度,而listnp.ndarray则为O(n)。

在Pandas,我经常要检查非常大的收藏品的会员资格。我推测同样适用,即检查一系列中set成员资格的每个项目比使用listnp.ndarray更有效。但是,情况似乎并非如此:

import numpy as np
import pandas as pd

np.random.seed(0)

x_set = {i for i in range(100000)}
x_arr = np.array(list(x_set))
x_list = list(x_set)

arr = np.random.randint(0, 20000, 10000)
ser = pd.Series(arr)
lst = arr.tolist()

%timeit ser.isin(x_set)                   # 8.9 ms
%timeit ser.isin(x_arr)                   # 2.17 ms
%timeit ser.isin(x_list)                  # 7.79 ms
%timeit np.in1d(arr, x_arr)               # 5.02 ms
%timeit [i in x_set for i in lst]         # 1.1 ms
%timeit [i in x_set for i in ser.values]  # 4.61 ms

用于测试的版本:

np.__version__  # '1.14.3'
pd.__version__  # '0.23.0'
sys.version     # '3.6.5'

我认为pd.Series.isin的源代码使用的是numpy.in1d,这可能意味着setnp.ndarray转换的开销很大。

否定构建投入的成本,对熊猫的影响:

  • 如果您知道x_listx_arr的元素是唯一的,请不要转换为x_set。与Pandas一起使用这将是昂贵的(转换和成员资格测试)。
  • 使用列表推导是从O(1)集查找中受益的唯一方法。

我的问题是:

  1. 我的分析是否正确?对于pd.Series.isin的实施方式,这似乎是一个显而易见但未记录的结果。
  2. 是否有解决方法,没有使用列表解析或pd.Series.apply 使用O(1)集查找?或者这是不可避免的设计选择和/或将NumPy作为熊猫骨干的必然结果?
  3. 更新:在较旧的设置(Pandas / NumPy版本)上,我看到x_set优于x_arr pd.Series.isin。另外一个问题是:从旧到新有什么从根本上改变导致set的表现恶化吗?

    %timeit ser.isin(x_set)                   # 10.5 ms
    %timeit ser.isin(x_arr)                   # 15.2 ms
    %timeit ser.isin(x_list)                  # 9.61 ms
    %timeit np.in1d(arr, x_arr)               # 4.15 ms
    %timeit [i in x_set for i in lst]         # 1.15 ms
    %timeit [i in x_set for i in ser.values]  # 2.8 ms
    
    pd.__version__  # '0.19.2'
    np.__version__  # '1.11.3'
    sys.version     # '3.6.0'
    

1 个答案:

答案 0 :(得分:30)

这可能不是很明显,但pd.Series.isin使用O(1) - 查找。

经过分析,证明了上述说法,我们将利用其洞察力创建一个Cython原型,可以轻松击败最快的开箱即用解决方案。

我们假设“set”包含n个元素,而“series”包含m个元素。运行时间是:

 T(n,m)=T_preprocess(n)+m*T_lookup(n)

对于pure-python版本,这意味着:

  • T_preprocess(n)=0 - 无需预处理
  • T_lookup(n)=O(1) - 众所周知的python set
  • 的行为
  • 结果为T(n,m)=O(m)

pd.Series.isin(x_arr)会发生什么?显然,如果我们跳过预处理并在线性时间内搜索,我们将获得O(n*m),这是不可接受的。

在调试器或探查器(我使用valgrind-callgrind + kcachegrind)的帮助下很容易看到,发生了什么:工作马是函数__pyx_pw_6pandas_5_libs_9hashtable_23ismember_int64。它的定义可以找到here

  • 在预处理步骤中,哈希图(pandas使用khash from klib)是从n的{​​{1}}元素中创建的,即在运行时x_arr中。
  • O(n)查找在m每个或在构造的哈希映射中总共O(1)发生。
  • 结果为O(m)

我们必须记住 - numpy-array的元素是raw-C-integers而不是原始集合中的Python对象 - 所以我们不能按原样使用set。

将Python对象集转换为一组C-int的替代方法是将单个C-int转换为Python-object,从而能够使用原始集。这就是T(n,m)=O(m)+O(n) - 变种:

中发生的情况
  • 没有预处理。
  • m查找发生在每个[i in x_set for i in ser.values]时间或总共O(1),但由于必要的Python对象创建,查找速度较慢。
  • 结果为O(m)

显然,你可以使用Cython加速这个版本。

但是足够的理论,我们来看看固定T(n,m)=O(m) s的不同n的运行时间:

enter image description here

我们可以看到:预处理的线性时间占据了大m的numpy版本。从numpy转换为pure-python(n)的版本与pure-python版本具有相同的常量行为,但由于必要的转换而变慢 - 这完全符合我们的分析。

图中无法很好地看到:如果numpy->python numpy版本变得更快 - 在这种情况下n < m更快的查找 - lib扮演最重要的角色,而不是预处理 - 一部分。

我从这个分析中得到的结论:

  • khashn < m应该被采纳,因为pd.Series.isin - 预处理费用不高。

  • O(n) :(可能是cythonized版本的)n > m应该被采用,因此[i in x_set for i in ser.values]可以避免。

  • 显然有一个灰色区域O(n)n大致相等,很难说没有测试哪个解决方案最好。

  • 如果您拥有它:最好的办法是将m直接构建为C整数集(setalready wrapped in pandas)或者甚至一些c ++ - 实现),从而消除了预处理的需要。我不知道,大熊猫是否有可以重复使用的东西,但在Cython中编写函数可能不是什么大问题。

问题在于,最后的建议并不是开箱即用的,因为在他们的界面中,熊猫和numpy都没有一套概念(至少对我有限的知识)。但是拥有原始C-set接口将是两全其美的:

  • 不需要预处理,因为值已作为集合传递
  • 不需要转换,因为传递的集合包含raw-C-values

我编写了一个快速而又脏的Cython-wrapper for khash(灵感来自pandas中的包装器),可以通过khash安装,然后与Cython一起使用以获得更快的pip install https://github.com/realead/cykhash/zipball/master版本:

isin

作为另一种可能性,c ++的%%cython import numpy as np cimport numpy as np from cykhash.khashsets cimport Int64Set def isin_khash(np.ndarray[np.int64_t, ndim=1] a, Int64Set b): cdef np.ndarray[np.uint8_t,ndim=1, cast=True] res=np.empty(a.shape[0],dtype=np.bool) cdef int i for i in range(a.size): res[i]=b.contains(a[i]) return res 可以被包装(参见清单C),其缺点是需要c ++库和(我们将看到)略慢。

比较方法(参见清单D创建时间):

enter image description here

khash比unordered_map快约20倍,比纯python快6倍(但纯python不是我们想要的),甚至比cpp-version快3倍。< / p>

编目

1)用valgrind进行分析:

numpy->python

现在:

#isin.py
import numpy as np
import pandas as pd

np.random.seed(0)

x_set = {i for i in range(2*10**6)}
x_arr = np.array(list(x_set))


arr = np.random.randint(0, 20000, 10000)
ser = pd.Series(arr)


for _ in range(10):
   ser.isin(x_arr)

导致以下调用图:

enter image description here

B:用于产生运行时间的ipython代码:

>>> valgrind --tool=callgrind python isin.py
>>> kcachegrind

C:cpp-wrapper:

import numpy as np
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt

np.random.seed(0)

x_set = {i for i in range(10**2)}
x_arr = np.array(list(x_set))
x_list = list(x_set)

arr = np.random.randint(0, 20000, 10000)
ser = pd.Series(arr)
lst = arr.tolist()

n=10**3
result=[]
while n<3*10**6:
    x_set = {i for i in range(n)}
    x_arr = np.array(list(x_set))
    x_list = list(x_set)

    t1=%timeit -o  ser.isin(x_arr) 
    t2=%timeit -o  [i in x_set for i in lst]
    t3=%timeit -o  [i in x_set for i in ser.values]

    result.append([n, t1.average, t2.average, t3.average])
    n*=2

#plotting result:
for_plot=np.array(result)
plt.plot(for_plot[:,0], for_plot[:,1], label='numpy')
plt.plot(for_plot[:,0], for_plot[:,2], label='python')
plt.plot(for_plot[:,0], for_plot[:,3], label='numpy->python')
plt.xlabel('n')
plt.ylabel('running time')
plt.legend()
plt.show()

D:使用不同的set-wrappers绘制结果:

%%cython --cplus -c=-std=c++11 -a

from libcpp.unordered_set cimport unordered_set

cdef class HashSet:
    cdef unordered_set[long long int] s
    cpdef add(self, long long int z):
        self.s.insert(z)
    cpdef bint contains(self, long long int z):
        return self.s.count(z)>0

import numpy as np
cimport numpy as np

cimport cython
@cython.boundscheck(False)
@cython.wraparound(False)

def isin_cpp(np.ndarray[np.int64_t, ndim=1] a, HashSet b):
    cdef np.ndarray[np.uint8_t,ndim=1, cast=True] res=np.empty(a.shape[0],dtype=np.bool)
    cdef int i
    for i in range(a.size):
        res[i]=b.contains(a[i])
    return res