Czy pętle for w pandach są naprawdę złe? Kiedy powinno mnie to obchodzić?

106

Czy forpętle są naprawdę „złe”? Jeśli nie, w jakiej sytuacji byłyby one lepsze niż stosowanie bardziej konwencjonalnego podejścia „wektoryzowanego”? 1

Jestem zaznajomiony z pojęciem „wektoryzacji” i jak pandy wykorzystują techniki wektoryzacji, aby przyspieszyć obliczenia. Funkcje wektoryzowane rozgłaszają operacje w całej serii lub ramce DataFrame, aby uzyskać znacznie większe przyspieszenia niż konwencjonalne iterowanie danych.

Jednak jestem dość zaskoczony, widząc dużo kodu (w tym z odpowiedzi na Stack Overflow) oferującego rozwiązania problemów, które obejmują zapętlanie danych za pomocą forpętli i list składanych. Dokumentacja i API mówią, że pętle są „złe” i że nie powinno się „nigdy” iterować po 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 ​​istnieją bardzo specyficzne sytuacje, w których forpętle są zwykle lepsze niż konwencjonalne iterowanie po danych. Ten post ma na celu uchwycenie tego dla potomności.

cs95
źródło

Odpowiedzi:

144

TLDR; Nie, forpę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:

  1. Kiedy Twoje dane są małe (... w zależności od tego, co robisz),
  2. W przypadku object/ mieszanych typów
  3. Podczas korzystania z strfunkcji 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

  1. Wyrównanie indeksu / osi
  2. Obsługa mieszanych typów danych
  3. 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).

forz drugiej strony pętle są szybsze niż myślisz. Jeszcze lepsze jest to, że listy składane (które tworzą listy za pomocą forpę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 seqjest seria pand lub kolumna DataFrame. Lub, gdy operujesz na wielu kolumnach,

[f(x, y) for x, y in zip(seq1, seq2)]

Gdzie seq1i seq2są 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 perfplotpakietu do przeprowadzenia testów w tym poście. Poniżej podano terminy wykonania powyższych operacji:

wprowadź opis obrazu tutaj

Zrozumienie listy przewyższa wyniki querydla ś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.uniquei 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

wprowadź opis obrazu tutaj

Wyniki są wyraźniejsze, Counterwygrywają z obiema metodami wektoryzowanymi dla większego zakresu małych N (~ 3500).

Uwaga
Więcej ciekawostek (dzięki uprzejmości @ user2357112). CounterJest 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ż forpę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

wprowadź opis obrazu tutaj

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

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

wprowadź opis obrazu tutaj

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

wprowadź opis obrazu tutaj

Indeksowanie pozycyjny Lista
taktowanie dla 3 operacji, które wyodrębnić 0TH elementu z listy kolumn (obsługa wyjątków) map, str.getakcesor 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.

wprowadź opis obrazu tutaj

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

wprowadź opis obrazu tutaj

Zarówno itertools.chain.from_iterableskładanie list zagnieżdżonych, jak i lista zagnieżdżona są konstrukcjami czystego języka Python i skalują się znacznie lepiej niż stackrozwią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 applyna tych rozwiązaniach, ponieważ wypaczyłoby to wykres (tak, to jest tak wolne).


Operacje na wyrażeniach regularnych i .strmetody dostępu

Pandy mogą stosować operacje takie jak regex str.contains, str.extracti 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.containswyglą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']]

matcherFunkcja jest bardzo rozciągliwe. W razie potrzeby można go dopasować do zwracania listy dla każdej grupy przechwytywania. Po prostu wyodrębnij zapytanie grouplub groupsatrybut obiektu dopasowującego.

Dla str.extractallzmienić p.searchsię 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

wprowadź opis obrazu tutaj

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, .valuesw 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 .valuesmoż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
)
cs95
źródło
pd.Seriesa pd.DataFrameteraz 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ć.
GZ0
@ GZ0 IIRC, akceptowanie iteratorów jest nowszym dodatkiem do API. Jeśli chodzi o „to znaczy, że można po prostu przekazać generator Pythona do funkcji konstruktora, zamiast najpierw tworzyć listę”, nie zgadzam się. Może pamięć, wydajność nie. Z mojego doświadczenia wynika, że ​​zwykle prawie zawsze szybciej jest wygenerować listę i przekazać ją dalej. Lista kompilacji FTW.
cs95
@ cs95 Dlaczego nie uważasz, że generatory mogą prowadzić do wzrostu wydajności? A może zrobiłeś jakieś testy tego?
GZ0
@ GZ0 Nie powiedziałem, że generatory nie prowadzą do wzrostu wydajności, mówię, że nie zyskujesz tak dużo, jak gdybyś korzystał z list składanych. Istnieją narzuty związane z inicjalizacją genexpsów, utrzymywaniem stanu itp., Których nie mają kompilacje list. Jest tutaj wiele przykładów ze składanymi listami, które można porównać ze słowami generatora. Oto przykład można uruchomić teraz: 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 Prawdopodobnie masz rację. Właśnie rzuciłem okiem na kod konstruktora programu pd.Seriesi okazało się, że jeśli nie można określić rozmiaru iterowalnych danych wyjściowych, lista i tak zostałaby skonstruowana z iterowalnego elementu. W większości przypadków użycie generatorów nie przyniesie korzyści. Jednak kiedy sprawdziłem przykłady, które podałeś, wersja generatora jest w rzeczywistości wolniejsza. Nie jestem pewien, skąd bierze się różnica.
GZ0
1

W skrócie

  • for loop + iterrowsjest bardzo wolny. Narzut nie jest znaczący w przypadku ~ 1 tys. Wierszy, ale jest zauważalny w przypadku ponad 10 tys. Wierszy.
  • for loop + itertuplesjest znacznie szybszy niż iterrowslub apply.
  • wektoryzacja jest zwykle znacznie szybsza niż itertuples

Reper wprowadź opis obrazu tutaj

artoby
źródło