Kiedy powinienem kiedykolwiek chcieć używać pandy Apply () w moim kodzie?


111

Widziałem wiele odpowiedzi opublikowanych na pytania dotyczące przepełnienia stosu, w których zastosowano metodę Pandy apply. Widziałem również użytkowników komentujących pod nimi, mówiąc, że „ applyjest powolny i należy go unikać”.

Przeczytałem wiele artykułów na temat wydajności, które wyjaśniają, że applyjest powolny. Widziałem również zastrzeżenie w dokumentach o tym, jak applyjest to po prostu wygodna funkcja przekazywania UDF (nie wydaje się, aby tego teraz znaleźć). Tak więc ogólny konsensus jest taki, że w applymiarę możliwości należy unikać. Rodzi to jednak następujące pytania:

  1. Jeśli applyjest tak źle, to dlaczego jest w API?
  2. Jak i kiedy powinienem zwolnić mój kod apply?
  3. Czy są kiedykolwiek sytuacje, w których applyjest dobrze (lepiej niż inne możliwe rozwiązania)?

1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)to przypadek, w którym applygeneralnie będzie nieznacznie szybszy, co stanowi dolne prawe zielone pole na poniższym diagramie jpp.
Alexander,

@ Aleksander dzięki. Nie wskazałem wyczerpująco tych sytuacji, ale warto o nich wiedzieć!
cs95

Odpowiedzi:


108

apply, wygodna funkcja, której nigdy nie potrzebujesz

Rozpoczynamy od odpowiedzi na pytania w PO, jeden po drugim.

Jeżeli zastosowanie jest tak źle, to dlaczego go w API?

DataFrame.applyi Series.applywygodnymi funkcjami zdefiniowanymi odpowiednio w obiekcie DataFrame i Series. applyakceptuje dowolną funkcję zdefiniowaną przez użytkownika, która stosuje transformację / agregację w DataFrame. applyjest w rzeczywistości srebrną kulą, która robi wszystko, czego żadna istniejąca funkcja pandy nie może zrobić.

Oto niektóre z rzeczy apply:

  • Uruchom dowolną funkcję zdefiniowaną przez użytkownika w DataFrame lub Series
  • Zastosuj funkcję row-wise ( axis=1) lub column-wise ( axis=0) w DataFrame
  • Wykonaj wyrównanie indeksu podczas stosowania funkcji
  • Wykonuj agregację za pomocą funkcji zdefiniowanych przez użytkownika (jednak zazwyczaj preferujemy agglub transformw takich przypadkach)
  • Wykonuj transformacje oparte na elementach
  • Rozgłaszaj zagregowane wyniki do oryginalnych wierszy (patrz result_typeargument).
  • Akceptuj argumenty pozycyjne / słowa kluczowe, aby przekazać je do funkcji zdefiniowanych przez użytkownika.

...Pośród innych. Aby uzyskać więcej informacji, zobacz Aplikacja funkcji wierszowej lub kolumnowej w dokumentacji.

Więc przy tych wszystkich funkcjach, dlaczego jest applyźle? To dlatego, że applyjest powolny . Pandy nie przyjmują żadnych założeń co do natury twojej funkcji, więc stosuj ją iteracyjnie do każdego wiersza / kolumny, jeśli to konieczne. Ponadto obsługa wszystkich powyższych sytuacji applywiąże się z pewnym dużym obciążeniem przy każdej iteracji. Ponadto applyzużywa dużo więcej pamięci, co jest wyzwaniem dla aplikacji ograniczonych do pamięci.

Istnieje bardzo niewiele sytuacji, w których applyjest stosowanie (więcej o tym poniżej). Jeśli nie masz pewności, czy powinieneś używać apply, prawdopodobnie nie powinieneś.


Odpowiedzmy na następne pytanie.

Jak i kiedy mam sprawić, aby mój kod był stosowany bez ograniczeń ?

Mówiąc inaczej, oto kilka typowych sytuacji, w których będziesz chciał pozbyć się wszelkich połączeń do apply.

Dane liczbowe

Jeśli pracujesz z danymi liczbowymi, prawdopodobnie istnieje już zwektoryzowana funkcja cython, która robi dokładnie to, co próbujesz zrobić (jeśli nie, zadaj pytanie na temat przepełnienia stosu lub otwórz żądanie funkcji w GitHub).

Porównaj działanie programu, applyaby uzyskać prostą operację dodawania.

df = pd.DataFrame({"A": [9, 4, 2, 1], "B": [12, 7, 5, 4]})
df

   A   B
0  9  12
1  4   7
2  2   5
3  1   4

df.apply(np.sum)

A    16
B    28
dtype: int64

df.sum()

A    16
B    28
dtype: int64

Jeśli chodzi o wydajność, nie ma porównania, cytonizowany odpowiednik jest znacznie szybszy. Nie ma potrzeby tworzenia wykresu, ponieważ różnica jest oczywista nawet w przypadku danych dotyczących zabawki.

%timeit df.apply(np.sum)
%timeit df.sum()
2.22 ms ± 41.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
471 µs ± 8.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Nawet jeśli włączysz przekazywanie surowych tablic z rawargumentem, nadal jest to dwa razy wolniejsze.

%timeit df.apply(np.sum, raw=True)
840 µs ± 691 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Inny przykład:

df.apply(lambda x: x.max() - x.min())

A    8
B    8
dtype: int64

df.max() - df.min()

A    8
B    8
dtype: int64

%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.max() - df.min()

2.43 ms ± 450 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
1.23 ms ± 14.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Ogólnie rzecz biorąc, jeśli to możliwe , szukaj wektoryzowanych alternatyw.

Ciąg / Regex

Pandy w większości sytuacji zapewniają „zwektoryzowane” funkcje łańcuchowe, ale są rzadkie przypadki, w których te funkcje nie ... „stosują się”, że tak powiem.

Częstym problemem jest sprawdzenie, czy wartość w kolumnie znajduje się w innej kolumnie tego samego wiersza.

df = pd.DataFrame({
    'Name': ['mickey', 'donald', 'minnie'],
    'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
    'Value': [20, 10, 86]})
df

     Name  Value                       Title
0  mickey     20                  wonderland
1  donald     10  welcome to donald's castle
2  minnie     86      Minnie mouse clubhouse

Powinno to zwrócić wiersz drugi i trzeci wiersz, ponieważ „donald” i „minnie” znajdują się w odpowiednich kolumnach „Tytuł”.

Używając Apply, można to zrobić za pomocą

df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)

0    False
1     True
2     True
dtype: bool

df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]

     Name                       Title  Value
1  donald  welcome to donald's castle     10
2  minnie      Minnie mouse clubhouse     86

Istnieje jednak lepsze rozwiązanie przy użyciu list składanych.

df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]

     Name                       Title  Value
1  donald  welcome to donald's castle     10
2  minnie      Minnie mouse clubhouse     86

%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]

2.85 ms ± 38.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
788 µs ± 16.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Należy tu zauważyć, że procedury iteracyjne są szybsze niż z applypowodu niższego narzutu. Jeśli potrzebujesz obsługiwać NaN i nieprawidłowe dtypes, możesz zbudować na tym, używając niestandardowej funkcji, którą możesz następnie wywołać z argumentami wewnątrz listy.

Aby uzyskać więcej informacji na temat tego, kiedy listy składane powinny być uważane za dobrą opcję, zobacz mój zapis: W przypadku pętli z pandami - kiedy powinno mnie to obchodzić? .

Uwaga
Operacje typu data i data-godzina również mają wersje wektorowe. Tak więc, na przykład, powinien wolisz pd.to_datetime(df['date']), nad, powiedzmy, df['date'].apply(pd.to_datetime).

Przeczytaj więcej w docs .

A Common Pitfall: Exploding Columns of Lists

s = pd.Series([[1, 2]] * 3)
s

0    [1, 2]
1    [1, 2]
2    [1, 2]
dtype: object

Ludzie są kuszeni, aby używać apply(pd.Series). To jest okropne pod względem wydajności.

s.apply(pd.Series)

   0  1
0  1  2
1  1  2
2  1  2

Lepszą opcją jest wyświetlenie kolumny i przekazanie jej do pd.DataFrame.

pd.DataFrame(s.tolist())

   0  1
0  1  2
1  1  2
2  1  2

%timeit s.apply(pd.Series)
%timeit pd.DataFrame(s.tolist())

2.65 ms ± 294 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
816 µs ± 40.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

W końcu,

Czy są jakieś sytuacje, w których apply jest dobrze?

Zastosuj to funkcja wygodna, więc zdarzają się sytuacje, w których koszty ogólne są na tyle znikome, że można je wybaczyć. To naprawdę zależy od tego, ile razy funkcja jest wywoływana.

Funkcje wektoryzowane dla serii, ale nie ramki danych
Co zrobić, jeśli chcesz zastosować operację na łańcuchach na wielu kolumnach? A co jeśli chcesz przekonwertować wiele kolumn na datę i godzinę? Te funkcje są wektoryzowane tylko dla serii, więc należy je zastosować do każdej kolumny, na której chcesz konwertować / operować.

df = pd.DataFrame(
         pd.date_range('2018-12-31','2019-01-31', freq='2D').date.astype(str).reshape(-1, 2), 
         columns=['date1', 'date2'])
df

       date1      date2
0 2018-12-31 2019-01-02
1 2019-01-04 2019-01-06
2 2019-01-08 2019-01-10
3 2019-01-12 2019-01-14
4 2019-01-16 2019-01-18
5 2019-01-20 2019-01-22
6 2019-01-24 2019-01-26
7 2019-01-28 2019-01-30

df.dtypes

date1    object
date2    object
dtype: object

Jest to dopuszczalny przypadek dla apply:

df.apply(pd.to_datetime, errors='coerce').dtypes

date1    datetime64[ns]
date2    datetime64[ns]
dtype: object

Zauważ, że sensowne byłoby również stackużycie jawnej pętli. Wszystkie te opcje są nieco szybsze niż używanie apply, ale różnica jest na tyle mała, że ​​można ją wybaczyć.

%timeit df.apply(pd.to_datetime, errors='coerce')
%timeit pd.to_datetime(df.stack(), errors='coerce').unstack()
%timeit pd.concat([pd.to_datetime(df[c], errors='coerce') for c in df], axis=1)
%timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')

5.49 ms ± 247 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.94 ms ± 48.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.16 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.41 ms ± 1.71 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Możesz zrobić podobny przypadek dla innych operacji, takich jak operacje na łańcuchach lub konwersja do kategorii.

u = df.apply(lambda x: x.str.contains(...))
v = df.apply(lambda x: x.astype(category))

vs

u = pd.concat([df[c].str.contains(...) for c in df], axis=1)
v = df.copy()
for c in df:
    v[c] = df[c].astype(category)

I tak dalej...

Konwersja serii na str: astypeversusapply

Wydaje się, że jest to cecha charakterystyczna API. Użycie applydo konwersji liczb całkowitych w serii na ciąg jest porównywalne (i czasami szybsze) niż użycie astype.

wprowadź opis obrazu tutaj Wykres został wykreślony przy użyciu perfplotbiblioteki.

import perfplot

perfplot.show(
    setup=lambda n: pd.Series(np.random.randint(0, n, n)),
    kernels=[
        lambda s: s.astype(str),
        lambda s: s.apply(str)
    ],
    labels=['astype', 'apply'],
    n_range=[2**k for k in range(1, 20)],
    xlabel='N',
    logx=True,
    logy=True,
    equality_check=lambda x, y: (x == y).all())

W przypadku pływaków widzę, że astypejest on konsekwentnie tak szybki lub nieco szybszy niż apply. Jest to więc związane z faktem, że dane w teście są typu całkowitego.

GroupBy operacje z przekształceniami łańcuchowymi

GroupBy.applynie było omawiane do tej pory, ale GroupBy.applyjest to również iteracyjna funkcja wygodna do obsługi wszystkiego, GroupByczego nie obsługują istniejące funkcje.

Jednym z typowych wymagań jest wykonanie operacji GroupBy, a następnie dwóch głównych operacji, takich jak „lagged cumsum”:

df = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df

   A   B
0  a  12
1  a   7
2  b   5
3  c   4
4  c   5
5  c   4
6  d   3
7  d   2
8  e   1
9  e  10

Potrzebujesz tutaj dwóch kolejnych połączeń grupowych:

df.groupby('A').B.cumsum().groupby(df.A).shift()

0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64

Używając apply, możesz skrócić to do pojedynczego połączenia.

df.groupby('A').B.apply(lambda x: x.cumsum().shift())

0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64

Bardzo trudno jest oszacować wydajność, ponieważ zależy ona od danych. Ale ogólnie applyjest to akceptowalne rozwiązanie, jeśli celem jest zmniejszenie liczby groupbypołączeń (ponieważ groupbyjest również dość drogie).


Inne zastrzeżenia

Oprócz powyższych zastrzeżeń warto również wspomnieć, że applyoperuje na pierwszym rzędzie (lub kolumnie) dwukrotnie. Ma to na celu określenie, czy funkcja ma jakiekolwiek skutki uboczne. Jeśli nie, applymoże być w stanie użyć szybkiej ścieżki do oceny wyniku, w przeciwnym razie powróci do powolnej implementacji.

df = pd.DataFrame({
    'A': [1, 2],
    'B': ['x', 'y']
})

def func(x):
    print(x['A'])
    return x

df.apply(func, axis=1)

# 1
# 1
# 2
   A  B
0  1  x
1  2  y

To zachowanie jest również widoczne GroupBy.applyna pandach w wersjach <0.25 (zostało to naprawione dla 0.25, zobacz tutaj, aby uzyskać więcej informacji ).


Myślę, że musimy być ostrożni ... z %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')pewnością po pierwszej iteracji będzie to znacznie szybsze, ponieważ przechodzisz datetimena ... datetime?
jpp

@jpp Miałem ten sam problem. Ale nadal musisz wykonać skanowanie liniowe w obie strony, wywoływanie to_datetime na łańcuchach jest tak szybkie, jak wywoływanie ich w obiektach datetime, jeśli nie szybsze. Czasy boiska są takie same. Alternatywą byłoby zaimplementowanie pewnego kroku przed kopiowaniem dla każdego czasowego rozwiązania, które odbiega od głównego punktu. Ale to ważna sprawa.
cs95

„Wywoływanie to_datetimełańcuchów jest tak samo szybkie jak… datetimeobiekty”… naprawdę? Uwzględniłem tworzenie ramek danych (koszt stały) w taktowaniu w applyporównaniu do forczasów pętli i różnica jest znacznie mniejsza.
jpp

@jpp Cóż, to właśnie uzyskałem z moich (wprawdzie ograniczonych) testów. Jestem pewien, że zależy to od danych, ale ogólna idea jest taka, że ​​dla celów ilustracji różnica brzmi „poważnie, nie martw się o to”.
cs95

1
@ cs95, Szczęśliwego Nowego Roku!
jpp

49

Nie wszystkie applysą takie same

Poniższy wykres sugeruje, kiedy wziąć pod uwagę apply1 . Zielony oznacza możliwie efektywny; czerwony unikaj.

wprowadź opis obrazu tutaj

Niektóre z nich są intuicyjne: pd.Series.applyjest to pętla wierszowa na poziomie Pythona, podobnie jak pd.DataFrame.applywierszowa ( axis=1). Nadużycia ich są liczne i mają szeroki zakres. Drugi post omawia je bardziej szczegółowo. Popularnymi rozwiązaniami są metody zwektoryzowane, listy składane (zakłada czyste dane) lub wydajne narzędzia, takie jak pd.DataFramekonstruktor (np. Unikać apply(pd.Series)).

Jeśli korzystasz z pd.DataFrame.applywierszy, określenie raw=True(jeśli to możliwe) jest często korzystne. Na tym etapie numbajest zwykle lepszym wyborem.

GroupBy.apply: ogólnie preferowany

Powtarzanie groupbyoperacji, których należy unikać apply, obniża wydajność. GroupBy.applyzwykle jest w porządku, pod warunkiem, że metody używane w funkcji niestandardowej są same wektoryzowane. Czasami nie ma natywnej metody Pandas dla agregacji grupowej, którą chcesz zastosować. W takim przypadku niewielka liczba grup applyz funkcją niestandardową może nadal oferować rozsądną wydajność.

pd.DataFrame.apply kolumnowo: mieszana torba

pd.DataFrame.applykolumnowym ( axis=0) jest interesującym przypadkiem. W przypadku małej liczby wierszy w porównaniu z dużą liczbą kolumn jest to prawie zawsze drogie. W przypadku dużej liczby wierszy względem kolumn, w bardziej typowym przypadku, czasami można zauważyć znaczną poprawę wydajności przy użyciu apply:

# Python 3.7, Pandas 0.23.4
np.random.seed(0)
df = pd.DataFrame(np.random.random((10**7, 3)))     # Scenario_1, many rows
df = pd.DataFrame(np.random.random((10**4, 10**3))) # Scenario_2, many columns

                                               # Scenario_1  | Scenario_2
%timeit df.sum()                               # 800 ms      | 109 ms
%timeit df.apply(pd.Series.sum)                # 568 ms      | 325 ms

%timeit df.max() - df.min()                    # 1.63 s      | 314 ms
%timeit df.apply(lambda x: x.max() - x.min())  # 838 ms      | 473 ms

%timeit df.mean()                              # 108 ms      | 94.4 ms
%timeit df.apply(pd.Series.mean)               # 276 ms      | 233 ms

1 Są wyjątki, ale są one zwykle marginalne lub rzadkie. Kilka przykładów:

  1. df['col'].apply(str)może nieznacznie przewyższać df['col'].astype(str).
  2. df.apply(pd.to_datetime)praca na łańcuchach nie skaluje się dobrze z wierszami w porównaniu ze zwykłą forpętlą.

2
Dziękujemy za
zaangażowanie

1
@coldspeed, Dzięki, nie ma nic złego w Twoim poście (poza pewnymi sprzecznymi testami porównawczymi w porównaniu z moim, ale mogą być oparte na danych wejściowych lub konfiguracji). Po prostu czułem, że istnieje inny sposób spojrzenia na problem.
jpp

@jpp Zawsze korzystałem z doskonałego schematu blokowego jako wskazówki, dopóki nie zobaczyłem dzisiaj, że wierszowanieapply jest znacznie szybsze niż moje rozwiązanie z any. Jakieś przemyślenia na ten temat?
Stef

1
@jpp: masz rację: dla 1mio wierszy x 100 kolumn anyjest około 100 razy szybsze niż apply. Zrobił moje pierwsze testy z 2000 rzędami x 1000 kolumn i tutaj applybył dwa razy szybszy niżany
Stef

1
@jpp Chciałbym użyć Twojego obrazu w prezentacji / artykule. dobrze się z tym czujesz? Oczywiście wspomnę o źródle. Dzięki
Erfan

3

Dla axis=1(tj. Funkcji wierszowych) możesz po prostu użyć następującej funkcji zamiast apply. Zastanawiam się, dlaczego tak nie jest pandas. (Nie testowane z indeksami złożonymi, ale wydaje się być znacznie szybsze niż apply)

def faster_df_apply(df, func):
    cols = list(df.columns)
    data, index = [], []
    for row in df.itertuples(index=True):
        row_dict = {f:v for f,v in zip(cols, row[1:])}
        data.append(func(row_dict))
        index.append(row[0])
    return pd.Series(data, index=index)

Byłem bardzo zaskoczony, że w niektórych przypadkach dało mi to lepsze wyniki. Było to szczególnie przydatne, gdy potrzebowałem zrobić wiele rzeczy, każda z innym podzbiorem wartości kolumn. Odpowiedź „Wszystkie aplikacje nie są takie same” może pomóc w ustaleniu, kiedy może to pomóc, ale nie jest bardzo trudne do przetestowania na próbce danych.
denson

Kilka wskazówek: pod względem wydajności, zrozumienie listy byłoby lepsze niż pętla for; zip(df, row[1:])tutaj wystarczy; naprawdę, na tym etapie rozważ, numbaczy func jest obliczeniem numerycznym. Zobacz tę odpowiedź, aby uzyskać wyjaśnienie.
jpp

@jpp - jeśli masz lepszą funkcję, udostępnij. Myślę, że z mojej analizy jest to bardzo bliskie optymalnemu. Tak numbajest szybsze, faster_df_applyjest przeznaczone dla osób, które chcą czegoś równoważnego, ale szybszego niż DataFrame.apply(co jest dziwnie wolne).
Pete Cacioppi

2

Czy są kiedykolwiek sytuacje, w których applyjest dobrze? Tak czasami.

Zadanie: dekodowanie ciągów Unicode.

import numpy as np
import pandas as pd
import unidecode

s = pd.Series(['mañana','Ceñía'])
s.head()
0    mañana
1     Ceñía


s.apply(unidecode.unidecode)
0    manana
1     Cenia

Aktualizacja w
żadnym wypadku nie opowiadałem się za używaniem apply, po prostu myślałem od czasuNumPy nie sobie z powyższą sytuacją, mógł być dobrym kandydatem pandas apply. Ale zapomniałem o zwykłym zrozumieniu listy ol dzięki przypomnieniu @jpp.


Więc nie. Jak to jest lepsze niż [unidecode.unidecode(x) for x in s]lub list(map(unidecode.unidecode, s))?
jpp

1
Ponieważ była to już seria pand, kusiło mnie, aby użyć zastosuj, Tak, masz rację, lepiej jest użyć list-comp niż zastosować, Ale głos negatywny był trochę ostry, nie byłem zwolennikiem apply, po prostu pomyślałem, że to mogłoby być dobre przypadek użycia.
astro123
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.