web-dev-qa-db-fra.com

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

J'utilise souvent les méthodes Pandas mask et where pour une logique plus claire lors de la mise à jour des valeurs d'une série Cependant, pour le code relativement critique pour les performances, je constate une baisse significative des performances par rapport à numpy.where .

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

  1. Les méthodes Pandas mask/where offrent-elles des fonctionnalités supplémentaires, à part inplace/errors/try-cast paramètres? Je comprends ces 3 paramètres mais les utilise rarement. Par exemple, je n'ai aucune idée de ce à quoi fait référence le paramètre level.
  2. Y a-t-il un contre-exemple non trivial où mask/where surpasse numpy.where? Si un tel exemple existe, il pourrait influencer la façon dont je choisirai les méthodes appropriées pour aller de l'avant.

Pour référence, voici quelques analyses comparatives 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 davantage 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
27
jpp

J'utilise pandas 0.23.3 et Python 3.6, donc je peux voir une vraie différence de temps d'exécution uniquement pour votre deuxième exemple).

Mais examinons une version légèrement différente de votre deuxième exemple (afin que nous éliminions 2*df[0]). Voici notre 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 les pandas.

Alors profilons les deux fonctions pour voir la différence - le profilage est un bon moyen d'avoir une vue d'ensemble quand 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 juste en lisant 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 nous pouvons le voir, la part du lion du temps est consacrée à PyArray_Where - environ 69%. Le symbole inconnu est une fonction du noyau (en fait clear_page) - Je cours sans privilèges root donc le symbole n'est pas résolu.

Et pour 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

Une situation assez 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 fonctionnalité numexpr .
  • il y a une copie de mémoire en cours - __memmove_ssse3_back utilise environ 25% du temps! Probablement, certaines des fonctions du noyau sont également connecté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 performance ressemblerait à:

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

Donc, fondamentalement, il utiliserait np.where Sous le capot + quelques frais généraux (tous ci-dessus la copie de données, voir __memmove_ssse3_back) À l'époque.

Je ne vois aucun scénario où pandas pourrait devenir plus rapide que numpy dans la version 0.19 de pandas - cela ne fait qu'ajouter des frais généraux à la fonctionnalité de numpy. La version 0.23.3 de Pandas est une toute autre histoire - ici module numexpr est utilisé, il est très possible qu'il existe 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 demandée/nécessaire - peut-être pourrait-on même appeler cela un bug de performance, mais je n'en sais pas assez pour en être certain.

Nous pourrions aider pandas à ne pas copier, en retirant quelques 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)

Maintenant, pandas est seulement 25% plus lent. Le 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 copie de données, mais toujours plus que dans la version numphy qui est principalement responsable des frais généraux.

Mes principaux enseignements:

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

  • lorsque les performances de where/mask sont 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 loin.


L'idée est de prendre

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

et pour éliminer la nécessité de créer une version temporaire, c'est-à-dire 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 numpy!

Et voici mon essai beaucoup moins réussi d'améliorer les performances avec 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)

accélère de 25%. Je ne sais pas pourquoi le cython est tellement plus lent que le numba.


Annonces:

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)
22
ead