python - Quando dovrei mai voler usare Panda Applica() nel mio codice?




pandas performance (3)

Questo è un QnA a risposta automatica pensato per istruire gli utenti sulle insidie ​​e sui vantaggi dell'applicazione.

Ho visto molte risposte postate su domande su Stack Overflow che prevedono l'uso di apply. Ho anche visto gli utenti commentare sotto di loro dicendo che " apply è lento" e dovrebbe essere evitato ".

Ho letto molti articoli sull'argomento delle prestazioni che spiegano che apply è lento. Ho anche visto un disclaimer nei documenti su come apply sia semplicemente una funzione di convenienza per il passaggio di UDF (non riesco a trovarlo ora). Pertanto, il consenso generale è che l' apply dovrebbe essere evitata, se possibile. Tuttavia, ciò solleva le seguenti domande:

  1. Se si apply così male, allora perché è nell'API?
  2. Come e quando devo applicare il mio codice -free?
  3. Ci sono mai situazioni in cui apply è buono (meglio di altre possibili soluzioni)?

Tutte le apply non sono uguali

La tabella seguente suggerisce quando considerare l' apply 1 . Verde significa probabilmente efficiente; il rosso evita.

Parte di questo è intuitivo: pd.Series.apply è un loop di riga a livello di Python, idem pd.DataFrame.apply riga (saggio axis=1 ). Gli abusi di questi sono molti e di ampia portata. L'altro post si occupa di loro in modo più approfondito. Le soluzioni più diffuse sono l'uso di metodi vettoriali, la comprensione di elenchi (presuppone dati puliti) o strumenti efficienti come il costruttore pd.DataFrame (ad es. Per evitare di apply(pd.Series) ).

Se si utilizza pd.DataFrame.apply riga, è spesso utile specificare raw=True (ove possibile). In questa fase, numba è di solito una scelta migliore.

GroupBy.apply : generalmente preferito

La ripetizione groupby operazioni di groupby per evitare l' apply danneggerà le prestazioni. GroupBy.apply solito va bene qui, a condizione che i metodi che usi nella tua funzione personalizzata siano essi stessi vettorializzati. A volte non esiste un metodo Pandas nativo per un'aggregazione di gruppo che si desidera applicare. In questo caso, per un numero limitato di gruppi apply una funzione personalizzata può comunque offrire prestazioni ragionevoli.

pd.DataFrame.apply column-wise: un miscuglio

pd.DataFrame.apply colonna ( axis=0 ) è un caso interessante. Per un numero limitato di righe rispetto a un numero elevato di colonne, è quasi sempre costoso. Per un gran numero di righe relative alle colonne, il caso più comune, a volte potresti vedere significativi miglioramenti delle prestazioni usando 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 leggermente sovraperformare df['col'].astype(str) .
  2. df.apply(pd.to_datetime) lavorando su stringhe non si df.apply(pd.to_datetime) bene con le righe rispetto a un normale ciclo for .

apply la funzione Convenienza che non hai mai avuto bisogno

Iniziamo affrontando le domande nel PO, una per una.

" Se si applica così male, allora perché è nell'API? "

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

Alcune delle cose che si apply possono fare:

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

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

Quindi, con tutte queste caratteristiche, perché apply male? È perché apply è lento . Panda non fa ipotesi sulla natura della tua funzione e quindi applica iterativamente la tua funzione a ciascuna riga / colonna, se necessario. Inoltre, gestire tutte le situazioni sopra indicate significa apply comporta un notevole sovraccarico ad ogni iterazione. Inoltre, apply consuma molta più memoria, il che rappresenta una sfida per le applicazioni con limiti di memoria.

Ci sono pochissime situazioni in cui apply è appropriato da apply (più su quello che segue). Se non sei sicuro di utilizzare l' apply , probabilmente non dovresti.

Affrontiamo la prossima domanda.

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

Per riformulare, ecco alcune situazioni comuni in cui vorrai liberarti di qualsiasi chiamata da apply .

Dati numerici

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

Contrasta le prestazioni di apply per 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

Per quanto riguarda le prestazioni, non c'è paragone, l'equivalente citonizzato è molto più veloce. Non è necessario 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 si abilita il passaggio di array grezzi con l'argomento raw , è 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, cercare alternative vettorializzate se possibile.

String / Regex

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

Un problema comune è verificare 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

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

Usando apply, 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 la comprensione dell'elenco.

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 quelle apply , a causa del sovraccarico inferiore. Se è necessario gestire NaN e tipi non validi, è possibile basarsi su una funzione personalizzata che è possibile chiamare con argomenti all'interno della comprensione dell'elenco.

Per ulteriori informazioni su quando la comprensione dell'elenco dovrebbe essere considerata una buona opzione, consultare il mio articolo di scrittura: Per i loop con i panda - Quando dovrebbe importarmene? .

Nota
Le operazioni relative a data e ora hanno anche versioni vettoriali. Quindi, per esempio, dovresti preferire pd.to_datetime(df['date']) , df['date'].apply(pd.to_datetime) .

Maggiori informazioni sui docs .

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 è buono? "

Applicare è una funzione di convenienza, quindi ci sono situazioni in cui l'overhead è abbastanza trascurabile da perdonare. Dipende davvero da quante volte viene chiamata la funzione.

Funzioni vettorizzate per serie, ma non per DataFrames
Cosa succede se si desidera applicare un'operazione di stringa su più colonne? Cosa succede se si desidera 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

Si noti che avrebbe anche senso stack o semplicemente usare un ciclo esplicito. Tutte queste opzioni sono leggermente più veloci rispetto all'utilizzo di 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 operazioni su stringa o 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 : astype contro si apply

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

Il grafico è stato tracciato utilizzando la libreria perfplot .

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 float, vedo che il astype è costantemente più veloce o leggermente più veloce di quanto si 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.apply non è stato discusso finora, ma GroupBy.apply è anche una funzione di convenienza iterativa per gestire tutto ciò che le funzioni GroupBy esistenti non lo fanno.

Un requisito comune è quello di 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 chiamate di gruppo successive 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 accorciarlo per 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 chiamata di gruppo (poiché anche il groupby è piuttosto costoso).

Altri avvertimenti

A parte le avvertenze di cui sopra, vale anche la pena ricordare che apply opera sulla prima riga (o colonna) due volte. Questo viene fatto per determinare se la funzione ha effetti collaterali. In caso contrario, apply potrebbe essere in grado di utilizzare un percorso rapido per la valutazione del risultato, altrimenti si ricorre a 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 riscontra anche in GroupBy.apply nelle versioni panda <0,25 (è stato corretto per 0,25, vedere qui per ulteriori informazioni ).


Vorrei aggiungere i miei due centesimi:

Ci sono mai situazioni in cui applicare è buono? Si Qualche volta.

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

Aggiornare
Non sostenevo in alcun modo l'uso di apply , pensando solo dal momento che il numpy non può affrontare la situazione di cui sopra, avrebbe potuto essere un buon candidato per pandas apply . Ma stavo dimenticando la semplice comprensione dell'elenco grazie al promemoria di @jpp.





apply