Czy for-loops w pandach są naprawdę złe? Kiedy powinno mnie to obchodzić?
Czy for
pętle są naprawdę "złe"? Jeśli nie, w jakiej sytuacji (- ach) byłyby lepsze niż stosowanie bardziej konwencjonalnego podejścia "wektoryzowanego"?1
Jednakże, jestem dość zaskoczony, widząc wiele kodu (w tym odpowiedzi na przepełnienie stosu) oferujących rozwiązania problemów związanych z zapętlaniem danych za pomocą pętli for
i zestawień list. Dokumentacja i API mówią, że pętle są "złe" i że nigdy nie powinno się iteracji na tablicach, seriach lub ramkach danych. Dlaczego więc czasami widzę użytkowników sugerujących rozwiązania oparte na pętli?
1 - chociaż prawdą jest, że pytanie brzmi dość szeroko, prawda jest taka, że są bardzo specyficzne sytuacje, w których pętle for
są zwykle lepsze niż konwencjonalne iterowanie danych. Ten post ma na celu uchwycenie tego dla potomności.
2 answers
TLDR; Nie, for
pętle nie są "złe", przynajmniej nie zawsze. Prawdopodobnie [75]}bardziej trafne jest stwierdzenie, że niektóre operacje wektoryzowane są wolniejsze niż iteracja {76]}, a nie mówienie, że iteracja jest szybsza niż niektóre operacje wektoryzowane. Wiedza o tym, kiedy i dlaczego jest kluczem do uzyskania jak największej wydajności kodu. W skrócie, są to sytuacje, w których warto rozważyć alternatywę dla wektoryzowanych funkcji pand: {]}
- gdy Twoje dane są małe (...w zależności od tego, co robisz),
- gdy mamy do czynienia z
object
/ mieszanymi dtypami
W tym celu należy użyć funkcji regex accessor.]}
Iteracja V / S wektoryzacja na małych Danych
Pandy w swoim projekcie API stosuje podejście"Convention Over Configuration"("Konwencja nad konfiguracją"). Oznacza to, że ten sam API został wyposażony w celu obsługi szerokiego zakresu danych i przypadków użycia.Kiedy pandy funkcja jest wywoływana, następujące rzeczy (między innymi) muszą być wewnętrznie obsługiwane przez funkcję, aby zapewnić działanie
- indeks/wyrównanie osi
- Obsługa mieszanych typów danych
- Obsługa brakujących danych
Prawie każda funkcja będzie musiała radzić sobie z nimi w różnym zakresie, a to przedstawiaoverhead . Narzut jest mniejszy dla funkcji numerycznych (na przykład, Series.add
), podczas gdy jest bardziej wyrazista dla funkcji łańcuchowych (dla przykład, Series.str.replace
).
for
pętle, z drugiej strony, są szybsze niż myślisz. Jeszcze lepsze jest to, że składanie list (które tworzą listy za pomocą pętli for
) jest jeszcze szybsze, ponieważ są zoptymalizowanymi mechanizmami iteracyjnymi do tworzenia list.
Składanie listy według wzorca
[f(x) for x in seq]
Gdzie seq
jest serią pand lub kolumną ramki danych. Czy też, podczas pracy nad wieloma kolumnami,
[f(x, y) for x, y in zip(seq1, seq2)]
Gdzie seq1
i seq2
są kolumnami.
Porównanie Liczb
Rozważ prostą operację indeksowania logicznego. Metoda rozumienia listy została dopasowana do Series.ne
(!=
) oraz query
. Oto funkcje:
# 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
Dla uproszczenia, użyłem perfplot
pakiet do wszystkich czasówtesty w tym poście. Czas operacji powyżej znajduje się poniżej:
Spis treści w przypadku małych n, a nawet przewyższa vectorized not equal comparison dla małego N. niestety, zrozumienie listy skaluje się liniowo, więc nie oferuje większego przyrostu wydajności dla większego N. [77]}
Uwaga
Warto wspomnieć, że wiele korzyści płynących z rozumienia list wynika z braku konieczności martwienia się o wyrównanie indeksu, oznacza to jednak, że jeśli kod jest zależny od wyrównania indeksowania, to się zepsuje. W niektóre przypadki, wektorowe operacje nad podstawowe tablice NumPy można uznać za "najlepsze z oba światy", pozwalając na wektoryzację bez wszystkich niepotrzebnych napowietrznych funkcji pand. Oznacza to, że można przepisać powyższą operację jakodf[df.A.values != df.B.values]
Który przewyższa zarówno pandy, jak i odpowiedniki listy:
Numpy wektoryzacja jest poza zakresem tego postu, ale zdecydowanie warto rozważyć, jeśli wydajność ma znaczenie.
Wartość Liczy
Kolejny przykład - tym razem z inną konstrukcją Pythona vanilla, która jest szybsza niż pętla for - collections.Counter
. Powszechnym wymogiem jest obliczenie liczby wartości i zwrócenie wyniku jako słownika. Odbywa się to za pomocą value_counts
, np.unique
, i 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
Wyniki są bardziej wyraźne, Counter
wygrywa w obu wektoryzowane metody dla większego zakresu małych N (~3500).
Uwaga
Więcej ciekawostek (dzięki uprzejmości @ user2357112).Counter
jest zaimplementowany C accelerator , tak więc podczas gdy nadal musi pracować z obiektami Pythona zamiast bazowe typy danych C, jest nadal szybsze niż pętlafor
. Python moc!
Oczywiście, odejście od tego jest to, że wydajność zależy od Twoich danych i przypadku użycia. Sens tych przykładem jest przekonanie Cię, aby nie wykluczać tych rozwiązań jako legalnych opcji. Jeśli nadal nie dają Ci wydajności, której potrzebujesz, zawsze jest cython i numba. Dodajmy ten test do miksu.
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 oferuje kompilację JIT loopy kodu Pythona do bardzo potężnego wektoryzowanego kodu. Zrozumienie, jak zrobić pracę numba wymaga krzywej uczenia się.
Operacje z mieszanymi / object
dtypes
Porównanie oparte na łańcuchach
Wracając do przykładu filtrowania z pierwszej sekcji, co jeśli porównywane kolumny są ciągami znaków? Rozważ te same 3 Funkcje powyżej, ale z wejściową ramką danych rzuconą na łańcuch.
# 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
Co się zmieniło? Należy tutaj zauważyć, że operacje string są z natury trudne do wektoryzacji. Pandy traktuje łańcuchy jako obiekty, a wszystkie operacje na obiektach spadają powrót do powolnej, loopy implementacji.
Teraz, ponieważ ta pętlowa implementacja jest otoczona wszystkimi wyżej wymienionymi kosztami, istnieje stała różnica wielkości między tymi rozwiązaniami, mimo że skalują się tak samo.
Jeśli chodzi o operacje na obiektach mutowalnych / złożonych, nie ma porównania. Rozumienie List przewyższa wszystkie operacje z użyciem dictów i list.
Dostęp do wartości słownika za pomocą klucza
Oto terminy dla dwie operacje, które wyodrębniają wartość z kolumny słowników: map
i zrozumienie listy. Konfiguracja znajduje się w załączniku, pod nagłówkiem "fragmenty kodu".
# Dictionary value extraction.
ser.map(operator.itemgetter('value')) # map
pd.Series([x.get('value') for x in ser]) # list comprehension
Indeksowanie Listy Pozycyjnej
Czasy dla 3 operacji, które wyodrębniają 0-ty element z listy kolumn (obsługa wyjątków), map
, str.get
metoda accessor , oraz zrozumienie listy:
# 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
Uwaga
Jeśli indeks ma znaczenie, chciałbyś zrobić:pd.Series([...], index=ser.index)
Podczas rekonstrukcji serii.
Spłaszczenie Listy
Ostatnim przykładem jest spłaszczanie list. Jest to kolejny powszechny problem i pokazuje, jak potężny jest tutaj czysty python.
# 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
itertools.chain.from_iterable
a zagnieżdżona lista jest czystą konstrukcją Pythona, a skaluj znacznie lepiej niż rozwiązanie stack
.
Te timingi są silnym wskazaniem na fakt, że pandy nie są przystosowane do pracy z mieszanymi dtypami i prawdopodobnie powinieneś powstrzymać się od używania ich do tego. Tam, gdzie to możliwe, dane powinny być obecne jako wartości skalarne (ints/floats/strings) w oddzielnych kolumnach.
Wreszcie, możliwość zastosowania tych rozwiązań zależy w dużej mierze od Twoich danych. Najlepiej więc przetestować te operacje na swoich danych przed podjęciem decyzji czym się kierować. Zauważ, że nie mam czasu apply
na tych rozwiązaniach, bo to by wykrzywiało Wykres (tak, to tak wolno).
Operacje Regex i .str
metody Accessor
Pandy mogą stosować operacje regex, takie jak str.contains
, str.extract
, oraz str.extractall
, jak również inne "wektoryzowane" operacje łańcuchowe (takie jak str.split
, str.find ,
str.translate`, i tak dalej) na kolumnach łańcuchów. Funkcje te są wolniejsze od list i mają być bardziej wygodne funkcje niż cokolwiek innego.
Zwykle znacznie szybciej jest wstępnie skompilować wzorzec regex i iterację danych za pomocą re.compile
(Zobacz także czy warto używać re Pythona./ align = "left" / ). Lista komp odpowiednik str.contains
wygląda mniej więcej tak:
p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])
Lub
ser2 = ser[[bool(p.search(x)) for x in ser]]
Jeśli potrzebujesz obsługiwać NaNs, możesz zrobić coś w stylu
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
Lista komp równoważna str.extract
(BEZ grupy) będzie wyglądać mniej więcej tak:
df['col2'] = [p.search(x).group(0) for x in df['col']]
Jeśli potrzebujesz obsługiwać no-match i Nan, możesz użyć niestandardowej funkcji (jeszcze szybciej!):
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']]
Funkcja matcher
jest bardzo rozszerzalna. Można go dopasować, aby w razie potrzeby zwrócić listę dla każdej grupy przechwytywania. Wystarczy wyodrębnić atrybut group
lub groups
obiektu matcher.
Dla str.extractall
, Zmień p.search
na p.findall
.
Ekstrakcja Strun
Rozważ prostą operację filtrowania. Pomysł jest aby wyodrębnić 4 cyfry, jeśli jest poprzedzona wielką literą.
# 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
Więcej Przykładów
Pełne ujawnienie-jestem autorem (w części lub całości) tych postów wymienionych poniżej.
Podsumowanie
Jak pokazano na powyższych przykładach, iteracja świeci podczas pracy z małymi rzędami ramek danych, mieszanymi typami danych i wyrażeniami regularnymi.
Przyspieszenie zależy od danych i problemu, więc przebieg może się różnić. Najlepiej jest dokładnie przeprowadzić testy i sprawdzić, czy wypłata jest warta wysiłku.
Funkcje "wektoryzowane" świecą w swojej prostocie i czytelności, więc jeśli wydajność nie jest krytyczna, zdecydowanie powinieneś je preferować.
Kolejna uwaga, niektóre operacje łańcuchowe dotyczą ograniczeń, które faworyzują użycie NumPy. Oto dwa przykłady, w których ostrożna wektoryzacja NumPy przewyższa Pythona:
Dodatkowo, czasami po prostu operowanie na bazowych tablicach za pomocą .values
, W przeciwieństwie do serii lub ramek danych, może zapewnić wystarczająco dobre przyspieszenie dla większości zwykłych scenariuszy (patrz Uwaga w sekcji porównanie liczb powyżej). Tak więc, na przykład df[df.A.values != df.B.values]
pokazuje natychmiastowe zwiększenie wydajności nad df[df.A != df.B]
. Używanie .values
może nie być odpowiednie w każdej sytuacji, ale jest to przydatne hack wiedzieć.
Jak wspomniano powyżej, to do ciebie należy decyzja, czy te rozwiązania są warte trudu wdrożenia.
Dodatek: Fragmenty Kodu
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
)
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2019-05-25 05:22:40
W skrócie
- dla pętli +
iterrows
jest bardzo wolna. Napowietrzność nie jest znacząca w rzędach ~1k, ale zauważalna w rzędach 10k+. - dla pętli +
itertuples
jest znacznie szybsza niżiterrows
lubapply
. - wektoryzacja jest zwykle znacznie szybsza niż
itertuples
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2020-07-28 20:05:17