在Python中,一般来说,可以通过set
对可散列集合的成员资格进行最佳测试。我们知道这一点,因为使用散列为我们提供了O(1)查找复杂度,而list
或np.ndarray
则为O(n)。
在Pandas,我经常要检查非常大的收藏品的会员资格。我推测同样适用,即检查一系列中set
成员资格的每个项目比使用list
或np.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
,这可能意味着set
到np.ndarray
转换的开销很大。
否定构建投入的成本,对熊猫的影响:
x_list
或x_arr
的元素是唯一的,请不要转换为x_set
。与Pandas一起使用这将是昂贵的(转换和成员资格测试)。我的问题是:
pd.Series.isin
的实施方式,这似乎是一个显而易见但未记录的结果。pd.Series.apply
, 使用O(1)集查找?或者这是不可避免的设计选择和/或将NumPy作为熊猫骨干的必然结果? 更新:在较旧的设置(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'
答案 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:
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)
- 变种:
[i in x_set for i in ser.values]
时间或总共O(1)
,但由于必要的Python对象创建,查找速度较慢。O(m)
显然,你可以使用Cython加速这个版本。
但是足够的理论,我们来看看固定T(n,m)=O(m)
s的不同n
的运行时间:
我们可以看到:预处理的线性时间占据了大m
的numpy版本。从numpy转换为pure-python(n
)的版本与pure-python版本具有相同的常量行为,但由于必要的转换而变慢 - 这完全符合我们的分析。
图中无法很好地看到:如果numpy->python
numpy版本变得更快 - 在这种情况下n < m
更快的查找 - lib扮演最重要的角色,而不是预处理 - 一部分。
我从这个分析中得到的结论:
khash
:n < m
应该被采纳,因为pd.Series.isin
- 预处理费用不高。
O(n)
:(可能是cythonized版本的)n > m
应该被采用,因此[i in x_set for i in ser.values]
可以避免。
显然有一个灰色区域O(n)
和n
大致相等,很难说没有测试哪个解决方案最好。
如果您拥有它:最好的办法是将m
直接构建为C整数集(set
(already wrapped in pandas)或者甚至一些c ++ - 实现),从而消除了预处理的需要。我不知道,大熊猫是否有可以重复使用的东西,但在Cython中编写函数可能不是什么大问题。
问题在于,最后的建议并不是开箱即用的,因为在他们的界面中,熊猫和numpy都没有一套概念(至少对我有限的知识)。但是拥有原始C-set接口将是两全其美的:
我编写了一个快速而又脏的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创建时间):
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)
导致以下调用图:
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