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)