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

Jestem zaznajomiony z pojęciem "wektoryzacji" i tym, jak pandy wykorzystują wektoryzowane techniki, aby przyspieszyć obliczenia. Wektorowe funkcje transmitują operacje na całej serii lub ramce danych, aby osiągnąć przyspieszenie znacznie większe niż konwencjonalna iteracja danych.

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.

Author: smci, 2019-01-03

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: {]}

  1. gdy Twoje dane są małe (...w zależności od tego, co robisz),
  2. gdy mamy do czynienia z object / mieszanymi dtypami
  3. W tym celu należy użyć funkcji regex accessor.]}
Przyjrzyjmy się tym sytuacjom indywidualnie.

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

  1. indeks/wyrównanie osi
  2. Obsługa mieszanych typów danych
  3. 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:

Tutaj wpisz opis obrazka

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ę jako

df[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

Tutaj wpisz opis obrazka

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ętla for. 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

Tutaj wpisz opis obrazka

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

Tutaj wpisz opis obrazka

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

Tutaj wpisz opis obrazka

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.

Tutaj wpisz opis obrazka

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

Tutaj wpisz opis obrazka

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

Tutaj wpisz opis obrazka

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
)
 165
Author: cs95,
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 lub apply.
  • wektoryzacja jest zwykle znacznie szybsza niż itertuples

Benchmark Tutaj wpisz opis obrazka

 1
Author: artoby,
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