Quando dovrei mai voler usare pandas apply () nel mio codice?


111

Ho visto molte risposte pubblicate a domande su Stack Overflow che coinvolgono l'uso del metodo Pandas apply. Ho anche visto utenti commentare sotto di loro dicendo che " applyè lento e dovrebbe essere evitato".

Ho letto molti articoli sull'argomento delle prestazioni che spiegano applyè lento. Ho anche visto un disclaimer nei documenti su come applysia semplicemente una funzione comoda per il passaggio di UDF (non riesco a trovarlo ora). Quindi, il consenso generale è che applydovrebbe essere evitato se possibile. Tuttavia, questo solleva le seguenti domande:

  1. Se applyè così grave, perché è nell'API?
  2. Come e quando dovrei rendere il mio codice libero apply?
  3. Ci sono mai situazioni in cui applyè buono (meglio di altre possibili soluzioni)?

1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)è un caso in cui applysarà generalmente leggermente più veloce, che è la casella verde in basso a destra nel diagramma di jpp sotto.
Alexander

@Alexander, grazie. Non ho sottolineato in modo esaustivo queste situazioni, ma sono utili da sapere!
cs95

Risposte:


108

apply, la comoda funzione di cui non hai mai avuto bisogno

Iniziamo affrontando le domande nel PO, una per una.

" Se l' applicazione è così negativa, perché è nell'API? "

DataFrame.applye Series.applysono funzioni utili definite rispettivamente sull'oggetto DataFrame e Series. applyaccetta qualsiasi funzione definita dall'utente che applica una trasformazione / aggregazione su un DataFrame. applyè effettivamente un proiettile d'argento che fa tutto ciò che qualsiasi funzione esistente dei panda non può fare.

Alcune cose applypossono fare:

  • Esegui qualsiasi funzione definita dall'utente su un DataFrame o una serie
  • Applicare una funzione per riga ( axis=1) o per colonna ( axis=0) su un DataFrame
  • Eseguire l'allineamento dell'indice durante l'applicazione della funzione
  • Eseguire l'aggregazione con funzioni definite dall'utente (tuttavia, di solito si preferisce aggo transformin questi casi)
  • Esegui trasformazioni in base agli elementi
  • Trasmetti i risultati aggregati alle righe originali (vedi l' result_typeargomento).
  • Accetta argomenti posizionali / parola chiave da passare alle funzioni definite dall'utente.

...Tra gli altri. Per ulteriori informazioni, vedere Applicazione di funzioni per riga o colonna nella documentazione.

Quindi, con tutte queste caratteristiche, perché è applycattivo? È perché applyè lento . Panda non fa supposizioni sulla natura della tua funzione, quindi applica in modo iterativo la tua funzione a ciascuna riga / colonna, se necessario. Inoltre, gestire tutte le situazioni di cui sopra significa applyincorrere in un notevole sovraccarico ad ogni iterazione. Inoltre, applyconsuma molta più memoria, il che rappresenta una sfida per le applicazioni limitate dalla memoria.

Ci sono pochissime situazioni in cui applyè opportuno utilizzare (più su quello sotto). Se non sei sicuro se dovresti usare apply, probabilmente non dovresti.


Affrontiamo la prossima domanda.

" Come e quando devo fare il mio codice di applicare -free? "

Per riformulare, ecco alcune situazioni comuni in cui vorresti sbarazzarti di qualsiasi chiamata a apply.

Dati numerici

Se stai lavorando con dati numerici, probabilmente esiste già una funzione cython vettorizzata che fa esattamente quello che stai cercando di fare (in caso contrario, fai una domanda su Stack Overflow o apri una richiesta di funzionalità su GitHub).

Contrasta le prestazioni di applyper una semplice operazione di aggiunta.

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

Dal punto di vista delle prestazioni, non c'è paragone, l'equivalente citato è molto più veloce. Non c'è bisogno di un grafico, perché la differenza è evidente anche per i dati dei giocattoli.

%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)

Anche se abiliti il ​​passaggio di array non elaborati con l' rawargomento, è ancora due volte più lento.

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

Un altro esempio:

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)

In generale, cerca alternative vettorializzate se possibile.

String / Regex

Pandas fornisce funzioni stringa "vettorializzate" nella maggior parte delle situazioni, ma ci sono rari casi in cui quelle funzioni non ... "si applicano", per così dire.

Un problema comune è controllare se un valore in una colonna è presente in un'altra colonna della stessa riga.

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

Questo dovrebbe restituire la seconda e la terza riga, poiché "donald" e "minnie" sono presenti nelle rispettive colonne "Title".

Usando applica, questo sarebbe fatto usando

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

Tuttavia, esiste una soluzione migliore utilizzando le liste di comprensione.

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)

La cosa da notare qui è che le routine iterative sono più veloci di apply, a causa dell'overhead inferiore. Se hai bisogno di gestire NaN e dtype non validi, puoi costruire su questo utilizzando una funzione personalizzata che puoi quindi chiamare con argomenti all'interno della comprensione dell'elenco.

Per ulteriori informazioni su quando la comprensione degli elenchi dovrebbe essere considerata una buona opzione, vedere il mio articolo: For loops with pandas - When should I care? .

Nota Anche le
operazioni data e data / ora hanno versioni vettoriali. Così, per esempio, si dovrebbe preferire pd.to_datetime(df['date']), oltre, dire df['date'].apply(pd.to_datetime).

Per saperne di più sui documenti .

Una trappola comune: colonne di elenchi che esplodono

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

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

Le persone sono tentate di usare apply(pd.Series). Questo è orribile in termini di prestazioni.

s.apply(pd.Series)

   0  1
0  1  2
1  1  2
2  1  2

Un'opzione migliore è elencare la colonna e passarla a 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)

Infine,

" Ci sono situazioni in cui apply va bene? "

Applica è una funzione comoda, quindi ci sono situazioni in cui il sovraccarico è abbastanza trascurabile da perdonare. Dipende davvero da quante volte viene chiamata la funzione.

Funzioni che sono vettorizzate per le serie, ma non per i DataFrame
E se si desidera applicare un'operazione di stringa su più colonne? E se volessi convertire più colonne in datetime? Queste funzioni sono vettorializzate solo per le serie, quindi devono essere applicate su ogni colonna su cui si desidera convertire / operare.

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

Questo è un caso ammissibile per apply:

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

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

Nota che avrebbe anche senso stack, o semplicemente usare un ciclo esplicito. Tutte queste opzioni sono leggermente più veloci dell'uso apply, ma la differenza è abbastanza piccola da perdonare.

%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)

È possibile creare un caso simile per altre operazioni come le operazioni sulle stringhe o la conversione in categoria.

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

v / s

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)

E così via...

Conversione di serie in str: astypeversusapply

Sembra un'idiosincrasia dell'API. L'utilizzo applyper convertire interi in una serie in stringa è paragonabile (e talvolta più veloce) rispetto all'utilizzo astype.

inserisci qui la descrizione dell'immagine Il grafico è stato tracciato utilizzando la perfplotlibreria.

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())

Con i galleggianti, vedo che astypeè costantemente veloce come, o leggermente più veloce di apply. Quindi questo ha a che fare con il fatto che i dati nel test sono di tipo intero.

GroupBy operazioni con trasformazioni concatenate

GroupBy.applynon è stata discussa fino ad ora, ma GroupBy.applyè anche una comoda funzione iterativa per gestire tutto ciò che le GroupByfunzioni esistenti non fanno.

Un requisito comune è eseguire un GroupBy e quindi due operazioni principali come un "cumsum ritardato":

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

Avresti bisogno di due successive chiamate di gruppo qui:

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

Usando apply, puoi abbreviare questo a una singola chiamata.

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

È molto difficile quantificare le prestazioni perché dipende dai dati. Ma in generale, applyè una soluzione accettabile se l'obiettivo è ridurre una groupbychiamata (perché groupbyè anche piuttosto costosa).


Altri avvertimenti

A parte gli avvertimenti sopra menzionati, vale anche la pena ricordare che applyopera due volte sulla prima riga (o colonna). Questo viene fatto per determinare se la funzione ha effetti collaterali. In caso contrario, applypotrebbe essere in grado di utilizzare un percorso veloce per valutare il risultato, altrimenti ricade su un'implementazione lenta.

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

Questo comportamento si vede anche nelle GroupBy.applyversioni di Panda <0.25 (è stato corretto per 0.25, vedi qui per maggiori informazioni .)


Penso che dobbiamo stare attenti .. %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')sicuramente dopo la prima iterazione sarà molto più veloce dato che ti stai convertendo datetimein ... datetime?
jpp

@jpp avevo la stessa preoccupazione. Ma è comunque necessario eseguire una scansione lineare in entrambi i casi, chiamare to_datetime su stringhe è veloce quanto chiamarli su oggetti datetime, se non più velocemente. I tempi del ballpark sono gli stessi. L'alternativa sarebbe implementare alcuni passaggi di pre-copia per ogni soluzione a tempo che toglie il punto principale. Ma è una preoccupazione valida.
cs95

"Invocare to_datetimestringhe è veloce come su ... datetimeoggetti" .. davvero? Ho incluso la creazione di dataframe (costo fisso) nei tempi applyvs forloop e la differenza è molto minore.
jpp

@jpp Bene, questo è quello che ho ottenuto dai miei test (certamente limitati). Sono sicuro che dipenda dai dati, ma l'idea generale è che a scopo illustrativo la differenza è "sul serio, non preoccuparti".
cs95

1
@ cs95, felice anno nuovo!
jpp

49

Non tutti applysono uguali

Il grafico sottostante suggerisce quando considerare apply1 . Verde significa possibilmente efficiente; rosso evitare.

inserisci qui la descrizione dell'immagine

Alcuni di questi sono intuitivi: pd.Series.applyè un ciclo per riga a livello di Python, idem per pd.DataFrame.applyriga ( axis=1). Gli abusi di questi sono molti e di ampia portata. L'altro post li tratta in modo più approfondito. Le soluzioni più diffuse consistono nell'usare metodi vettorizzati, comprensioni di elenchi (presuppone dati puliti) o strumenti efficienti come il pd.DataFramecostruttore (ad esempio da evitare apply(pd.Series)).

Se si utilizza la pd.DataFrame.applyriga per riga, la specifica raw=True(ove possibile) è spesso vantaggiosa. In questa fase, di numbasolito è una scelta migliore.

GroupBy.apply: generalmente favorito

Ripetere le groupbyoperazioni da evitare applydanneggerà le prestazioni. GroupBy.applydi solito va bene qui, a condizione che i metodi utilizzati nella funzione personalizzata siano essi stessi vettorizzati. A volte non esiste un metodo Pandas nativo per un'aggregazione groupwise che desideri applicare. In questo caso, per un numero limitato di gruppi applycon una funzione personalizzata può comunque offrire prestazioni ragionevoli.

pd.DataFrame.apply a livello di colonna: una borsa mista

pd.DataFrame.applycolumn-wise ( axis=0) è un caso interessante. Per un numero ridotto di righe rispetto a un numero elevato di colonne, è quasi sempre costoso. Per un numero elevato di righe relative alle colonne, il caso più comune, a volte potresti vedere miglioramenti significativi delle prestazioni utilizzando 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 Ci sono eccezioni, ma di solito sono marginali o non comuni. Un paio di esempi:

  1. df['col'].apply(str)potrebbe sovraperformare leggermente df['col'].astype(str).
  2. df.apply(pd.to_datetime)lavorare sulle stringhe non si adatta bene alle righe rispetto a un forciclo normale .

2
Grazie per il contributo, apprezza le molteplici prospettive :) +1
cs95

1
@coldspeed, grazie, non c'è niente di sbagliato nel tuo post (a parte alcuni benchmark contraddittori rispetto al mio, ma potrebbe essere basato su input o configurazione). Ho appena sentito che esiste un modo diverso di guardare al problema.
jpp

@jpp Ho sempre usato il tuo eccellente diagramma di flusso come guida fino a quando ho visto oggi che una rigaapply è significativamente più veloce della mia soluzione con any. Qualche idea su questo?
Stef

1
@jpp: Hai ragione: per a 1 milione di righe x 100 cols anyè di circa 100 volte più veloce rispetto apply. Ha fatto i miei primi test con 2000 righe x 1000 colonne e qui è applystato due volte più veloce diany
Stef

1
@jpp vorrei usare la tua immagine in una presentazione / articolo. Ti va bene? Citerò ovviamente la fonte. Grazie
Erfan

3

Per axis=1(cioè le funzioni per riga) allora puoi semplicemente usare la seguente funzione al posto di apply. Mi chiedo perché questo non sia il pandascomportamento. (Non testato con indici composti, ma sembra essere molto più veloce di 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)

Sono rimasto molto sorpreso di scoprire che questo mi ha dato prestazioni migliori in alcuni casi. È stato particolarmente utile quando avevo bisogno di fare più cose, ciascuna con un diverso sottoinsieme di valori di colonna. La risposta "Tutte le domande non sono uguali" potrebbe aiutare a capire quando è probabile che sia d'aiuto, ma non è molto difficile testare su un campione dei tuoi dati.
denson

Alcuni suggerimenti: per le prestazioni una comprensione della lista supererebbe il ciclo for; zip(df, row[1:])è sufficiente qui; in realtà, in questa fase, considera numbase func è un calcolo numerico. Vedi questa risposta per una spiegazione.
jpp

@jpp - se hai una funzione migliore, condividila. Penso che questo sia abbastanza vicino all'ottimale dalla mia analisi. Sì numbaè più veloce, faster_df_applyè pensato per le persone che vogliono solo qualcosa di equivalente, ma più veloce di DataFrame.apply(che è stranamente lento).
Pete Cacioppi

2

Ci sono mai situazioni in cui applyva bene? Si Qualche volta.

Attività: decodificare le stringhe 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

Aggiornamento
non stavo affatto sostenendo l'uso di apply, solo pensando che dal momento che NumPynon può affrontare la situazione di cui sopra, avrebbe potuto essere un buon candidato per pandas apply. Ma stavo dimenticando la semplice comprensione della lista grazie al promemoria di @jpp.


Beh no. Come è meglio di [unidecode.unidecode(x) for x in s]o list(map(unidecode.unidecode, s))?
jpp

1
Dato che era già una serie di panda, ero tentato di usare applica, sì hai ragione, è meglio usare lista-comp piuttosto che applicare, ma il voto negativo è stato un po 'duro, non stavo sostenendo, ho applysolo pensato che avrebbe potuto essere un bene caso d'uso.
astro123
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.