web-dev-qa-db-fra.com

Pandas pd.Series.isin performance avec set versus array

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:

  • Si vous savez que vos éléments de 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.
  • L'utilisation des listes de compréhension est le seul moyen de bénéficier de la recherche d'ensemble O(1).

Mes questions sont:

  1. Mon analyse ci-dessus est-elle correcte? Cela semble être un résultat évident, mais non documenté, de la façon dont pd.Series.isin A été implémenté.
  2. Existe-t-il une solution de contournement, sans utiliser de compréhension de liste ou 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'
24
jpp

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écessaire
  • T_lookup(n)=O(1) - comportement bien connu de l'ensemble de python
  • résulte en T(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 :

  • Dans une étape de prétraitement, une carte de hachage (pandas utilise khash de klib ) est créée à partir des éléments n de x_arr, C'est-à-dire au moment de l'exécution O(n).
  • Les recherches m se produisent dans O(1) chacune ou O(m) au total dans la table de hachage construite.
  • résulte en 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:

  • Pas de prétraitement.
  • m les recherches se produisent dans 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.
  • résulte en 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:

enter image description here

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:

  • aucun prétraitement nécessaire car les valeurs sont déjà passées sous forme d'ensemble
  • aucune conversion n'est nécessaire car l'ensemble transmis se compose de valeurs C brutes

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):

enter image description here

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:

enter image description here

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()
32
ead