32 votes

Méthodes Pandas mask / where versus NumPy np.where

J'utilise souvent Pandas mask y where des méthodes pour une logique plus propre lors de la mise à jour conditionnelle des valeurs d'une série. Cependant, pour un code dont les performances sont relativement critiques, je remarque une baisse significative des performances par rapport à la méthode numpy.where .

Bien que je sois heureux d'accepter cela pour des cas spécifiques, je suis curieux de savoir :

  1. Faire des pandas mask / where offrent des fonctionnalités supplémentaires, en dehors de inplace / errors / try-cast paramètres ? Je comprends ces trois paramètres, mais je les utilise rarement. Par exemple, je n'ai aucune idée de ce qu'est le paramètre level se réfère au paramètre.
  2. Existe-t-il un contre-exemple non trivial dans lequel mask / where surpasse numpy.where ? Si un tel exemple existe, il pourrait influencer la façon dont je choisis les méthodes appropriées à l'avenir.

Pour référence, voici un benchmarking sur Pandas 0.19.2 / Python 3.6.0 :

np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

assert (df[0].mask(df[0] > 0.5, 1).values == np.where(df[0] > 0.5, 1, df[0])).all()

%timeit df[0].mask(df[0] > 0.5, 1)       # 145 ms per loop
%timeit np.where(df[0] > 0.5, 1, df[0])  # 113 ms per loop

Les performances semblent diverger plus pour les valeurs non scalaires :

%timeit df[0].mask(df[0] > 0.5, df[0]*2)       # 338 ms per loop
%timeit np.where(df[0] > 0.5, df[0]*2, df[0])  # 153 ms per loop

30voto

ead Points 1051

J'utilise pandas 0.23.3 et Python 3.6, donc je ne vois une réelle différence de temps d'exécution que pour votre deuxième exemple.

Mais examinons une version légèrement différente de votre deuxième exemple (pour obtenir 2*df[0] hors du chemin). Voici notre base de référence sur ma machine :

twice = df[0]*2
mask = df[0] > 0.5
%timeit np.where(mask, twice, df[0])  
# 61.4 ms ± 1.51 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit df[0].mask(mask, twice)
# 143 ms ± 5.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

La version de Numpy est environ 2,3 fois plus rapide que celle de pandas.

Le profilage est un bon moyen d'obtenir une vue d'ensemble lorsque l'on n'est pas très familier avec la base du code : c'est plus rapide que le débogage et moins sujet aux erreurs que d'essayer de comprendre ce qui se passe en lisant simplement le code.

Je suis sous Linux et j'utilise perf . Pour la version de numpy, nous obtenons (pour la liste, voir l'annexe A) :

>>> perf record python np_where.py
>>> perf report

Overhead  Command  Shared Object                                Symbol                              
  68,50%  python   multiarray.cpython-36m-x86_64-linux-gnu.so   [.] PyArray_Where
   8,96%  python   [unknown]                                    [k] 0xffffffff8140290c
   1,57%  python   mtrand.cpython-36m-x86_64-linux-gnu.so       [.] rk_random

Comme on peut le voir, la plus grande partie du temps est consacrée aux activités suivantes PyArray_Where - environ 69%. Le symbole inconnu est une fonction noyau (en fait clear_page ) - Je fonctionne sans les privilèges de la racine, le symbole n'est donc pas résolu.

Et pour les pandas, nous obtenons (voir l'annexe B pour le code) :

>>> perf record python pd_mask.py
>>> perf report

Overhead  Command  Shared Object                                Symbol                                                                                               
  37,12%  python   interpreter.cpython-36m-x86_64-linux-gnu.so  [.] vm_engine_iter_task
  23,36%  python   libc-2.23.so                                 [.] __memmove_ssse3_back
  19,78%  python   [unknown]                                    [k] 0xffffffff8140290c
   3,32%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] DOUBLE_isnan
   1,48%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] BOOL_logical_not

La situation est tout à fait différente :

  • pandas n'utilise pas PyArray_Where sous le capot - le consommateur de temps le plus important est vm_engine_iter_task qui est numexpr-fonctionnalité .
  • il y a un gros travail de copie de mémoire en cours - __memmove_ssse3_back utilise environ 25 % du temps ! Il est probable que certaines fonctions du noyau soient également liées à des accès à la mémoire.

En fait, pandas-0.19 utilisait PyArray_Where sous le capot, pour l'ancienne version, le rapport de perf ressemblerait à ceci :

Overhead  Command        Shared Object                     Symbol                                                                                                     
  32,42%  python         multiarray.so                     [.] PyArray_Where
  30,25%  python         libc-2.23.so                      [.] __memmove_ssse3_back
  21,31%  python         [kernel.kallsyms]                 [k] clear_page
   1,72%  python         [kernel.kallsyms]                 [k] __schedule

Il s'agirait donc d'utiliser np.where sous le capot + quelques frais généraux (tout ce qui précède la copie de données, voir __memmove_ssse3_back ) à l'époque.

Je ne vois aucun scénario dans lequel pandas pourrait devenir plus rapide que numpy dans la version 0.19 de pandas - il ne fait qu'ajouter des frais généraux à la fonctionnalité de numpy. La version 0.23.3 de Pandas est une histoire complètement différente - ici, le module numexpr est utilisé, il est très possible qu'il y ait des scénarios pour lesquels la version de pandas est (au moins légèrement) plus rapide.

Je ne suis pas sûr que cette copie de mémoire soit vraiment nécessaire - on pourrait même parler de bug de performance, mais je n'en sais pas assez pour en être certain.

Nous pourrions aider les pandas à ne pas copier, en supprimant certaines indirections (en passant np.array au lieu de pd.Series ). Par exemple :

%timeit df[0].mask(mask.values > 0.5, twice.values)
# 75.7 ms ± 1.5 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Aujourd'hui, les pandas ne sont plus que 25 % plus lents. La perf dit :

Overhead  Command  Shared Object                                Symbol                                                                                                
  50,81%  python   interpreter.cpython-36m-x86_64-linux-gnu.so  [.] vm_engine_iter_task
  14,12%  python   [unknown]                                    [k] 0xffffffff8140290c
   9,93%  python   libc-2.23.so                                 [.] __memmove_ssse3_back
   4,61%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] DOUBLE_isnan
   2,01%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] BOOL_logical_not

Beaucoup moins de copies de données, mais toujours plus que dans la version de numpy qui est principalement responsable de l'overhead.

Les principaux enseignements que j'en tire :

  • pandas a le potentiel d'être au moins légèrement plus rapide que numpy (parce qu'il est possible d'être plus rapide). Cependant, la gestion quelque peu opaque de la copie de données par pandas rend difficile de prédire quand ce potentiel est éclipsé par la copie de données (inutile).

  • lorsque la performance des where / mask est le goulot d'étranglement, j'utiliserais numba/cython pour améliorer les performances - voir mes tentatives plutôt naïves d'utiliser numba et cython plus bas.


L'idée est de prendre

np.where(df[0] > 0.5, df[0]*2, df[0])

et d'éliminer la nécessité de créer une version temporaire - c'est-à-dire une version de la df[0]*2 .

Comme proposé par @max9111, en utilisant numba :

import numba as nb
@nb.njit
def nb_where(df):
    n = len(df)
    output = np.empty(n, dtype=np.float64)
    for i in range(n):
        if df[i]>0.5:
            output[i] = 2.0*df[i]
        else:
            output[i] = df[i]
    return output

assert(np.where(df[0] > 0.5, twice, df[0])==nb_where(df[0].values)).all()
%timeit np.where(df[0] > 0.5, df[0]*2, df[0])
# 85.1 ms ± 1.61 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit nb_where(df[0].values)
# 17.4 ms ± 673 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Ce qui est environ 5 fois plus rapide que la version de numpy !

Et voici ma tentative, de loin moins réussie, d'améliorer les performances à l'aide de Cython :

%%cython -a
cimport numpy as np
import numpy as np
cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
def cy_where(double[::1] df):
    cdef int i
    cdef int n = len(df)
    cdef np.ndarray[np.float64_t] output = np.empty(n, dtype=np.float64)
    for i in range(n):
        if df[i]>0.5:
            output[i] = 2.0*df[i]
        else:
            output[i] = df[i]
    return output

assert (df[0].mask(df[0] > 0.5, 2*df[0]).values == cy_where(df[0].values)).all()

%timeit cy_where(df[0].values)
# 66.7± 753 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

permet d'accélérer de 25 %. Je ne sais pas exactement pourquoi Cython est si lent que Numba.


Listes :

A : np_where.py :

import pandas as pd
import numpy as np

np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

twice = df[0]*2
for _ in range(50):
      np.where(df[0] > 0.5, twice, df[0])  

B : pd_mask.py :

import pandas as pd
import numpy as np

np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

twice = df[0]*2
mask = df[0] > 0.5
for _ in range(50):
      df[0].mask(mask, twice)

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X