Ответ 1
Это может быть не очевидно, но pd.Series.isin
использует O(1)
-look вверх.
После анализа, который доказывает вышеприведенное утверждение, мы будем использовать его идеи для создания прототипа Cython, который может легко побить самое быстрое решение из коробки.
Предположим, что "множество" имеет n
элементов, а "серия" имеет m
элементов. Время работы:
T(n,m)=T_preprocess(n)+m*T_lookup(n)
Для версии pure-python это означает:
-
T_preprocess(n)=0
- не требуется предварительная обработка -
T_lookup(n)=O(1)
- хорошо известное поведение набора python - приводит к
T(n,m)=O(m)
Что происходит с pd.Series.isin(x_arr)
? Очевидно, что если мы пропустим предварительную обработку и поиск в линейном времени, мы получим O(n*m)
, что неприемлемо.
Легко видеть с помощью отладчика или профилировщика (я использовал valgrind-callgrind + kcachegrind), что происходит: рабочей лошадкой является функция __pyx_pw_6pandas_5_libs_9hashtable_23ismember_int64
. Его определение можно найти здесь:
- На этапе предварительной обработки хэш-карта (pandas использует khash из klib) создается из
n
элементов изx_arr
, то есть во время работыO(n)
. -
m
наблюдений происходят вO(1)
каждый илиO(m)
в общей сложности в построенной хэш-карте. - приводит к
T(n,m)=O(m)+O(n)
Мы должны помнить, что элементы массива numpy- представляют собой raw-C-целые числа, а не объекты Python в исходном наборе, поэтому мы не можем использовать набор как есть.
Альтернативой преобразованию множества объектов Python в набор C-ints было бы преобразование одиночных C-ints в объект Python и, таким образом, возможность использовать исходный набор. Это то, что происходит в [i in x_set for я in ser.values]
-variant:
- Нет предварительной обработки.
- м просмотры происходят в
O(1)
раз каждый илиO(m)
, но поиск медленнее из-за необходимости создания объекта Python. - приводит к
T(n,m)=O(m)
Понятно, что вы можете немного ускорить эту версию, используя Cython.
Но достаточно теории, позвольте взглянуть на время работы для разных n
с фиксированными m
s:
Мы можем видеть: линейное время предварительной обработки доминирует над версией numpy- для больших n
s. Версия с преобразованием из numpy в pure-python (numpy->python
) имеет такое же постоянное поведение, как и версия pure-python, но медленнее, из-за необходимого преобразования - это все в соответствии с нашим анализом.
Это не может быть хорошо видно на схеме: если n < m
NumPy версия становится быстрее - в этом случае быстрее просмотровых из khash
-lib играет самую важную роль, а не первичная обработка частей.
Мои выводы из этого анализа:
-
n < m
:pd.Series.isin
следует взять, потому чтоO(n)
-preprocessing не так дорого. -
n > m
: (возможно, cythonized version of)[i in x_set for я in ser.values]
, и, следовательно,O(n)
избегается. -
очевидно, есть серая зона, где
n
иm
приблизительно равны, и трудно сказать, какое решение лучше всего без тестирования. -
Если у вас есть это под вашим контролем: лучше всего построить
set
непосредственно как набор C-целого (khash
(уже завернутый в панды) или, возможно, даже некоторые c++ -выполнения), тем самым устраняя необходимость предварительной обработки, Я не знаю, есть ли что-то в пандах, которые вы могли бы повторно использовать, но, вероятно, не очень важно написать функцию в Cython.
Проблема в том, что последнее предложение не работает из коробки, так как ни у pandas, ни numpy нет понятия набора (по крайней мере, для моих ограниченных знаний) в их интерфейсах. Но наличие raw-C-set-интерфейсов было бы лучшим из обоих миров:
- не требуется предварительная обработка, поскольку значения уже переданы как набор
- нет необходимости в преобразовании, потому что прошедший набор состоит из значений raw-C
Я закодировал быструю и грязную Cython-обертку для хаша (вдохновленную оболочкой в пандах), которую можно установить через pip install https://github.com/realead/cykhash/zipball/master
а затем использовать с Cython для более быстрая версия isin
:
%%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++ unordered_map
можно обернуть (см. Список C), что имеет недостаток в необходимости использования c++ -lib и, как мы увидим, немного медленнее.
Сравнение подходов (см. Список D для создания таймингов):
khash примерно на 20 раз быстрее, чем numpy->python
, о факторе 6 быстрее, чем чистый python (но чистый-питон - это не то, что мы хотим) и даже около фактора 3 быстрее, чем версия cpp.
Объявления
1) профилирование с valgrind:
#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)
и сейчас:
>>> valgrind --tool=callgrind python isin.py
>>> kcachegrind
приводит к следующему графику вызовов:
B: код ipython для создания времени работы:
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()
C: cpp-wrapper:
%%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
D: отображение результатов с помощью различных наборов-оберток:
import numpy as np
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt
from cykhash import Int64Set
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))
cpp_set=HashSet()
khash_set=Int64Set()
for i in x_set:
cpp_set.add(i)
khash_set.add(i)
assert((ser.isin(x_arr).values==isin_cpp(ser.values, cpp_set)).all())
assert((ser.isin(x_arr).values==isin_khash(ser.values, khash_set)).all())
t1=%timeit -o isin_khash(ser.values, khash_set)
t2=%timeit -o isin_cpp(ser.values, cpp_set)
t3=%timeit -o [i in x_set for i in lst]
t4=%timeit -o [i in x_set for i in ser.values]
result.append([n, t1.average, t2.average, t3.average, t4.average])
n*=2
#ploting result:
for_plot=np.array(result)
plt.plot(for_plot[:,0], for_plot[:,1], label='khash')
plt.plot(for_plot[:,0], for_plot[:,2], label='cpp')
plt.plot(for_plot[:,0], for_plot[:,3], label='pure python')
plt.plot(for_plot[:,0], for_plot[:,4], label='numpy->python')
plt.xlabel('n')
plt.ylabel('running time')
ymin, ymax = plt.ylim()
plt.ylim(0,ymax)
plt.legend()
plt.show()