Sind For-Loops bei Pandas wirklich schlecht? Wann sollte es mich interessieren?

104

Sind forLoops wirklich "schlecht"? Wenn nicht, in welchen Situationen wären sie besser als ein konventionellerer "vektorisierter" Ansatz? 1

Ich bin mit dem Konzept der "Vektorisierung" vertraut und weiß, wie Pandas vektorisierte Techniken einsetzt, um die Berechnung zu beschleunigen. Vektorisierte Funktionen senden Vorgänge über die gesamte Serie oder DataFrame, um Beschleunigungen zu erzielen, die viel größer sind als herkömmliche Iterationen über die Daten.

Ich bin jedoch ziemlich überrascht, dass viel Code (einschließlich Antworten auf den Stapelüberlauf) Lösungen für Probleme bietet, bei denen Daten mithilfe von forSchleifen und Listenverständnissen durchlaufen werden . Die Dokumentation und die API besagen, dass Schleifen "schlecht" sind und dass man "niemals" über Arrays, Serien oder DataFrames iterieren sollte. Wie kommt es also, dass Benutzer manchmal schleifenbasierte Lösungen vorschlagen?


1 - Während es stimmt, dass die Frage etwas weit gefasst klingt, ist die Wahrheit, dass es sehr spezifische Situationen gibt, in denen forSchleifen normalerweise besser sind als herkömmliche Iterationen über Daten. Dieser Beitrag soll dies für die Nachwelt festhalten.

cs95
quelle

Antworten:

141

TLDR; Nein, forSchleifen 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:

  1. Wenn Ihre Daten klein sind (... je nachdem, was Sie tun),
  2. Beim Umgang mit object/ gemischten dtypen
  3. Bei Verwendung der strAccessor-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

  1. Index- / Achsenausrichtung
  2. Umgang mit gemischten Datentypen
  3. 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 .

forLoops hingegen sind schneller als Sie denken. Was noch besser ist, ist das Listenverständnis (das Listen über forSchleifen 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 seqist 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 seq1und seq2sind 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 perfplotPaket verwendet, um alle Timeit-Tests in diesem Beitrag auszuführen. Die Zeiten für die obigen Operationen sind unten:

Geben Sie hier die Bildbeschreibung ein

Das Listenverständnis übertrifft das querymäß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.uniqueund 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

Geben Sie hier die Bildbeschreibung ein

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 Counterwird 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 forSchleife 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

Geben Sie hier die Bildbeschreibung ein

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 / objectdtypes

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

Geben Sie hier die Bildbeschreibung ein

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: mapund 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

Geben Sie hier die Bildbeschreibung ein

Indizierungszeitpunkte
für Positionslisten für 3 Operationen, die das 0. Element aus einer Liste von Spalten (Behandlung von Ausnahmen) map, der str.getZugriffsmethode 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.

Geben Sie hier die Bildbeschreibung ein

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

Geben Sie hier die Bildbeschreibung ein

Beide itertools.chain.from_iterableund das verschachtelte Listenverständnis sind reine Python-Konstrukte und skalieren viel besser als die stackLö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 applydiese Lösungen nicht zeitlich festgelegt habe , da dies das Diagramm verzerren würde (ja, es ist so langsam).


Regex-Operationen und .strAccessor-Methoden

Pandas können regex Vorgänge anwenden wie str.contains, str.extractund 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.containsungefä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 matcherFunktion ist sehr erweiterbar. Es kann angepasst werden, um bei Bedarf eine Liste für jede Erfassungsgruppe zurückzugeben. Extrahieren Sie einfach die Abfrage groupoder das groupsAttribut des Matcher-Objekts.

Für str.extractalländern p.searchzu 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

Geben Sie hier die Bildbeschreibung ein

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 .valuesim 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 .valuesmö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
)
cs95
quelle
pd.Seriesund pd.DataFramejetzt 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.
GZ0
@ GZ0 IIRC, das Akzeptieren von Iteratoren ist eine neuere Ergänzung der API. Was "bedeutet, dass man einfach einen Python-Generator an die Konstruktorfunktionen übergeben kann, anstatt zuerst eine Liste erstellen zu müssen", stimme ich nicht zu. Speicher vielleicht, Leistung nein. Nach meiner Erfahrung ist es normalerweise fast immer schneller, die Liste zu erstellen und weiterzugeben. Liste comps FTW.
CS95
@ cs95 Warum können Generatoren Ihrer Meinung nach nicht zu Leistungssteigerungen führen? Oder haben Sie das getestet?
GZ0
@ GZ0 Ich habe nicht gesagt, dass Generatoren nicht zu einem Leistungsgewinn führen. Ich sage, dass Sie nicht so viel gewinnen, wie Sie es mit Listenverständnis tun würden. Mit dem Initialisieren von Genexps, dem Aufrechterhalten des Status usw. sind Overheads verbunden, die Listen-Comps nicht haben. Hier gibt es viele Beispiele mit Listenverständnissen, die Sie mit dem Generatorverständnis vergleichen können. Hier ist ein Beispiel, das Sie jetzt ausführen können : 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 Du hast wahrscheinlich recht. Ich habe gerade einen kurzen Blick in den Konstruktorcode von pd.Seriesgeworfen und es stellt sich heraus, dass, wenn die Größe der iterierbaren Ausgabe nicht bestimmt werden kann, eine Liste ohnehin aus der iterierbaren Ausgabe erstellt wird. In den meisten Fällen würde die Verwendung von Generatoren also keine Vorteile bringen. Als ich jedoch die von Ihnen angegebenen Beispiele ausführte, ist die Generatorversion tatsächlich langsamer. Ich bin mir nicht sicher, woher der Unterschied kommt.
GZ0
1

Zusamenfassend

  • für loop + iterrowsist extrem langsam. Der Overhead ist bei ~ 1.000 Zeilen nicht signifikant, macht sich jedoch bei mehr als 10.000 Zeilen bemerkbar.
  • for loop + itertuplesist viel schneller als iterrowsoder apply.
  • Die Vektorisierung ist normalerweise viel schneller als itertuples

Benchmark Geben Sie hier die Bildbeschreibung ein

Artoby
quelle