TLDR; Non, les for
boucles 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:
- Lorsque vos données sont petites (... selon ce que vous faites),
- Lorsque vous traitez avec
object
/ des dtypes mixtes
- Lors de l'utilisation des
str
fonctions 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
- Alignement index / axe
- Gestion des types de données mixtes
- 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
).
for
les 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 for
boucles) 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 seq
trouve 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)]
Où seq1
et seq2
sont 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 perfplot
package pour exécuter tous les tests timeit dans cet article. Les horaires des opérations ci-dessus sont indiqués ci-dessous:
La compréhension de liste surpasse query
pour 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.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, l' Counter
emporte 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 Counter
est 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 for
boucle. 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
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 object
types 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
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: map
et 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
Liste positionnelle indexation
minutage pour 3 opérations qui extraient l'élément 0e d'une liste de colonnes (gestion des exceptions), map
, str.get
accesseur 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.
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
Les deux itertools.chain.from_iterable
et la compréhension de liste imbriquée sont des constructions python pures, et évoluent bien mieux que la stack
solution.
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é apply
ces solutions, car cela fausserait le graphique (oui, c'est aussi lent).
Opérations Regex et .str
méthodes d'accès
Pandas peuvent appliquer des opérations telles que regex str.contains
, str.extract
et 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.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 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 matcher
fonction 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 group
ou l' groups
attribut de l'objet matcher.
Pour str.extractall
, changez p.search
pour 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
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 .values
plutô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 .values
peut 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
)
pd.Series
et prendpd.DataFrame
dé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.