Quand devrais-je vouloir utiliser pandas apply () dans mon code?

111

J'ai vu de nombreuses réponses postées à 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 " applyc'est lent et devrait être évité".

J'ai lu de nombreux articles sur le thème de la performance qui expliquent la applylenteur. J'ai également vu une clause de non-responsabilité dans la documentation sur la façon dont applyest simplement une fonction pratique pour passer des UDF ( je ne peux pas le trouver maintenant). Donc, le consensus général est que cela applydevrait être évité si possible. Cependant, cela soulève les questions suivantes:

  1. Si applyc'est si mauvais, alors pourquoi est-ce dans l'API?
  2. Comment et quand dois-je rendre mon code sans code apply?
  3. Y a-t-il déjà des situations où applyc'est bon (mieux que d'autres solutions possibles)?
cs95
la source
1
returns.add(1).apply(np.log)vs np.log(returns.add(1)est un cas où applysera généralement légèrement plus rapide, qui est la case verte en bas à droite dans le diagramme de jpp ci-dessous.
Alexander
@Alexander merci. N'a pas souligné de manière exhaustive ces situations, mais il est utile de les connaître!
cs95

Réponses:

108

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.applyet Series.applysont des fonctions de commodité définies respectivement sur les objets DataFrame et Series. applyaccepte toute fonction définie par l'utilisateur qui applique une transformation / agrégation sur un DataFrame. applyest en fait une solution miracle qui fait tout ce que les pandas existants ne peuvent pas faire.

Certaines des choses applypeuvent 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 aggou transformdans ces cas)
  • Effectuer des transformations élémentaires
  • Diffusez les résultats agrégés sur les lignes d'origine (voir l' result_typeargument).
  • 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 applymauvais? C'est parce que applyc'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 applyentraîne une surcharge importante à chaque itération. De plus, applyconsomme 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 applyest 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 applypour 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' rawargument, 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' stackutiliser 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: astypeversusapply

Cela semble être une idiosyncrasie de l'API. L'utilisation applypour convertir des entiers d'une série en chaîne est comparable (et parfois plus rapide) que l'utilisation astype.

entrez la description de l'image ici Le graphique a été tracé à l'aide de la perfplotbibliothè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 astypeest 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.applyn'a pas été discuté jusqu'à présent, mais GroupBy.applyc'est aussi une fonction de commodité itérative pour gérer tout ce que les GroupByfonctions 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, applyc'est une solution acceptable si le but est de réduire un groupbyappel (car groupbyc'est aussi assez cher).


Autres mises en garde

Outre les mises en garde mentionnées ci-dessus, il convient également de mentionner que applyfonctionne deux fois sur la première ligne (ou colonne). Ceci est fait pour déterminer si la fonction a des effets secondaires. Sinon, applyvous 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.applyles versions sur pandas <0.25 (il a été corrigé pour 0.25, voir ici pour plus d'informations .)

cs95
la source
Je pense que nous devons faire attention .. avec %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')sûrement après la première itération, ce sera beaucoup plus rapide puisque vous vous convertissez datetimeen ... datetime?
jpp
@jpp J'avais le même souci. Mais vous devez toujours effectuer une analyse linéaire de toute façon, appeler to_datetime sur des chaînes est aussi rapide que les appeler sur des objets datetime, sinon plus rapide. Les horaires approximatifs sont les mêmes. L'alternative serait de mettre en œuvre une étape de pré-copie pour chaque solution chronométrée qui enlève le point principal. Mais c'est une préoccupation valable.
cs95
"L'appel to_datetimesur des chaînes est aussi rapide que sur ... des datetimeobjets" .. vraiment? J'ai inclus la création de dataframe (coût fixe) dans les timings applyvs forloop et la différence est beaucoup plus petite.
jpp
@jpp Eh bien, c'est ce que j'ai obtenu de mes tests (certes limités). Je suis sûr que cela dépend des données, mais l'idée générale est qu'à des fins d'illustration, la différence est "sérieusement, ne vous en faites pas".
cs95
1
@ cs95, Bonne année!
jpp
49

Tous ne applyse ressemblent pas

Le tableau ci-dessous indique quand considérer apply1 . Le vert signifie peut-être efficace; rouge éviter.

entrez la description de l'image ici

Une partie de cela est intuitive: il pd.Series.applys'agit d'une boucle de ligne par ligne au niveau de Python, idem pd.DataFrame.applyrow-wise ( axis=1). Les abus de ceux-ci sont nombreux et variés. L'autre article les traite plus en profondeur. Les solutions populaires consistent à utiliser des méthodes vectorisées, des compréhensions de listes (suppose des données propres) ou des outils efficaces comme le pd.DataFrameconstructeur (par exemple à éviter apply(pd.Series)).

Si vous utilisez par pd.DataFrame.applyligne, la spécification raw=True(si possible) est souvent bénéfique. À ce stade, numbaest généralement un meilleur choix.

GroupBy.apply: généralement favorisé

La répétition des groupbyopérations à éviter applynuira aux performances. GroupBy.applyc'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 native pour une agrégation de groupe que vous souhaitez appliquer. Dans ce cas, pour un petit nombre de groupes applyavec une fonction personnalisée peut encore offrir des performances raisonnables.

pd.DataFrame.apply en colonne: un sac mélangé

pd.DataFrame.applycolumn -wise ( axis=0) est un cas intéressant. Pour un petit nombre de lignes par rapport à un grand nombre de colonnes, c'est presque toujours cher. Pour un grand nombre de lignes par rapport aux colonnes, cas le plus courant, vous pouvez parfois constater des améliorations significatives des performances 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:

  1. df['col'].apply(str)peut légèrement surpasser df['col'].astype(str).
  2. df.apply(pd.to_datetime)travailler sur des chaînes ne s'adapte pas bien aux lignes par rapport à une forboucle normale .
jpp
la source
2
Merci de votre participation, appréciez les perspectives multiples :) +1
cs95
1
@coldspeed, Merci, il n'y a rien de mal avec votre message (à part quelques comparaisons contradictoires avec le mien, mais cela pourrait être basé sur une entrée ou une configuration). Je pensais juste qu'il y avait une manière différente de regarder le problème.
jpp
@jpp J'ai toujours utilisé votre excellent organigramme comme guide jusqu'à ce que je sache aujourd'hui qu'une ligneapply par ligne est beaucoup plus rapide que ma solution avec any. Des pensées à ce sujet?
Stef
1
@jpp: vous avez raison: pour 1 million de lignes x 100 cols, anyc'est environ 100 fois plus rapide que apply. Il a fait mes premiers tests avec 2000 lignes x 1000 cols et ici applyc'était deux fois plus rapide queany
Stef
1
@jpp Je voudrais utiliser votre image dans une présentation / article. Es-tu d'accord avec ça? Je mentionnerai évidemment la source. Merci
Erfan
3

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 pandascomportement. (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)
Pete Cacioppi
la source
J'ai été très surpris de constater que cela m'a donné de meilleures performances dans certains cas. C'était particulièrement utile lorsque je devais faire plusieurs choses, chacune avec un sous-ensemble différent de valeurs de colonne. La réponse "Toutes les demandes ne sont pas identiques" peut vous aider à déterminer quand cela est susceptible d'aider, mais il n'est pas très difficile de tester sur un échantillon de vos données.
denson
Quelques conseils: pour les performances, une compréhension de liste surpasserait la boucle for; zip(df, row[1:])suffit ici; vraiment, à ce stade, considérez numbasi func est un calcul numérique. Voir cette réponse pour une explication.
jpp
@jpp - si vous avez une meilleure fonction, veuillez partager. Je pense que c'est assez proche de l'optimum d'après mon analyse. Oui, numbac'est plus rapide, faster_df_applyest destiné aux personnes qui veulent juste quelque chose d'équivalent, mais plus rapide, DataFrame.apply(qui est étrangement lent).
Pete Cacioppi
2

Y a-t-il déjà des situations où applyc'est bien? 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, je pensais juste que puisque le NumPyne peut pas faire face à la situation ci-dessus, cela 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.

astro123
la source
Et bien non. En quoi est-ce mieux que [unidecode.unidecode(x) for x in s]ou list(map(unidecode.unidecode, s))?
jpp
1
Comme c'était déjà une série de pandas, j'ai été tenté d'utiliser apply, oui vous avez raison, il vaut mieux utiliser list-comp que postuler, mais le vote négatif était un peu dur, je ne préconisais pas apply, je pensais juste que cela aurait pu être un bon cas d'utilisation.
astro123