J'ai vu de nombreuses réponses à des questions sur Stack Overflow impliquant l'utilisation de la méthode Pandas apply
. J'ai également vu des utilisateurs commenter sous eux en disant que "apply
est lent et doit être évité ".
J'ai lu de nombreux articles sur le thème des performances qui expliquent que apply
est lent. J'ai également vu une clause de non-responsabilité dans les documents sur la façon dont apply
est simplement une fonction pratique pour passer des FDU (ne semble pas le trouver maintenant). Ainsi, le consensus général est que apply
doit être évité si possible. Cependant, cela soulève les questions suivantes:
apply
est si mauvais, alors pourquoi est-il dans l'API?apply
-?apply
est bon (meilleur que d'autres solutions possibles)?apply
, la fonction pratique dont vous n'avez jamais eu besoinNous commençons par aborder les questions du PO, une par une.
" Si appliquer est si mauvais, alors pourquoi est-il dans l'API?"
DataFrame.apply
et Series.apply
sont fonctions de commodité définies respectivement sur les objets DataFrame et Series. apply
accepte toute fonction définie par l'utilisateur qui applique une transformation/agrégation sur un DataFrame. apply
est en fait une solution miracle qui fait tout ce que les fonctions pandas ne peuvent pas faire.
Certaines des choses que apply
peut faire:
axis=1
) Soit par colonne (axis=0
) Sur un DataFrameagg
ou transform
dans ces cas)result_type
).... Entre autres. Pour plus d'informations, voir Application de fonction ligne ou colonne dans la documentation.
Donc, avec toutes ces fonctionnalités, pourquoi apply
est-il mauvais? C'est parce que apply
est lent . Pandas ne fait aucune hypothèse sur la nature de votre fonction, et donc applique itérativement votre fonction à chaque ligne/colonne comme De plus, la gestion de all des situations ci-dessus signifie que apply
entraîne une surcharge importante à chaque itération. De plus, apply
consomme beaucoup plus de mémoire, ce qui représente un défi pour les applications limitées en mémoire.
Il y a très peu de situations où apply
est approprié à utiliser (plus de détails ci-dessous). Si vous ne savez pas si vous devez utiliser apply
, vous ne devriez probablement pas.
Répondons à la question suivante.
" Comment et quand dois-je créer mon code appliquer gratuit?"
Pour reformuler, voici quelques situations courantes où vous voudrez vous débarrasser de tous les appels à apply
.
Si vous travaillez avec des données numériques, il existe probablement déjà une fonction cython vectorisée qui fait exactement ce que vous essayez de faire (sinon, posez une question sur Stack Overflow ou ouvrez une demande de fonctionnalité sur GitHub).
Comparez les performances de apply
pour une opération d'ajout simple.
df = pd.DataFrame({"A": [9, 4, 2, 1], "B": [12, 7, 5, 4]})
df
A B
0 9 12
1 4 7
2 2 5
3 1 4
df.apply(np.sum)
A 16
B 28
dtype: int64
df.sum()
A 16
B 28
dtype: int64
En termes de performances, il n'y a pas de comparaison, l'équivalent cythonisé est beaucoup plus rapide. Il n'y a pas besoin de graphique, car la différence est évidente même pour les données de jouets.
%timeit df.apply(np.sum)
%timeit df.sum()
2.22 ms ± 41.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
471 µs ± 8.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
Même si vous activez le passage de tableaux bruts avec l'argument raw
, c'est toujours deux fois plus lent.
%timeit df.apply(np.sum, raw=True)
840 µs ± 691 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Un autre exemple:
df.apply(lambda x: x.max() - x.min())
A 8
B 8
dtype: int64
df.max() - df.min()
A 8
B 8
dtype: int64
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.max() - df.min()
2.43 ms ± 450 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
1.23 ms ± 14.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
En général, recherchez des alternatives vectorisées si possible.
Pandas fournit des fonctions de chaîne "vectorisées" dans la plupart des situations, mais il existe de rares cas où ces fonctions ne ... "s'appliquent", pour ainsi dire.
Un problème courant consiste à vérifier si une valeur dans une colonne est présente dans une autre colonne de la même ligne.
df = pd.DataFrame({
'Name': ['mickey', 'donald', 'minnie'],
'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
'Value': [20, 10, 86]})
df
Name Value Title
0 mickey 20 wonderland
1 donald 10 welcome to donald's castle
2 minnie 86 Minnie mouse clubhouse
Cela devrait renvoyer la deuxième et la troisième ligne, car "donald" et "minnie" sont présents dans leurs colonnes "Title" respectives.
En utilisant appliquer, cela se ferait en utilisant
df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)
0 False
1 True
2 True
dtype: bool
df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
Name Title Value
1 donald welcome to donald's castle 10
2 minnie Minnie mouse clubhouse 86
Cependant, une meilleure solution existe en utilisant des listes de compréhension.
df[[y.lower() in x.lower() for x, y in Zip(df['Title'], df['Name'])]]
Name Title Value
1 donald welcome to donald's castle 10
2 minnie Minnie mouse clubhouse 86
%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
%timeit df[[y.lower() in x.lower() for x, y in Zip(df['Title'], df['Name'])]]
2.85 ms ± 38.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
788 µs ± 16.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
La chose à noter ici est que les routines itératives sont plus rapides que apply
, en raison de la surcharge réduite. Si vous avez besoin de gérer des NaN et des dtypes invalides, vous pouvez vous en inspirer en utilisant une fonction personnalisée que vous pouvez ensuite appeler avec des arguments dans la liste de compréhension.
Pour plus d'informations sur le moment où la compréhension des listes doit être considérée comme une bonne option, consultez mon article: Pour les boucles avec pandas - Quand dois-je m'en soucier? .
Remarque
Les opérations de date et de date/heure ont également des versions vectorisées. Ainsi, par exemple, vous devriez préférerpd.to_datetime(df['date'])
, plutôt quedf['date'].apply(pd.to_datetime)
.En savoir plus sur le docs .
s = pd.Series([[1, 2]] * 3)
s
0 [1, 2]
1 [1, 2]
2 [1, 2]
dtype: object
Les gens sont tentés d'utiliser apply(pd.Series)
. C'est horrible en termes de performances.
s.apply(pd.Series)
0 1
0 1 2
1 1 2
2 1 2
Une meilleure option consiste à lister la colonne et à la transmettre à pd.DataFrame.
pd.DataFrame(s.tolist())
0 1
0 1 2
1 1 2
2 1 2
%timeit s.apply(pd.Series)
%timeit pd.DataFrame(s.tolist())
2.65 ms ± 294 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
816 µs ± 40.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
Enfin,
" Y a-t-il des situations où
apply
est bon?"
Appliquer est une fonction pratique, donc il y a sont situations où la surcharge est assez négligeable pour pardonner. Cela dépend vraiment du nombre d'appels de la fonction.
Fonctions vectorisées pour la série, mais pas les DataFrames
Que faire si vous souhaitez appliquer une opération de chaîne sur plusieurs colonnes? Que faire si vous souhaitez convertir plusieurs colonnes en datetime? Ces fonctions sont vectorisées pour la série uniquement, elles doivent donc être appliquées sur chaque colonne que vous souhaitez convertir/utiliser.
df = pd.DataFrame(
pd.date_range('2018-12-31','2019-01-31', freq='2D').date.astype(str).reshape(-1, 2),
columns=['date1', 'date2'])
df
date1 date2
0 2018-12-31 2019-01-02
1 2019-01-04 2019-01-06
2 2019-01-08 2019-01-10
3 2019-01-12 2019-01-14
4 2019-01-16 2019-01-18
5 2019-01-20 2019-01-22
6 2019-01-24 2019-01-26
7 2019-01-28 2019-01-30
df.dtypes
date1 object
date2 object
dtype: object
Il s'agit d'un cas admissible pour apply
:
df.apply(pd.to_datetime, errors='coerce').dtypes
date1 datetime64[ns]
date2 datetime64[ns]
dtype: object
Notez qu'il serait également judicieux de stack
, ou simplement d'utiliser une boucle explicite. Toutes ces options sont légèrement plus rapides que l'utilisation de apply
, mais la différence est suffisamment petite pour pardonner.
%timeit df.apply(pd.to_datetime, errors='coerce')
%timeit pd.to_datetime(df.stack(), errors='coerce').unstack()
%timeit pd.concat([pd.to_datetime(df[c], errors='coerce') for c in df], axis=1)
%timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')
5.49 ms ± 247 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.94 ms ± 48.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.16 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.41 ms ± 1.71 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Vous pouvez faire un cas similaire pour d'autres opérations telles que les opérations de chaîne ou la conversion en catégorie.
u = df.apply(lambda x: x.str.contains(...))
v = df.apply(lambda x: x.astype(category))
contre
u = pd.concat([df[c].str.contains(...) for c in df], axis=1)
v = df.copy()
for c in df:
v[c] = df[c].astype(category)
Etc...
str
: astype
contre apply
Cela ressemble à une idiosyncrasie de l'API. L'utilisation de apply
pour convertir des entiers dans une série en chaîne est comparable (et parfois plus rapide) que l'utilisation de astype
.
Le graphique a été tracé à l'aide de la bibliothèque perfplot
.
import perfplot
perfplot.show(
setup=lambda n: pd.Series(np.random.randint(0, n, n)),
kernels=[
lambda s: s.astype(str),
lambda s: s.apply(str)
],
labels=['astype', 'apply'],
n_range=[2**k for k in range(1, 20)],
xlabel='N',
logx=True,
logy=True,
equality_check=lambda x, y: (x == y).all())
Avec les flotteurs, je vois que le astype
est toujours aussi rapide ou légèrement plus rapide que apply
. Cela a donc à voir avec le fait que les données du test sont de type entier.
GroupBy
opérations avec transformations chaînées GroupBy.apply
N'a pas été discuté jusqu'à présent, mais GroupBy.apply
Est également une fonction de commodité itérative pour gérer tout ce que les fonctions GroupBy
existantes ne font pas.
Une exigence courante consiste à effectuer un GroupBy puis deux opérations principales telles qu'un "cumsum décalé":
df = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df
A B
0 a 12
1 a 7
2 b 5
3 c 4
4 c 5
5 c 4
6 d 3
7 d 2
8 e 1
9 e 10
Vous auriez besoin de deux appels de groupe successifs ici:
df.groupby('A').B.cumsum().groupby(df.A).shift()
0 NaN
1 12.0
2 NaN
3 NaN
4 4.0
5 9.0
6 NaN
7 3.0
8 NaN
9 1.0
Name: B, dtype: float64
En utilisant apply
, vous pouvez raccourcir cela en un seul appel.
df.groupby('A').B.apply(lambda x: x.cumsum().shift())
0 NaN
1 12.0
2 NaN
3 NaN
4 4.0
5 9.0
6 NaN
7 3.0
8 NaN
9 1.0
Name: B, dtype: float64
Il est très difficile de quantifier les performances car cela dépend des données. Mais en général, apply
est une solution acceptable si l'objectif est de réduire un appel à groupby
(car groupby
est également assez cher).
Outre les mises en garde mentionnées ci-dessus, il convient également de mentionner que apply
opère deux fois sur la première ligne (ou colonne). Ceci est fait pour déterminer si la fonction a des effets secondaires. Sinon, apply
peut être en mesure d'utiliser un raccourci pour évaluer le résultat, sinon il revient à une implémentation lente.
df = pd.DataFrame({
'A': [1, 2],
'B': ['x', 'y']
})
def func(x):
print(x['A'])
return x
df.apply(func, axis=1)
# 1
# 1
# 2
A B
0 1 x
1 2 y
Ce comportement est également visible dans GroupBy.apply
Sur pandas versions <0,25 (il a été corrigé pour 0,25, voir ici pour plus d'informations .))
apply
ne se ressemblent pas tousLe tableau ci-dessous suggère quand considérer apply
1. Le vert signifie peut-être efficace; rouge à éviter.
Une partie de ceci est intuitive: pd.Series.apply
Est une boucle en ligne au niveau Python, idem pd.DataFrame.apply
En ligne (axis=1
). Les abus de ceux-ci sont nombreux et variés. L'autre article les traite plus en profondeur. Les solutions les plus courantes consistent à utiliser des méthodes vectorisées, des compréhensions de listes (en supposant des données propres) ou des outils efficaces tels que le constructeur pd.DataFrame
(Par exemple pour éviter apply(pd.Series)
).
Si vous utilisez pd.DataFrame.apply
Par ligne, il est souvent utile de spécifier raw=True
(Si possible). À ce stade, numba
est généralement un meilleur choix.
GroupBy.apply
: Généralement préféréLa répétition des opérations groupby
pour éviter apply
nuira aux performances. GroupBy.apply
Est généralement bien ici, à condition que les méthodes que vous utilisez dans votre fonction personnalisée soient elles-mêmes vectorisées. Parfois, il n'y a pas de méthode Pandas pour une agrégation groupée que vous souhaitez appliquer. Dans ce cas, pour un petit nombre de groupes, apply
avec une fonction personnalisée peut toujours offrir des performances raisonnables .
pd.DataFrame.apply
Colonne par colonne: un sac mixtepd.DataFrame.apply
Colonne par colonne (axis=0
) Est un cas intéressant. Pour un petit nombre de lignes contre un grand nombre de colonnes, c'est presque toujours cher. Pour un grand nombre de lignes par rapport aux colonnes, le cas le plus courant, vous pouvez parfois voir des améliorations de performances significatives en utilisant apply
:
# Python 3.7, Pandas 0.23.4
np.random.seed(0)
df = pd.DataFrame(np.random.random((10**7, 3))) # Scenario_1, many rows
df = pd.DataFrame(np.random.random((10**4, 10**3))) # Scenario_2, many columns
# Scenario_1 | Scenario_2
%timeit df.sum() # 800 ms | 109 ms
%timeit df.apply(pd.Series.sum) # 568 ms | 325 ms
%timeit df.max() - df.min() # 1.63 s | 314 ms
%timeit df.apply(lambda x: x.max() - x.min()) # 838 ms | 473 ms
%timeit df.mean() # 108 ms | 94.4 ms
%timeit df.apply(pd.Series.mean) # 276 ms | 233 ms
1 Il existe des exceptions, mais celles-ci sont généralement marginales ou rares. Quelques exemples:
df['col'].apply(str)
peut légèrement surpasser df['col'].astype(str)
.df.apply(pd.to_datetime)
travailler sur des chaînes n'est pas bien mis à l'échelle avec des lignes par rapport à une boucle for
régulière.Pour axis=1
(c'est-à-dire les fonctions par ligne), vous pouvez simplement utiliser la fonction suivante à la place de apply
. Je me demande pourquoi ce n'est pas le comportement pandas
. (Non testé avec des index composés, mais il semble être beaucoup plus rapide que apply
)
def faster_df_apply(df, func):
cols = list(df.columns)
data, index = [], []
for row in df.itertuples(index=True):
row_dict = {f:v for f,v in Zip(cols, row[1:])}
data.append(func(row_dict))
index.append(row[0])
return pd.Series(data, index=index)
Y a-t-il jamais des situations où apply
est bon? Oui, parfois.
Tâche: décoder les chaînes Unicode.
import numpy as np
import pandas as pd
import unidecode
s = pd.Series(['mañana','Ceñía'])
s.head()
0 mañana
1 Ceñía
s.apply(unidecode.unidecode)
0 manana
1 Cenia
Mise à jour
Je ne préconisais en aucun cas l'utilisation de apply
, pensant simplement que la NumPy
ne peut pas faire face à la situation ci-dessus, elle aurait pu être un bon candidat pour pandas apply
. Mais j'oubliais la simple compréhension de la liste grâce au rappel de @jpp.