web-dev-qa-db-fra.com

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

Les boucles for sont-elles vraiment "mauvaises"? Sinon, dans quelle (s) situation (s) seraient-elles meilleures que d'utiliser une approche "vectorisée" plus conventionnelle?1

Je connais le concept de "vectorisation" et comment pandas utilise des techniques vectorisées pour accélérer le calcul. Les fonctions vectorisées diffusent des opérations sur toute la série ou DataFrame pour atteindre 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) offrir des solutions aux problèmes qui impliquent une boucle à travers les données en utilisant les boucles for et les listes de compréhension. La documentation et l'API indiquent 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 boucles for sont généralement meilleures que l'itération conventionnelle sur les données. Ce message vise à capturer cela pour la postérité.

93
cs95

TLDR; Non, les boucles for ne sont pas "mauvaises", du moins, pas toujours. Il est probablement plus précis de dire que certaines opérations vectorisées sont plus lentes que l'itération , par opposition à dire que l'itération est plus rapide que certaines opérations vectorisées. Savoir quand et pourquoi est la clé pour tirer le meilleur parti de votre code. En bref, ce sont les situations où il vaut la peine d'envisager une alternative aux fonctions vectorisées pandas:

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

Examinons ces situations individuellement.


Itération v/s Vectorisation sur 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 éléments suivants (entre autres) doivent être gérés en interne par la fonction, pour garantir le fonctionnement

  1. Alignement index/axe
  2. Gestion des types de données mixtes
  3. Gérer les 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'il est plus prononcé pour les fonctions de chaîne (par exemple, Series.str.replace ).

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

La compréhension des listes suit le modèle

[f(x) for x in seq]

seq est une colonne pandas series ou DataFrame. Ou, lorsque vous utilisez plusieurs colonnes,

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

seq1 et seq2 sont des colonnes.

Comparaison numérique
Considérons une opération d'indexation booléenne simple. La méthode de compréhension de liste a été synchronisée avec 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 package perfplot pour exécuter tous les tests de timeit dans ce post. Les horaires des opérations ci-dessus sont les suivants:

enter image description here

La compréhension de la liste surpasse query pour N de taille moyenne, et surpasse même la comparaison vectorisée pas égale pour N. minuscule. Malheureusement, la compréhension de liste évolue de façon linéaire, donc elle n'offre pas beaucoup de gain de performances pour N. plus grand.

Remarque
. 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 la vectorisation sans tous les frais généraux inutiles des pandas fonctions. Cela signifie que vous pouvez réécrire l'opération ci-dessus comme

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

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

La vectorisation NumPy est hors de portée de cet article, mais elle vaut vraiment la peine d'être considérée, si les performances sont importantes.

Valeur compte
Prenons un autre exemple - cette fois, avec une autre construction Vanilla python 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.unique et 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

enter image description here

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

Remarque
Plus de futilités (courtoisie @ user2357112). Counter est implémenté avec un accélérateur C , donc bien qu'il doive encore fonctionner avec python objets au lieu des types de données C sous-jacents, il est toujours plus rapide qu'une boucle for. Python power!

Bien sûr, la conséquence 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 dans le 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

enter image description here

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


Opérations avec les types mixtes/object dtypes

Comparaison basée sur les chaînes
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 mêmes 3 fonctions ci-dessus, mais avec le DataFrame d'entrée converti 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

enter image description here

Alors, qu'est-ce qui a changé? La chose à noter ici est que les opérations de chaîne sont intrinsèquement difficiles à vectoriser. Pandas traite les chaînes comme des objets, et tout 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 d'amplitude constante entre ces solutions, même si elles évoluent de la même manière.

Lorsqu'il s'agit d'opérations sur des objets mutables/complexes, il n'y a pas de comparaison. La compréhension des listes surpasse toutes les opérations impliquant des dictés 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: map et la compréhension de la liste. La configuration se trouve dans l'annexe, sous le titre "Extraits de code".

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

enter image description here

Indexation de liste positionnelle
Timings pour 3 opérations qui extraient le 0ème élément d'une liste de colonnes (gestion des exceptions), map , str.get méthode accesseur , 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'indice est important, vous voudrez:

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

Lors de la reconstruction de la série.

enter image description here

Aplatissement de la liste
Un dernier exemple est l'aplatissement des listes. Ceci est un autre problème commun, et montre à quel point la puissance python est pure 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

enter image description here

Tous les deux itertools.chain.from_iterable et la compréhension de la liste imbriquée sont des constructions pures python, et évoluent bien mieux que la solution stack.

Ces horaires sont une indication forte du fait que pandas n'est pas équipé pour fonctionner 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 la forme 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 comment je n'ai pas chronométré apply sur ces solutions, car cela fausserait le graphique (oui, c'est si lent).


Opérations Regex et .str Méthodes d'accesseur

Les pandas peuvent appliquer des opérations d'expression régulière telles que str.contains , str.extract et str.extractall , ainsi que d'autres opérations de chaîne "vectorisées" (telles que str.split, str.find,str.translate`, etc.) sur les colonnes de chaînes. Ces fonctions sont plus lentes que les compréhensions de liste et sont censées être plus de fonctions pratiques qu'autre chose.

Il est généralement beaucoup plus rapide de précompiler un modèle d'expression régulière et d'itérer sur vos données avec re.compile (voir aussi Cela vaut-il la peine d'utiliser re.compile de Python? ). La liste comp équivaut à str.contains ressemble à 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 devez 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 comp équivaut à str.extract (sans groupes) ressemblera à:

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

Si vous devez gérer les non-correspondances 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 fonction matcher est très extensible. Il peut être adapté pour renvoyer une liste pour chaque groupe de capture, selon les besoins. Il suffit d'extraire la requête de l'attribut group ou groups de l'objet matcher.

Pour str.extractall, changement p.search à p.findall.

Extraction de chaînes
Envisagez 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

enter image description here

Plus d'exemples
Divulgation complète - Je suis l'auteur (en tout ou en partie) des messages é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, donc votre kilométrage peut 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 de chaîne traitent de contraintes qui favorisent l'utilisation de NumPy. Voici deux exemples où la vectorisation soigneuse de NumPy surpasse Python:

De plus, il suffit parfois d'opérer sur les tableaux sous-jacents via .values par opposition à sur les Series ou DataFrames peut offrir une accélération suffisamment saine pour la plupart des scénarios habituels (voir la Remarque dans la Comparaison numérique section ci-dessus). Ainsi, par exemple df[df.A.values != df.B.values] afficherait des gains de performances instantanés sur df[df.A != df.B]. En utilisant .values n'est peut-être pas approprié 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
)
120
cs95