TLDR; Nein, for
Schleifen sind nicht "schlecht", zumindest nicht immer. Es ist wahrscheinlich genauer zu sagen, dass einige vektorisierte Operationen langsamer als die Iteration sind, als zu sagen, dass die Iteration schneller ist als einige vektorisierte Operationen. Zu wissen, wann und warum dies der Schlüssel ist, um die größtmögliche Leistung aus Ihrem Code herauszuholen. Kurz gesagt, dies sind die Situationen, in denen es sich lohnt, eine Alternative zu vektorisierten Pandas-Funktionen in Betracht zu ziehen:
- Wenn Ihre Daten klein sind (... je nachdem, was Sie tun),
- Beim Umgang mit
object
/ gemischten dtypen
- Bei Verwendung der
str
Accessor-Funktionen / regex
Lassen Sie uns diese Situationen einzeln untersuchen.
Iteration v / s Vektorisierung auf kleinen Daten
Pandas verfolgt in seinem API-Design einen "Convention Over Configuration" -Ansatz. Dies bedeutet, dass dieselbe API angepasst wurde, um eine breite Palette von Daten und Anwendungsfällen abzudecken.
Wenn eine Pandas-Funktion aufgerufen wird, müssen (unter anderem) die folgenden Dinge intern von der Funktion erledigt werden, um das Funktionieren sicherzustellen
- Index- / Achsenausrichtung
- Umgang mit gemischten Datentypen
- Umgang mit fehlenden Daten
Fast jede Funktion muss sich in unterschiedlichem Maße mit diesen befassen, und dies ist mit einem Overhead verbunden . Der Overhead ist beispielsweise für numerische Funktionen geringer Series.add
, während er für Zeichenfolgenfunktionen (z. B. Series.str.replace
) stärker ausgeprägt ist .
for
Loops hingegen sind schneller als Sie denken. Was noch besser ist, ist das Listenverständnis (das Listen über for
Schleifen erstellt) noch schneller, da es sich um optimierte iterative Mechanismen für die Listenerstellung handelt.
Listenverständnisse folgen dem Muster
[f(x) for x in seq]
Wo seq
ist eine Pandas-Serie oder eine DataFrame-Spalte? Oder wenn Sie über mehrere Spalten arbeiten,
[f(x, y) for x, y in zip(seq1, seq2)]
Wo seq1
und seq2
sind Spalten.
Numerischer Vergleich
Betrachten Sie eine einfache boolesche Indizierungsoperation. Die Listenverständnismethode wurde gegen Series.ne
( !=
) und zeitlich festgelegt query
. Hier sind die Funktionen:
# 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
Der Einfachheit halber habe ich das perfplot
Paket verwendet, um alle Timeit-Tests in diesem Beitrag auszuführen. Die Zeiten für die obigen Operationen sind unten:
Das Listenverständnis übertrifft das query
mäßig große N und übertrifft sogar das vektorisierte nicht gleichwertige Vergleich für das winzige N. Leider skaliert das Listenverständnis linear, sodass es für das größere N nicht viel Leistungsgewinn bietet.
Hinweis
Es ist erwähnenswert, dass ein Großteil des Vorteils des Listenverständnisses darin besteht, dass Sie sich nicht um die Indexausrichtung kümmern müssen. Wenn Ihr Code jedoch von der Indexausrichtung abhängig ist, wird dies nicht funktionieren. In einigen Fällen können vektorisierte Operationen über die zugrunde liegenden NumPy-Arrays als das "Beste aus beiden Welten" angesehen werden, was eine Vektorisierung ohne den unnötigen Overhead der Pandas-Funktionen ermöglicht. Dies bedeutet, dass Sie den obigen Vorgang wie folgt umschreiben können
df[df.A.values != df.B.values]
Was sowohl die Pandas als auch das Listenverständnis übertrifft:
NumPy-Vektorisierung ist nicht Gegenstand dieses Beitrags, aber es lohnt sich auf jeden Fall, darüber nachzudenken, ob es auf die Leistung ankommt.
Wertzählungen
Nehmen Sie ein anderes Beispiel - diesmal mit einem anderen Vanille-Python-Konstrukt, das schneller als eine for-Schleife ist - collections.Counter
. Eine häufige Anforderung besteht darin, die Wertanzahl zu berechnen und das Ergebnis als Wörterbuch zurückzugeben. Dies geschieht mit value_counts
, np.unique
und 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
Die Ergebnisse sind ausgeprägter, Counter
sich gegen beide vektorisierten Methoden für einen größeren Bereich kleiner N (~ 3500) durch.
Hinweis
Weitere Wissenswertes (mit freundlicher Genehmigung von user2357112). Das Counter
wird mit einem C-Beschleuniger implementiert , sodass es zwar immer noch mit Python-Objekten anstelle der zugrunde liegenden C-Datentypen arbeiten muss, aber immer noch schneller als eine for
Schleife ist. Python-Power!
Der Nachteil von hier ist natürlich, dass die Leistung von Ihren Daten und Ihrem Anwendungsfall abhängt. Mit diesen Beispielen möchten wir Sie davon überzeugen, diese Lösungen nicht als legitime Optionen auszuschließen. Wenn diese Ihnen immer noch nicht die Leistung bieten, die Sie benötigen, gibt es immer Cython und Numba . Fügen wir diesen Test der Mischung hinzu.
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 bietet JIT-Kompilierung von Loop-Python-Code zu sehr leistungsfähigem vektorisiertem Code. Zu verstehen, wie man Numba zum Laufen bringt, erfordert eine Lernkurve.
Operationen mit Mixed / object
dtypes
String-basierter Vergleich
Neubetrachtung die Filterung Beispiel aus dem ersten Abschnitt, was passiert , wenn die Spalten verglichenen Strings? Betrachten Sie die gleichen 3 Funktionen wie oben, aber mit der Eingabe DataFrame in String umgewandelt.
# 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
Also, was hat sich geändert? Die Sache, die hier zu beachten ist, ist das String-Operationen von Natur aus schwer zu vektorisieren sind. Pandas behandelt Zeichenfolgen als Objekte, und alle Operationen an Objekten greifen auf eine langsame, schleifenförmige Implementierung zurück.
Da diese schleifenförmige Implementierung von dem oben genannten Overhead umgeben ist, besteht zwischen diesen Lösungen ein konstanter Größenunterschied, obwohl sie gleich skaliert sind.
Wenn es um Operationen an veränderlichen / komplexen Objekten geht, gibt es keinen Vergleich. Das Listenverständnis übertrifft alle Operationen mit Diktaten und Listen.
Zugreifen auf Wörterbuchwerte über Schlüssel
Hier sind Zeitabläufe für zwei Operationen, die einen Wert aus einer Spalte von Wörterbüchern extrahieren: map
und das Listenverständnis. Das Setup befindet sich im Anhang unter der Überschrift "Code Snippets".
# Dictionary value extraction.
ser.map(operator.itemgetter('value')) # map
pd.Series([x.get('value') for x in ser]) # list comprehension
Indizierungszeitpunkte
für Positionslisten für 3 Operationen, die das 0. Element aus einer Liste von Spalten (Behandlung von Ausnahmen) map
, der str.get
Zugriffsmethode und dem Listenverständnis extrahieren :
# 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
Hinweis
Wenn der Index wichtig ist, möchten Sie Folgendes tun:
pd.Series([...], index=ser.index)
Bei der Rekonstruktion der Serie.
Listenreduzierung
Ein letztes Beispiel ist das Reduzieren von Listen. Dies ist ein weiteres häufiges Problem und zeigt, wie mächtig reines Python hier ist.
# 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
Beide itertools.chain.from_iterable
und das verschachtelte Listenverständnis sind reine Python-Konstrukte und skalieren viel besser als die stack
Lösung.
Diese Timings sind ein starkes Indiz dafür, dass Pandas nicht für die Arbeit mit gemischten D-Typen ausgerüstet sind und dass Sie es wahrscheinlich nicht verwenden sollten, um dies zu tun. Nach Möglichkeit sollten Daten als Skalarwerte (Ints / Floats / Strings) in separaten Spalten vorhanden sein.
Schließlich hängt die Anwendbarkeit dieser Lösungen stark von Ihren Daten ab. Am besten testen Sie diese Vorgänge anhand Ihrer Daten, bevor Sie entscheiden, was Sie tun möchten. Beachten Sie, dass ich apply
diese Lösungen nicht zeitlich festgelegt habe , da dies das Diagramm verzerren würde (ja, es ist so langsam).
Regex-Operationen und .str
Accessor-Methoden
Pandas können regex Vorgänge anwenden wie str.contains
, str.extract
und str.extractall
, wie auch andere „vektorisiert“ String - Operationen (wie str.split
, str.find ,
str.translate`, usw.) auf String - Spalten. Diese Funktionen sind langsamer als das Listenverständnis und sollen mehr Komfortfunktionen sein als alles andere.
Es ist normalerweise viel schneller, ein Regex-Muster vorkompilieren und Ihre Daten mit durchlaufen zu können re.compile
(siehe auch Lohnt es sich, Pythons re.compile zu verwenden? ). Die Liste comp entspricht str.contains
ungefähr so:
p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])
Oder,
ser2 = ser[[bool(p.search(x)) for x in ser]]
Wenn Sie mit NaNs umgehen müssen, können Sie so etwas tun
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
Die Liste comp, die str.extract
(ohne Gruppen) entspricht, sieht ungefähr so aus:
df['col2'] = [p.search(x).group(0) for x in df['col']]
Wenn Sie keine Übereinstimmungen und NaNs verarbeiten müssen, können Sie eine benutzerdefinierte Funktion verwenden (noch schneller!):
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']]
Die matcher
Funktion ist sehr erweiterbar. Es kann angepasst werden, um bei Bedarf eine Liste für jede Erfassungsgruppe zurückzugeben. Extrahieren Sie einfach die Abfrage group
oder das groups
Attribut des Matcher-Objekts.
Für str.extractall
ändern p.search
zu p.findall
.
Zeichenfolgenextraktion
Betrachten Sie eine einfache Filteroperation. Die Idee ist, 4 Ziffern zu extrahieren, wenn ein Großbuchstabe vorangestellt ist.
# 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
Weitere Beispiele
Vollständige Offenlegung - Ich bin der Autor (ganz oder teilweise) dieser unten aufgeführten Beiträge.
Fazit
Wie aus den obigen Beispielen hervorgeht, leuchtet die Iteration bei der Arbeit mit kleinen Zeilen von DataFrames, gemischten Datentypen und regulären Ausdrücken.
Die Geschwindigkeit, die Sie erhalten, hängt von Ihren Daten und Ihrem Problem ab, sodass Ihr Kilometerstand variieren kann. Am besten führen Sie Tests sorgfältig durch und prüfen, ob sich die Auszahlung lohnt.
Die "vektorisierten" Funktionen zeichnen sich durch ihre Einfachheit und Lesbarkeit aus. Wenn die Leistung nicht kritisch ist, sollten Sie diese auf jeden Fall bevorzugen.
Eine weitere Randnotiz: Bestimmte Zeichenfolgenoperationen befassen sich mit Einschränkungen, die die Verwendung von NumPy begünstigen. Hier sind zwei Beispiele, bei denen eine sorgfältige NumPy-Vektorisierung Python übertrifft:
Darüber hinaus kann manchmal nur der Betrieb auf den zugrunde liegenden Arrays über .values
im Gegensatz zu den Serien- oder DataFrames eine ausreichend gute Beschleunigung für die meisten üblichen Szenarien bieten (siehe Hinweis im Abschnitt Numerischer Vergleich oben). So df[df.A.values != df.B.values]
würde zum Beispiel sofortige Leistungssteigerungen über zeigen df[df.A != df.B]
. Die Verwendung ist .values
möglicherweise nicht in jeder Situation angemessen, aber es ist ein nützlicher Hack, dies zu wissen.
Wie oben erwähnt, liegt es an Ihnen, zu entscheiden, ob sich die Implementierung dieser Lösungen lohnt.
Anhang: Code-Schnipsel
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
undpd.DataFrame
jetzt unterstützen Konstruktion aus iterables. Das heißt, man kann einfach einen Python-Generator an die Konstruktorfunktionen übergeben, anstatt zuerst eine Liste erstellen zu müssen (unter Verwendung des Listenverständnisses), was in vielen Fällen langsamer sein kann. Die Größe des Generatorausgangs kann jedoch nicht im Voraus bestimmt werden. Ich bin mir nicht sicher, wie viel Zeit / Speicheraufwand das verursachen würde.