apply
, la fonction pratique dont vous n'avez jamais eu besoin
Nous commençons par aborder les questions du PO, une par une.
" Si Apply est si mauvais, alors pourquoi est-ce dans l'API? "
DataFrame.apply
et Series.apply
sont des 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 pandas existants ne peuvent pas faire.
Certaines des choses apply
peuvent faire:
- Exécutez n'importe quelle fonction définie par l'utilisateur sur un DataFrame ou une série
- Appliquer une fonction ligne par ligne (
axis=1
) ou colonne ( axis=0
) sur un DataFrame
- Effectuer l'alignement d'index lors de l'application de la fonction
- Effectuer l'agrégation avec des fonctions définies par l'utilisateur (cependant, nous préférons généralement
agg
ou transform
dans ces cas)
- Effectuer des transformations élémentaires
- Diffusez les résultats agrégés sur les lignes d'origine (voir l'
result_type
argument).
- Acceptez les arguments positionnels / mots-clés à transmettre aux fonctions définies par l'utilisateur.
... Entre autres. Pour plus d'informations, consultez Application de fonction par ligne ou par colonne dans la documentation.
Alors, avec toutes ces fonctionnalités, pourquoi est-ce apply
mauvais? C'est parce que apply
c'est lent . Pandas ne fait aucune hypothèse sur la nature de votre fonction, et donc applique itérativement votre fonction à chaque ligne / colonne si nécessaire. De plus, la gestion de toutes les situations ci-dessus apply
entraîne une surcharge importante à chaque itération. De plus, apply
consomme beaucoup plus de mémoire, ce qui est un défi pour les applications limitées en mémoire.
Il y a très peu de situations où il apply
est approprié d'utiliser (plus de détails ci-dessous). Si vous ne savez pas si vous devriez utiliser apply
, vous ne devriez probablement pas.
Abordons la question suivante.
" Comment et quand dois-je faire en sorte que mon code s'applique gratuitement? "
Pour reformuler, voici quelques situations courantes dans lesquelles vous voudrez vous débarrasser de tout appel à apply
.
Données numériques
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'addition 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 sur les 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' raw
argument, 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.
Chaîne / expression régulière
Pandas fournit des fonctions de chaîne "vectorisées" dans la plupart des situations, mais il y a 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, puisque "donald" et "minnie" sont présents dans leurs colonnes "Titre" respectives.
En utilisant apply, cela serait fait 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 la compréhension de liste.
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 réduction des frais généraux. Si vous avez besoin de gérer des NaN et des dtypes non valides, vous pouvez utiliser une fonction personnalisée que vous pouvez ensuite appeler avec des arguments dans la compréhension de la liste.
Pour plus d'informations sur le moment où la compréhension de liste doit être considérée comme une bonne option, consultez mon article: Pour les boucles avec des pandas - Quand dois-je m'en soucier? .
Remarque Les opérations de
date et d'heure ont également des versions vectorisées. Ainsi, par exemple, vous devriez préférer pd.to_datetime(df['date'])
, par exemple, à df['date'].apply(pd.to_datetime)
.
En savoir plus sur la
documentation .
Un écueil courant: des colonnes de listes qui explosent
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
c'est bon? "
Appliquer est une fonction pratique, il existe donc des situations où la surcharge est suffisamment négligeable pour pardonner. Cela dépend vraiment du nombre d'appels de la fonction.
Fonctions vectorisées pour les séries, mais pas pour 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 les séries uniquement, elles doivent donc être appliquées sur chaque colonne que vous souhaitez convertir / opérer.
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
C'est un cas recevable pour apply
:
df.apply(pd.to_datetime, errors='coerce').dtypes
date1 datetime64[ns]
date2 datetime64[ns]
dtype: object
Notez qu'il serait également judicieux d' stack
utiliser ou simplement d'utiliser une boucle explicite. Toutes ces options sont légèrement plus rapides que l'utilisation 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...
Conversion de séries en str
: astype
versusapply
Cela semble être une idiosyncrasie de l'API. L'utilisation apply
pour convertir des entiers d'une série en chaîne est comparable (et parfois plus rapide) que l'utilisation astype
.
Le graphique a été tracé à l'aide de la perfplot
bibliothèque.
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
c'est aussi une fonction de commodité itérative pour gérer tout ce que les GroupBy
fonctions existantes ne font pas.
Une exigence courante consiste à effectuer un GroupBy, puis deux opérations principales telles qu'un «retard cumulé»:
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 groupby 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 elles dépendent des données. Mais en général, apply
c'est une solution acceptable si le but est de réduire un groupby
appel (car groupby
c'est aussi assez cher).
Autres mises en garde
Outre les mises en garde mentionnées ci-dessus, il convient également de mentionner que apply
fonctionne deux fois sur la première ligne (ou colonne). Ceci est fait pour déterminer si la fonction a des effets secondaires. Sinon, apply
vous pourrez peut-être utiliser un chemin rapide pour évaluer le résultat, sinon cela 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 observé dans GroupBy.apply
les versions sur pandas <0.25 (il a été corrigé pour 0.25, voir ici pour plus d'informations .)
returns.add(1).apply(np.log)
vsnp.log(returns.add(1)
est un cas oùapply
sera généralement légèrement plus rapide, qui est la case verte en bas à droite dans le diagramme de jpp ci-dessous.