Dans Python en général, l'appartenance à une collection lavable est mieux testée via set
. Nous le savons car l'utilisation du hachage nous donne une complexité de recherche O(1) par rapport à O(n) pour list
ou np.ndarray
.
Dans Pandas, je dois souvent vérifier l'appartenance à de très grandes collections. J'ai supposé que la même chose s'appliquerait, c'est-à-dire que vérifier chaque élément d'une série pour l'appartenance à un set
est plus efficace que d'utiliser list
ou np.ndarray
. Cependant, cela ne semble pas être le cas:
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
Versions utilisées pour les tests:
np.__version__ # '1.14.3'
pd.__version__ # '0.23.0'
sys.version # '3.6.5'
Le code source de pd.Series.isin
, je crois, utilise numpy.in1d
, ce qui signifie vraisemblablement une surcharge importante pour set
pour Conversion np.ndarray
.
Nier le coût de construction des intrants, les implications pour les pandas:
x_list
Ou x_arr
Sont uniques, ne vous embêtez pas à convertir en x_set
. Cela sera coûteux (tests de conversion et d'appartenance) pour une utilisation avec Pandas.Mes questions sont:
pd.Series.isin
A été implémenté.pd.Series.apply
, Qui le fait utilise la recherche d'ensemble O(1)? Ou est-ce un choix de conception inévitable et/ou un corollaire d'avoir NumPy comme colonne vertébrale des Pandas? Mise à jour : Sur une configuration plus ancienne (versions Pandas/NumPy), je vois x_set
Surperformer x_arr
Avec pd.Series.isin
. Donc, une question supplémentaire: quelque chose a-t-il fondamentalement changé de l'ancien au nouveau pour entraîner une dégradation des performances avec 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'
Ce n'est peut-être pas évident, mais pd.Series.isin
Utilise O(1)
- recherche.
Après une analyse, qui prouve la déclaration ci-dessus, nous utiliserons ses connaissances pour créer un prototype Cython qui peut facilement battre la solution prête à l'emploi la plus rapide.
Supposons que le "set" a des éléments n
et la "série" a des éléments m
. Le temps de course est alors:
T(n,m)=T_preprocess(n)+m*T_lookup(n)
Pour la version pure-python, cela signifie:
T_preprocess(n)=0
- aucun prétraitement nécessaireT_lookup(n)=O(1)
- comportement bien connu de l'ensemble de pythonT(n,m)=O(m)
Que se passe-t-il pour pd.Series.isin(x_arr)
? Évidemment, si nous sautons le prétraitement et recherchons en temps linéaire, nous obtiendrons O(n*m)
, ce qui n'est pas acceptable.
Il est facile de voir à l'aide d'un débogueur ou d'un profileur (j'ai utilisé valgrind-callgrind + kcachegrind), ce qui se passe: le cheval de travail est la fonction __pyx_pw_6pandas_5_libs_9hashtable_23ismember_int64
. Sa définition peut être trouvée ici :
n
de x_arr
, C'est-à-dire au moment de l'exécution O(n)
.m
se produisent dans O(1)
chacune ou O(m)
au total dans la table de hachage construite.T(n,m)=O(m)+O(n)
Nous devons nous rappeler - les éléments de numpy-array sont des entiers en C brut et non les objets Python de l'ensemble d'origine - nous ne pouvons donc pas utiliser l'ensemble tel qu'il est.
Une alternative à la conversion de l'ensemble des objets Python en un ensemble de C-ints serait de convertir l'unique C-ints en Python-object et donc de pouvoir utiliser l'ensemble d'origine. C'est ce qui se passe dans [i in x_set for i in ser.values]
- variante:
O(1)
à chaque fois ou O(m)
au total, mais la recherche est plus lente en raison de la création nécessaire d'un objet Python.T(n,m)=O(m)
De toute évidence, vous pouvez accélérer un peu cette version en utilisant Cython.
Mais assez de théorie, jetons un coup d'œil aux temps d'exécution pour différents n
avec des m
fixes:
Nous pouvons voir: le temps linéaire du prétraitement domine la version numpy pour les grands n
s. La version avec conversion de numpy en pure-python (numpy->python
) A le même comportement constant que la version pure-python mais est plus lente, en raison de la conversion nécessaire - tout cela conformément à notre analyse.
Cela ne peut pas être bien vu dans le diagramme: si n < m
La version numpy devient plus rapide - dans ce cas, la recherche plus rapide de khash
- lib joue le rôle le plus important et non la partie de prétraitement .
Mes points à retenir de cette analyse:
n < m
: pd.Series.isin
Doit être pris car O(n)
- le prétraitement n'est pas si coûteux.
n > m
: (Probablement une version cythonisée de) [i in x_set for i in ser.values]
Doit être pris et donc O(n)
évité.
il y a clairement une zone grise où n
et m
sont approximativement égaux et il est difficile de dire quelle solution est la meilleure sans tester.
Si vous l'avez sous votre contrôle: la meilleure chose serait de construire le set
directement en tant qu'ensemble de C (khash
( déjà emballé dans des pandas ) ou peut-être même quelques implémentations c ++), éliminant ainsi le besoin de prétraitement. Je ne sais pas, s'il y a quelque chose dans pandas vous pouvez réutiliser, mais ce n'est probablement pas un gros problème pour écrire la fonction en Cython.
Le problème est que la dernière suggestion ne fonctionne pas, car ni pandas ni numpy n'ont une notion d'ensemble (au moins à ma connaissance limitée) dans leurs interfaces. Mais avoir Les interfaces raw-C-set seraient les meilleures des deux mondes:
J'ai codé un rapide et sale Cython-wrapper pour khash (inspiré par le wrapper dans pandas), qui peut être installé via pip install https://github.com/realead/cykhash/zipball/master
Puis utilisé avec Cython pour un 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
Comme autre possibilité, le unordered_map
Du c ++ peut être encapsulé (voir liste C), ce qui a l'inconvénient d'avoir besoin de bibliothèques c ++ et (comme nous le verrons) est légèrement plus lent.
Comparaison des approches (voir liste D pour la création de timings):
khash est environ 20 fois plus rapide que le numpy->python
, environ 6 fois plus rapide que le pur python (mais pure-python n'est pas ce que nous voulons de toute façon) et même environ 3 fois plus rapide que la version cpp.
Annonces
1) profilage avec 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)
et maintenant:
>>> valgrind --tool=callgrind python isin.py
>>> kcachegrind
conduit au graphe d'appel suivant:
B: code ipython pour produire les temps d'exécution:
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: enveloppe cpp:
%%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: traçage des résultats avec différents décapsuleurs:
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()