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.apply
i Series.apply
są wygodnymi funkcjami zdefiniowanymi odpowiednio w obiekcie DataFrame i Series. apply
akceptuje dowolną funkcję zdefiniowaną przez użytkownika, która stosuje transformację / agregację w DataFrame. apply
jest 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
agg
lub transform
w takich przypadkach)
- Wykonuj transformacje oparte na elementach
- Rozgłaszaj zagregowane wyniki do oryginalnych wierszy (patrz
result_type
argument).
- 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 apply
jest 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 apply
wiąże się z pewnym dużym obciążeniem przy każdej iteracji. Ponadto apply
zużywa dużo więcej pamięci, co jest wyzwaniem dla aplikacji ograniczonych do pamięci.
Istnieje bardzo niewiele sytuacji, w których apply
jest 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, apply
aby 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 raw
argumentem, 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 apply
powodu 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ż stack
uż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
: astype
versusapply
Wydaje się, że jest to cecha charakterystyczna API. Użycie apply
do konwersji liczb całkowitych w serii na ciąg jest porównywalne (i czasami szybsze) niż użycie astype
.
Wykres został wykreślony przy użyciu perfplot
biblioteki.
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 astype
jest 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.apply
nie było omawiane do tej pory, ale GroupBy.apply
jest to również iteracyjna funkcja wygodna do obsługi wszystkiego, GroupBy
czego 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 apply
jest to akceptowalne rozwiązanie, jeśli celem jest zmniejszenie liczby groupby
połączeń (ponieważ groupby
jest również dość drogie).
Inne zastrzeżenia
Oprócz powyższych zastrzeżeń warto również wspomnieć, że apply
operuje na pierwszym rzędzie (lub kolumnie) dwukrotnie. Ma to na celu określenie, czy funkcja ma jakiekolwiek skutki uboczne. Jeśli nie, apply
moż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.apply
na pandach w wersjach <0.25 (zostało to naprawione dla 0.25, zobacz tutaj, aby uzyskać więcej informacji ).
returns.add(1).apply(np.log)
vs.np.log(returns.add(1)
to przypadek, w którymapply
generalnie będzie nieznacznie szybszy, co stanowi dolne prawe zielone pole na poniższym diagramie jpp.