Les boucles for des pandas sont-elles vraiment mauvaises? Quand devrais-je m'en soucier?

106

Les forboucles sont-elles vraiment «mauvaises»? Sinon, dans quelle (s) situation (s) seraient-ils mieux que d'utiliser une approche «vectorisée» plus conventionnelle? 1

Je connais le concept de "vectorisation" et la manière dont les pandas utilisent des techniques vectorisées pour accélérer le calcul. Les fonctions vectorisées diffusent les opérations sur l'ensemble de la série ou du DataFrame pour obtenir des accélérations bien plus importantes que l'itération conventionnelle sur les données.

Cependant, je suis assez surpris de voir beaucoup de code (y compris des réponses sur Stack Overflow) offrant des solutions aux problèmes qui impliquent de boucler des données en utilisant des forboucles et des compréhensions de listes. La documentation et l'API disent que les boucles sont «mauvaises» et qu'il ne faut «jamais» parcourir les tableaux, les séries ou les DataFrames. Alors, comment se fait-il que je vois parfois des utilisateurs suggérer des solutions basées sur des boucles?


1 - S'il est vrai que la question semble assez large, la vérité est qu'il existe des situations très spécifiques où les forboucles sont généralement meilleures que l'itération conventionnelle sur des données. Cet article vise à capturer cela pour la postérité.

cs95
la source

Réponses:

144

TLDR; Non, les forboucles ne sont pas «mauvaises», du moins pas toujours. Il est probablement plus exact de dire que certaines opérations vectorisées sont plus lentes que l'itération , plutôt que de dire que l'itération est plus rapide que certaines opérations vectorisées. Savoir quand et pourquoi est essentiel pour tirer le meilleur parti de votre code. En un mot, voici les situations dans lesquelles il vaut la peine d'envisager une alternative aux fonctions pandas vectorisées:

  1. Lorsque vos données sont petites (... selon ce que vous faites),
  2. Lorsque vous traitez avec object/ des dtypes mixtes
  3. Lors de l'utilisation des strfonctions d'accesseur / regex

Examinons ces situations individuellement.


Itération v / s Vectorisation sur de petites données

Pandas suit une approche «Convention Over Configuration» dans sa conception d'API. Cela signifie que la même API a été adaptée pour répondre à un large éventail de données et de cas d'utilisation.

Lorsqu'une fonction pandas est appelée, les choses suivantes (entre autres) doivent être gérées en interne par la fonction, pour assurer le fonctionnement

  1. Alignement index / axe
  2. Gestion des types de données mixtes
  3. Traitement des données manquantes

Presque toutes les fonctions devront les gérer à des degrés divers, ce qui présente une surcharge . La surcharge est moindre pour les fonctions numériques (par exemple, Series.add), alors qu'elle est plus prononcée pour les fonctions de chaîne (par exemple, Series.str.replace).

forles boucles, en revanche, sont plus rapides que vous ne le pensez. Ce qui est encore mieux, c'est que les compréhensions de listes (qui créent des listes via des forboucles) sont encore plus rapides car elles sont des mécanismes itératifs optimisés pour la création de listes.

Les compréhensions de liste suivent le modèle

[f(x) for x in seq]

Où se seqtrouve une série pandas ou une colonne DataFrame. Ou, lorsque vous travaillez sur plusieurs colonnes,

[f(x, y) for x, y in zip(seq1, seq2)]

seq1et seq2sont les colonnes.

Comparaison numérique
Considérons une simple opération d'indexation booléenne. La méthode de compréhension de liste a été chronométrée contre Series.ne( !=) et query. Voici les fonctions:

# Boolean indexing with Numeric value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

Pour plus de simplicité, j'ai utilisé le perfplotpackage pour exécuter tous les tests timeit dans cet article. Les horaires des opérations ci-dessus sont indiqués ci-dessous:

entrez la description de l'image ici

La compréhension de liste surpasse querypour N de taille moyenne, et surpasse même la comparaison vectorisée non égale pour les petits N.

Remarque
Il convient de mentionner que la plupart des avantages de la compréhension des listes proviennent de ne pas avoir à se soucier de l'alignement de l'index, mais cela signifie que si votre code dépend de l'alignement de l'indexation, cela se cassera. Dans certains cas, les opérations vectorisées sur les tableaux NumPy sous-jacents peuvent être considérées comme apportant le «meilleur des deux mondes», permettant une vectorisation sans tous les frais généraux inutiles des fonctions pandas. Cela signifie que vous pouvez réécrire l'opération ci-dessus comme

df[df.A.values != df.B.values]

Ce qui surpasse à la fois les pandas et les équivalents de compréhension de liste: la

vectorisation NumPy est hors de la portée de cet article, mais cela vaut vraiment la peine d'être pris en compte, si les performances comptent.

La valeur compte
En prenant un autre exemple - cette fois, avec une autre construction python vanille qui est plus rapide qu'une boucle for - collections.Counter. Une exigence courante consiste à calculer le nombre de valeurs et à renvoyer le résultat sous forme de dictionnaire. Cela se fait avec value_counts, np.uniqueet Counter:

# Value Counts comparison.
ser.value_counts(sort=False).to_dict()           # value_counts
dict(zip(*np.unique(ser, return_counts=True)))   # np.unique
Counter(ser)                                     # Counter

entrez la description de l'image ici

Les résultats sont plus prononcés, l' Counteremporte sur les deux méthodes vectorisées pour une plus grande plage de petits N (~ 3500).

Remarque
Plus de trivia (avec la permission de @ user2357112). Le Counterest implémenté avec un accélérateur C , donc s'il doit encore fonctionner avec des objets python au lieu des types de données C sous-jacents, il est toujours plus rapide qu'une forboucle. Puissance Python!

Bien sûr, ce qu'il faut retenir ici, c'est que les performances dépendent de vos données et de votre cas d'utilisation. Le but de ces exemples est de vous convaincre de ne pas exclure ces solutions comme des options légitimes. Si ceux-ci ne vous donnent toujours pas les performances dont vous avez besoin, il y a toujours cython et numba . Ajoutons ce test au mix.

from numba import njit, prange

@njit(parallel=True)
def get_mask(x, y):
    result = [False] * len(x)
    for i in prange(len(x)):
        result[i] = x[i] != y[i]

    return np.array(result)

df[get_mask(df.A.values, df.B.values)] # numba

entrez la description de l'image ici

Numba propose une compilation JIT de code python en boucle vers un code vectorisé très puissant. Comprendre comment faire fonctionner numba implique une courbe d'apprentissage.


Opérations avec des objecttypes mixtes / dtypes

Comparaison basée sur des chaînes En
revisitant l'exemple de filtrage de la première section, que se passe-t-il si les colonnes comparées sont des chaînes? Considérez les 3 mêmes fonctions ci-dessus, mais avec l'entrée DataFrame convertie en chaîne.

# Boolean indexing with string value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

entrez la description de l'image ici

Alors, qu'est-ce qui a changé? La chose à noter ici est que les opérations sur les chaînes sont par nature difficiles à vectoriser. Pandas traite les chaînes comme des objets et toutes les opérations sur les objets reviennent à une implémentation lente et en boucle.

Maintenant, parce que cette implémentation en boucle est entourée de tous les frais généraux mentionnés ci-dessus, il existe une différence de grandeur constante entre ces solutions, même si elles sont à l'échelle de la même.

Lorsqu'il s'agit d'opérations sur des objets mutables / complexes, il n'y a pas de comparaison. La compréhension de liste surpasse toutes les opérations impliquant des dictionnaires et des listes.

Accès aux valeurs de dictionnaire par clé
Voici les horaires de deux opérations qui extraient une valeur d'une colonne de dictionnaires: mapet la compréhension de la liste. La configuration est dans l'annexe, sous la rubrique "Extraits de code".

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

entrez la description de l'image ici

Liste positionnelle indexation
minutage pour 3 opérations qui extraient l'élément 0e d'une liste de colonnes (gestion des exceptions), map, str.getaccesseur méthode , et la compréhension de la liste:

# List positional indexing. 
def get_0th(lst):
    try:
        return lst[0]
    # Handle empty lists and NaNs gracefully.
    except (IndexError, TypeError):
        return np.nan

ser.map(get_0th)                                          # map
ser.str[0]                                                # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser])  # list comp
pd.Series([get_0th(x) for x in ser])                      # list comp safe

Remarque
Si l'index est important, vous voudrez faire:

pd.Series([...], index=ser.index)

Lors de la reconstruction de la série.

entrez la description de l'image ici

Aplatissement des listes
Un dernier exemple est l'aplatissement des listes. Ceci est un autre problème courant et démontre à quel point le python pur est puissant ici.

# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True)  # stack
pd.Series(list(chain.from_iterable(ser.tolist())))         # itertools.chain
pd.Series([y for x in ser for y in x])                     # nested list comp

entrez la description de l'image ici

Les deux itertools.chain.from_iterableet la compréhension de liste imbriquée sont des constructions python pures, et évoluent bien mieux que la stacksolution.

Ces horaires sont une indication forte du fait que les pandas ne sont pas équipés pour travailler avec des dtypes mixtes, et que vous devriez probablement vous abstenir de l'utiliser pour le faire. Dans la mesure du possible, les données doivent être présentes sous forme de valeurs scalaires (ints / floats / strings) dans des colonnes séparées.

Enfin, l'applicabilité de ces solutions dépend largement de vos données. Donc, la meilleure chose à faire serait de tester ces opérations sur vos données avant de décider quoi faire. Remarquez que je n'ai pas chronométré applyces solutions, car cela fausserait le graphique (oui, c'est aussi lent).


Opérations Regex et .strméthodes d'accès

Pandas peuvent appliquer des opérations telles que regex str.contains, str.extractet str.extractall, ainsi que d' autres opérations de chaîne « vectorisé » (tels que str.split, str.find ,str.translate`, etc.) sur les colonnes de chaîne. Ces fonctions sont plus lentes que les compréhensions de listes et sont censées être des fonctions plus pratiques qu'autre chose.

Il est généralement beaucoup plus rapide de pré-compiler un modèle regex et d'itérer sur vos données avec re.compile(voir également Vaut-il la peine d'utiliser le re.compile de Python? ). La liste comp équivalente à str.containsressemble à ceci:

p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])

Ou,

ser2 = ser[[bool(p.search(x)) for x in ser]]

Si vous avez besoin de gérer des NaN, vous pouvez faire quelque chose comme

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

La liste de compilation équivalente à str.extract(sans groupes) ressemblera à quelque chose comme:

df['col2'] = [p.search(x).group(0) for x in df['col']]

Si vous devez gérer les no-matches et les NaN, vous pouvez utiliser une fonction personnalisée (encore plus rapide!):

def matcher(x):
    m = p.search(str(x))
    if m:
        return m.group(0)
    return np.nan

df['col2'] = [matcher(x) for x in df['col']]

La matcherfonction est très extensible. Il peut être adapté pour renvoyer une liste pour chaque groupe de capture, selon les besoins. Extrayez simplement la requête groupou l' groupsattribut de l'objet matcher.

Pour str.extractall, changez p.searchpour p.findall.

Extraction de chaînes
Considérez une opération de filtrage simple. L'idée est d'extraire 4 chiffres s'il est précédé d'une lettre majuscule.

# Extracting strings.
p = re.compile(r'(?<=[A-Z])(\d{4})')
def matcher(x):
    m = p.search(x)
    if m:
        return m.group(0)
    return np.nan

ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False)   #  str.extract
pd.Series([matcher(x) for x in ser])                  #  list comprehension

entrez la description de l'image ici

Plus d'exemples
Divulgation complète - Je suis l'auteur (en partie ou en totalité) de ces articles énumérés ci-dessous.


Conclusion

Comme le montrent les exemples ci-dessus, l'itération brille lorsque vous travaillez avec de petites lignes de DataFrames, des types de données mixtes et des expressions régulières.

L'accélération que vous obtenez dépend de vos données et de votre problème, votre kilométrage peut donc varier. La meilleure chose à faire est d'exécuter soigneusement des tests et de voir si le paiement en vaut la peine.

Les fonctions "vectorisées" brillent par leur simplicité et leur lisibilité, donc si les performances ne sont pas critiques, vous devriez certainement les préférer.

Autre remarque, certaines opérations sur les chaînes traitent des contraintes qui favorisent l'utilisation de NumPy. Voici deux exemples où la vectorisation prudente de NumPy surpasse python:

De plus, parfois, le simple fonctionnement des baies sous-jacentes via .valuesplutôt que sur les séries ou les DataFrames peut offrir une accélération suffisamment saine pour la plupart des scénarios habituels (voir la note dans la section Comparaison numérique ci-dessus). Ainsi, par exemple df[df.A.values != df.B.values]afficherait des améliorations instantanées des performances df[df.A != df.B]. L'utilisation .valuespeut ne pas être appropriée dans toutes les situations, mais c'est un hack utile à connaître.

Comme mentionné ci-dessus, c'est à vous de décider si ces solutions valent la peine d'être mises en œuvre.


Annexe: extraits de code

import perfplot  
import operator 
import pandas as pd
import numpy as np
import re

from collections import Counter
from itertools import chain

# Boolean indexing with Numeric value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B']),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
        lambda df: df[get_mask(df.A.values, df.B.values)]
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp', 'numba'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N'
)

# Value Counts comparison.
perfplot.show(
    setup=lambda n: pd.Series(np.random.choice(1000, n)),
    kernels=[
        lambda ser: ser.value_counts(sort=False).to_dict(),
        lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
        lambda ser: Counter(ser),
    ],
    labels=['value_counts', 'np.unique', 'Counter'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=lambda x, y: dict(x) == dict(y)
)

# Boolean indexing with string value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B'], dtype=str),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# Dictionary value extraction.
ser1 = pd.Series([{'key': 'abc', 'value': 123}, {'key': 'xyz', 'value': 456}])
perfplot.show(
    setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(operator.itemgetter('value')),
        lambda ser: pd.Series([x.get('value') for x in ser]),
    ],
    labels=['map', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# List positional indexing. 
ser2 = pd.Series([['a', 'b', 'c'], [1, 2], []])        
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(get_0th),
        lambda ser: ser.str[0],
        lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
        lambda ser: pd.Series([get_0th(x) for x in ser]),
    ],
    labels=['map', 'str accessor', 'list comprehension', 'list comp safe'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# Nested list flattening.
ser3 = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']])
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
        lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
        lambda ser: pd.Series([y for x in ser for y in x]),
    ],
    labels=['stack', 'itertools.chain', 'nested list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',    
    equality_check=None

)

# Extracting strings.
ser4 = pd.Series(['foo xyz', 'test A1234', 'D3345 xtz'])
perfplot.show(
    setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False),
        lambda ser: pd.Series([matcher(x) for x in ser])
    ],
    labels=['str.extract', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)
cs95
la source
pd.Serieset prend pd.DataFramedésormais en charge la construction à partir d'itérables. Cela signifie que l'on peut simplement passer un générateur Python aux fonctions du constructeur plutôt que de devoir d'abord construire une liste (en utilisant des compréhensions de liste), ce qui pourrait être plus lent dans de nombreux cas. Cependant, la taille de la sortie du générateur ne peut pas être déterminée à l'avance. Je ne sais pas combien de temps / mémoire cela entraînerait.
GZ0
@ GZ0 IIRC, accepter les itérateurs est un ajout plus récent à l'API. Quant à "cela signifie que l'on peut simplement passer un générateur Python aux fonctions du constructeur plutôt que de devoir d'abord construire une liste", je ne suis pas d'accord. Mémoire peut-être, performance non. D'après mon expérience, il est généralement presque toujours plus rapide de générer la liste et de la transmettre. Liste des comps FTW.
cs95
@ cs95 Pourquoi ne pensez-vous pas que les générateurs peuvent conduire à un gain de performance? Ou aviez-vous fait des tests à ce sujet?
GZ0
@ GZ0 Je n'ai pas dit que les générateurs ne conduisaient pas à un gain de performance, je dis que vous ne gagnez pas autant que vous le feriez en utilisant la compréhension de liste. Il y a des frais généraux associés à l'initialisation des genexps, au maintien de l'état, etc., que les comps de liste n'ont pas. Il y a beaucoup d'exemples ici avec des compréhensions de liste que vous pouvez comparer aux compréhensions de générateur pour comparer. Voici un exemple que vous pouvez exécuter dès maintenant ser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000):; %timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95
@ cs95 Vous avez probablement raison. Je viens de jeter un coup d'œil rapide dans le code du constructeur de pd.Serieset il s'avère que si la taille de la sortie itérable ne peut pas être déterminée, une liste serait de toute façon construite à partir de l'itérable. Donc, dans la plupart des cas, il n'y aurait aucun gain à utiliser des générateurs. Cependant, lorsque j'ai exécuté les exemples que vous avez donnés, la version du générateur est en fait plus lente. Je ne sais pas d'où vient la différence.
GZ0
1

En bref

  • for loop + iterrowsest extrêmement lent. La surcharge n'est pas significative sur ~ 1 000 lignes, mais perceptible sur plus de 10 000 lignes.
  • for loop + itertuplesest beaucoup plus rapide queiterrows ou apply.
  • la vectorisation est généralement beaucoup plus rapide que itertuples

Référence entrez la description de l'image ici

artoby
la source