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é.
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:
object
/dtypes mixtesstr
/regexExaminons ces situations individuellement.
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
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]
Où seq
est une colonne pandas series ou DataFrame. Ou, lorsque vous utilisez plusieurs colonnes,
[f(x, y) for x, y in Zip(seq1, seq2)]
Où 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:
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 commedf[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
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 bouclefor
. 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
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.
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
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
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.
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
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).
.str
Méthodes d'accesseurLes 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
Plus d'exemples
Divulgation complète - Je suis l'auteur (en tout ou en partie) des messages énumérés ci-dessous.
Supprimer les parties indésirables des chaînes dans une colonne
Remplace tout sauf la dernière occurrence d'un caractère dans une trame de données
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.
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
)