web-dev-qa-db-fra.com

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

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:

  1. Si apply est si mauvais, alors pourquoi est-il dans l'API?
  2. Comment et quand dois-je libérer mon code apply-?
  3. Y a-t-il des situations où apply est bon (meilleur que d'autres solutions possibles)?
93
cs95

apply, la fonction pratique dont vous n'avez jamais eu besoin

Nous 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:

  • Exécuter n'importe quelle fonction définie par l'utilisateur sur un DataFrame ou une série
  • Appliquer une fonction soit par ligne (axis=1) Soit par colonne (axis=0) Sur un DataFrame
  • Effectuer l'alignement d'index lors de l'application de la fonction
  • Effectuer une 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 par élément
  • Diffusez les résultats agrégés vers les lignes d'origine (voir l'argument result_type).
  • Acceptez les arguments de position/mot-clé pour passer aux fonctions définies par l'utilisateur.

... 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.

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'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.

Chaîne/expression régulière

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érer pd.to_datetime(df['date']), plutôt que df['date'].apply(pd.to_datetime).

En savoir plus sur le docs .

Un piège commun: l'explosion des colonnes de listes

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...

Conversion de la série en 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.

enter image description here 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).


Autres mises en garde

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 .))

87
cs95

Les apply ne se ressemblent pas tous

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

enter image description here

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 mixte

pd.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:

  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 n'est pas bien mis à l'échelle avec des lignes par rapport à une boucle for régulière.
35
jpp

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)
4
Pete Cacioppi

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.

3
astro123