TLDR; Nie, for
pętle nie są ogólnie „złe”, przynajmniej nie zawsze. Prawdopodobnie dokładniejsze jest stwierdzenie, że niektóre operacje wektoryzowane są wolniejsze niż iteracja , niż stwierdzenie, że iteracja jest szybsza niż niektóre operacje wektoryzowane. Wiedza o tym, kiedy i dlaczego jest kluczem do uzyskania największej wydajności kodu. W skrócie są to sytuacje, w których warto rozważyć alternatywę dla funkcji pand zwektoryzowanych:
- Kiedy Twoje dane są małe (... w zależności od tego, co robisz),
- W przypadku
object
/ mieszanych typów
- Podczas korzystania z
str
funkcji akcesorów / regex
Przeanalizujmy te sytuacje indywidualnie.
Iteracja v / s Wektoryzacja na małych danych
Pandas stosuje podejście „Convention Over Configuration” w swoim projekcie API. Oznacza to, że ten sam interfejs API został dopasowany do szerokiego zakresu danych i przypadków użycia.
Gdy wywoływana jest funkcja pandy, następujące rzeczy (między innymi) muszą być wewnętrznie obsługiwane przez funkcję, aby zapewnić działanie
- Wyrównanie indeksu / osi
- Obsługa mieszanych typów danych
- Obsługa brakujących danych
Prawie każda funkcja będzie musiała sobie z nimi radzić w różnym stopniu, a to stanowi obciążenie . Narzut jest mniejszy w przypadku funkcji numerycznych (na przykład Series.add
), podczas gdy jest bardziej wyraźny w przypadku funkcji łańcuchowych (na przykład Series.str.replace
).
for
z drugiej strony pętle są szybsze niż myślisz. Jeszcze lepsze jest to, że listy składane (które tworzą listy za pomocą for
pętli) są jeszcze szybsze, ponieważ są zoptymalizowanymi iteracyjnymi mechanizmami tworzenia list.
Listy składane są zgodne ze wzorem
[f(x) for x in seq]
Gdzie seq
jest seria pand lub kolumna DataFrame. Lub, gdy operujesz na wielu kolumnach,
[f(x, y) for x, y in zip(seq1, seq2)]
Gdzie seq1
i seq2
są kolumny.
Porównanie liczbowe
Rozważmy prostą operację indeksowania logicznego. Metoda list złożonych została ustawiona w czasie przeciwko Series.ne
( !=
) i 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 cały czas użyłem tego perfplot
pakietu do przeprowadzenia testów w tym poście. Poniżej podano terminy wykonania powyższych operacji:
Zrozumienie listy przewyższa wyniki query
dla średniej wielkości N, a nawet przewyższa wektoryzowane, nie równa się porównaniu dla małego N.
Uwaga
Warto wspomnieć, że wiele korzyści z rozumienia list wynika z braku martwienia się o wyrównanie indeksu, ale oznacza to, że jeśli twój kod jest zależny od wyrównania indeksowania, to się zepsuje. W niektórych przypadkach wektoryzowane operacje na bazowych tablicach NumPy można uznać za przynoszące „najlepsze z obu światów”, pozwalające na wektoryzację bez wszystkich niepotrzebnych narzutów funkcji pand. Oznacza to, że możesz przepisać powyższą operację jako
df[df.A.values != df.B.values]
Co przewyższa zarówno pandy, jak i odpowiedniki ze zrozumieniem list:
wektoryzacja NumPy jest poza zakresem tego postu, ale zdecydowanie warto się nad nią zastanowić, jeśli liczy się wydajność.
Wartość ma znaczenie
Biorąc inny przykład - tym razem z inną konstrukcją waniliowego Pythona, która jest szybsza niż pętla for - collections.Counter
. Typowym wymaganiem jest obliczenie liczników wartości i zwrócenie wyniku w postaci słownika. Odbywa się to 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ą wyraźniejsze, Counter
wygrywają z obiema metodami wektoryzowanymi dla większego zakresu małych N (~ 3500).
Uwaga
Więcej ciekawostek (dzięki uprzejmości @ user2357112). Counter
Jest realizowany z C akceleratora , więc podczas gdy nadal musi pracować z python obiektów zamiast podstawowych typów danych C, to jeszcze szybciej niż for
pętli. Moc Pythona!
Oczywiście wnioskiem jest to, że wydajność zależy od twoich danych i przypadku użycia. Celem tych przykładów jest przekonanie Cię, aby nie wykluczać tych rozwiązań jako uzasadnionych opcji. Jeśli te nadal nie zapewniają wydajności, której potrzebujesz, zawsze są 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 zapętlonego kodu Pythona do bardzo wydajnego kodu wektoryzowanego. Zrozumienie, jak sprawić, by Numba działało, wymaga krzywej uczenia się.
Operacje z mieszanymi / object
dtypami
Porównanie oparte
na ciągach Wracając do przykładu filtrowania z pierwszej sekcji, co się stanie, jeśli porównywane kolumny są ciągami? Rozważ te same 3 funkcje powyżej, ale z wejściowym rzutowaniem DataFrame 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
Więc co się zmieniło? Należy tutaj zauważyć, że operacje na łańcuchach są z natury trudne do wektoryzacji. Pandy traktują ciągi znaków jako obiekty, a wszystkie operacje na obiektach wracają do powolnej, zapętlonej implementacji.
Teraz, ponieważ ta pętla implementacji jest otoczona przez wszystkie wspomniane powyżej narzuty, istnieje stała różnica wielkości między tymi rozwiązaniami, nawet jeśli skalują się one tak samo.
Jeśli chodzi o operacje na obiektach zmiennych / złożonych, nie ma porównania. Zrozumienie list przewyższa wszystkie operacje związane z dyktami i listami.
Dostęp do wartości słownika według klucza
Oto czasy dla dwóch operacji, które wyodrębniają wartość z kolumny słowników: map
i rozumienia listy. Konfiguracja znajduje się w dodatku, 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 pozycyjny Lista
taktowanie dla 3 operacji, które wyodrębnić 0TH elementu z listy kolumn (obsługa wyjątków) map
, str.get
akcesor metody , a lista zrozumieniem:
# 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, zechcesz:
pd.Series([...], index=ser.index)
Podczas rekonstrukcji serii.
Spłaszczanie list
Ostatnim przykładem jest spłaszczanie list. To kolejny powszechny problem, który 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
Zarówno itertools.chain.from_iterable
składanie list zagnieżdżonych, jak i lista zagnieżdżona są konstrukcjami czystego języka Python i skalują się znacznie lepiej niż stack
rozwiązanie.
Te czasy są mocnym wskazaniem na fakt, że pandy nie są przystosowane do pracy z różnymi typami i że prawdopodobnie powinieneś powstrzymać się od używania go do tego. Tam, gdzie to możliwe, dane powinny być obecne jako wartości skalarne (int / floats / strings) w oddzielnych kolumnach.
Wreszcie, możliwość zastosowania tych rozwiązań zależy w dużej mierze od danych. Dlatego najlepiej byłoby przetestować te operacje na danych przed podjęciem decyzji, z czym należy się postępować. Zwróć uwagę, jak nie ustawiłem czasu apply
na tych rozwiązaniach, ponieważ wypaczyłoby to wykres (tak, to jest tak wolne).
Operacje na wyrażeniach regularnych i .str
metody dostępu
Pandy mogą stosować operacje takie jak regex str.contains
, str.extract
i str.extractall
, jak inne „vectorized” Operacje łańcuchowe (takie jak str.split
, str.find ,
str.translate`, i tak dalej) na kolumnach smyczkowych. Te funkcje są wolniejsze niż składanie list i mają być bardziej wygodnymi funkcjami niż cokolwiek innego.
Wstępna kompilacja wzorca wyrażenia regularnego i iteracja po danych jest zwykle znacznie szybsza re.compile
(zobacz także Czy warto używać pliku re.compile w języku Python? ). Lista odpowiedników comp 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ć NaN, możesz zrobić coś takiego
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
Lista odpowiedników str.extract
(bez grup) będzie wyglądać następująco:
df['col2'] = [p.search(x).group(0) for x in df['col']]
Jeśli potrzebujesz poradzić sobie z brakiem dopasowań i NaN, możesz użyć funkcji niestandardowej (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']]
matcher
Funkcja jest bardzo rozciągliwe. W razie potrzeby można go dopasować do zwracania listy dla każdej grupy przechwytywania. Po prostu wyodrębnij zapytanie group
lub groups
atrybut obiektu dopasowującego.
Dla str.extractall
zmienić p.search
się p.findall
.
Wyodrębnianie ciągów
Rozważmy prostą operację filtrowania. Chodzi o to, aby wyodrębnić 4 cyfry, jeśli jest poprzedzone dużą 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 w całości) poniższych postów.
Wniosek
Jak pokazano na powyższych przykładach, iteracja wyróżnia się podczas pracy z małymi wierszami ramek DataFrame, mieszanymi typami danych i wyrażeniami regularnymi.
Przyspieszenie, które uzyskasz, zależy od danych i problemu, więc przebieg może się różnić. Najlepszą rzeczą do zrobienia jest dokładne przeprowadzenie testów i sprawdzenie, czy wypłata jest warta wysiłku.
Funkcje zwektoryzowane wyróżniają się prostotą i czytelnością, więc jeśli wydajność nie jest krytyczna, zdecydowanie powinieneś je preferować.
Inna uwaga na marginesie, niektóre operacje na łańcuchach dotyczą ograniczeń, które sprzyjają używaniu NumPy. Oto dwa przykłady, w których ostrożna wektoryzacja NumPy przewyższa Pythona:
Ponadto czasami po prostu operowanie na bazowych tablicach za pośrednictwem, .values
w przeciwieństwie do Serii lub Ramek DataFrames, może zapewnić wystarczające przyspieszenie w większości typowych scenariuszy (patrz uwaga w sekcji Porównanie liczbowe powyżej). Na przykład df[df.A.values != df.B.values]
pokazałby natychmiastowy wzrost wydajności df[df.A != df.B]
. Używanie .values
może nie być odpowiednie w każdej sytuacji, ale warto o tym wiedzieć.
Jak wspomniano powyżej, do Ciebie należy decyzja, czy warto wdrożyć te rozwiązania.
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
)
pd.Series
apd.DataFrame
teraz obsługuje konstrukcję z elementów iterable. Oznacza to, że można po prostu przekazać generator języka Python do funkcji konstruktora, zamiast najpierw konstruować listę (przy użyciu funkcji list złożonych), co w wielu przypadkach może być wolniejsze. Jednak wielkości mocy generatora nie można wcześniej określić. Nie jestem pewien, ile czasu / pamięci mogłoby to spowodować.