I for-loop nei panda sono davvero pessimi? Quando dovrei preoccuparmi?


106

I forloop sono davvero "cattivi"? In caso contrario, in quali situazioni sarebbero migliori rispetto all'utilizzo di un approccio "vettorializzato" più convenzionale? 1

Conosco il concetto di "vettorizzazione" e il modo in cui i panda impiegano tecniche vettorializzate per accelerare il calcolo. Le funzioni vettorializzate trasmettono operazioni sull'intera serie o DataFrame per ottenere accelerazioni molto maggiori rispetto all'iterazione convenzionale sui dati.

Tuttavia, sono piuttosto sorpreso di vedere un sacco di codice (comprese le risposte su Stack Overflow) che offre soluzioni a problemi che implicano il looping dei dati utilizzando forloop e liste di comprensione. La documentazione e l'API dicono che i loop sono "cattivi" e che non si dovrebbe "mai" iterare su array, serie o DataFrame. Quindi, come mai a volte vedo utenti che suggeriscono soluzioni basate su loop?


1 - Anche se è vero che la domanda suona alquanto ampia, la verità è che ci sono situazioni molto specifiche in cui i forcicli sono generalmente migliori rispetto all'iterazione convenzionale sui dati. Questo post mira a catturare questo per i posteri.

Risposte:


144

TLDR; No, i forloop non sono "cattivi", almeno, non sempre. Probabilmente è più accurato dire che alcune operazioni vettorializzate sono più lente dell'iterazione, invece che l'iterazione è più veloce di alcune operazioni vettorizzate. Sapere quando e perché è fondamentale per ottenere le massime prestazioni dal tuo codice. In poche parole, queste sono le situazioni in cui vale la pena considerare un'alternativa alle funzioni di panda vettorializzate:

  1. Quando i tuoi dati sono piccoli (... a seconda di cosa stai facendo),
  2. Quando si ha a che fare con object/ tipi misti
  3. Quando si utilizzano le strfunzioni di accesso / regex

Esaminiamo queste situazioni individualmente.


Iterazione v / s vettorizzazione su piccoli dati

Pandas segue un approccio "Convenzione sulla configurazione" nella sua progettazione API. Ciò significa che la stessa API è stata adattata per soddisfare un'ampia gamma di dati e casi d'uso.

Quando viene chiamata una funzione panda, le seguenti cose (tra le altre) devono essere gestite internamente dalla funzione, per garantire il funzionamento

  1. Allineamento indice / asse
  2. Gestione di tipi di dati misti
  3. Gestione dei dati mancanti

Quasi tutte le funzioni dovranno occuparsene in misura variabile e questo presenta un sovraccarico . L'overhead è inferiore per le funzioni numeriche (ad esempio Series.add), mentre è più pronunciato per le funzioni di stringa (ad esempio Series.str.replace).

fori loop, d'altra parte, sono più veloci di quanto pensi. Ciò che è ancora meglio è che le comprensioni di elenchi (che creano elenchi tramite forcicli) sono ancora più veloci in quanto sono meccanismi iterativi ottimizzati per la creazione di elenchi.

Le comprensioni degli elenchi seguono lo schema

[f(x) for x in seq]

Dov'è sequna serie di panda o una colonna DataFrame. Oppure, quando si opera su più colonne,

[f(x, y) for x, y in zip(seq1, seq2)]

Dove seq1e seq2sono le colonne.

Confronto numerico
Si consideri una semplice operazione di indicizzazione booleana. Il metodo di comprensione dell'elenco è stato cronometrato rispetto a Series.ne( !=) e query. Ecco le funzioni:

# 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

Per semplicità, ho usato il perfplotpacchetto per eseguire tutti i test timeit in questo post. I tempi per le operazioni di cui sopra sono di seguito:

inserisci qui la descrizione dell'immagine

La comprensione dell'elenco supera le prestazioni queryper N di dimensioni moderate e supera anche il confronto vettorizzato non uguale per N minuscolo. Sfortunatamente, la comprensione dell'elenco scala linearmente, quindi non offre molto guadagno in termini di prestazioni per N. più grande.

Nota
Vale la pena ricordare che gran parte del vantaggio della comprensione dell'elenco deriva dal non doversi preoccupare dell'allineamento dell'indice, ma questo significa che se il codice dipende dall'allineamento dell'indicizzazione, questo si interromperà. In alcuni casi, le operazioni vettorizzate sugli array NumPy sottostanti possono essere considerate come portatrici del "meglio di entrambi i mondi", consentendo la vettorizzazione senza tutto il sovraccarico non necessario delle funzioni panda. Ciò significa che puoi riscrivere l'operazione sopra come

df[df.A.values != df.B.values]

Che supera sia i panda che gli equivalenti di comprensione delle liste: la

vettorizzazione di NumPy è fuori dallo scopo di questo post, ma vale sicuramente la pena considerare, se le prestazioni contano.

Il valore conta
Prendendo un altro esempio - questa volta, con un altro costrutto Python vaniglia che è più veloce di un ciclo for - collections.Counter. Un requisito comune è calcolare i conteggi dei valori e restituire il risultato come dizionario. Questo viene fatto con value_counts, np.uniquee 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

inserisci qui la descrizione dell'immagine

I risultati sono più pronunciati, Countervincono su entrambi i metodi vettorizzati per una gamma più ampia di piccoli N (~ 3500).

Nota
Altre curiosità (per gentile concessione di @ user2357112). Il Counterè implementato con un acceleratore C , così mentre deve ancora lavorare con pitone oggetti invece dei tipi di dati C sottostanti, è ancora più veloce di un forciclo. Potere di Python!

Ovviamente, la conclusione da qui è che le prestazioni dipendono dai dati e dal caso d'uso. Lo scopo di questi esempi è convincerti a non escludere queste soluzioni come opzioni legittime. Se questi continuano a non darti le prestazioni di cui hai bisogno, ci sono sempre cython e numba . Aggiungiamo questo test al mix.

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

inserisci qui la descrizione dell'immagine

Numba offre la compilazione JIT di codice Python loopy in codice vettorializzato molto potente. Capire come far funzionare numba richiede una curva di apprendimento.


Operazioni con Mixed / objectdtypes

Confronto basato su stringhe
Rivisitando l'esempio di filtraggio dalla prima sezione, cosa succede se le colonne confrontate sono stringhe? Considera le stesse 3 funzioni precedenti, ma con l'input DataFrame cast su string.

# 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

inserisci qui la descrizione dell'immagine

Allora, cosa è cambiato? La cosa da notare qui è che le operazioni sulle stringhe sono intrinsecamente difficili da vettorializzare. Pandas tratta le stringhe come oggetti e tutte le operazioni sugli oggetti ricadono su un'implementazione lenta e ripetitiva.

Ora, poiché questa implementazione loopy è circondata da tutto l'overhead menzionato sopra, c'è una differenza di grandezza costante tra queste soluzioni, anche se scalano lo stesso.

Quando si tratta di operazioni su oggetti mutabili / complessi, non c'è confronto. La comprensione delle liste supera tutte le operazioni che coinvolgono dict ed elenchi.

Accesso ai valori del dizionario per chiave
Di seguito sono riportati i tempi per due operazioni che estraggono un valore da una colonna di dizionari: mape la comprensione dell'elenco. La configurazione è nell'Appendice, sotto il titolo "Frammenti di codice".

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

inserisci qui la descrizione dell'immagine

Posizionale Elenco indicizzazione
tempi per 3 operazioni che estraggono l'elemento 0a da un elenco di colonne (la gestione delle eccezioni), map,str.get di accesso metodo , e la lista di comprensione:

# 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

Nota
Se l'indice è importante, dovresti fare:

pd.Series([...], index=ser.index)

Quando si ricostruisce la serie.

inserisci qui la descrizione dell'immagine

Appiattimento degli elenchi
Un ultimo esempio è l'appiattimento degli elenchi. Questo è un altro problema comune e dimostra quanto sia potente Python puro qui.

# 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

inserisci qui la descrizione dell'immagine

Sia itertools.chain.from_iterablela comprensione della lista annidata che la comprensione dell'elenco sono puri costrutti Python e scalano molto meglio della stacksoluzione.

Questi tempi sono una forte indicazione del fatto che i panda non sono attrezzati per lavorare con dtype misti, e che probabilmente dovresti astenervi dall'usarlo per farlo. Ove possibile, i dati dovrebbero essere presenti come valori scalari (int / float / stringhe) in colonne separate.

Infine, l'applicabilità di queste soluzioni dipende ampiamente dai tuoi dati. Quindi, la cosa migliore da fare sarebbe testare queste operazioni sui tuoi dati prima di decidere cosa fare. Nota come non ho cronometratoapply su queste soluzioni, perché distorcerebbe il grafico (sì, è così lento).


Operazioni con espressioni regolari e .str metodi di accesso

Pandas possono applicare operazioni regex quali str.contains, str.extracte str.extractall, così come altre "vettorizzati" operazioni corda (ad esempiostr.split , str.find ,str.translate`, e così via) su colonne stringa. Queste funzioni sono più lente delle comprensioni di elenchi e sono pensate per essere più funzioni utili di qualsiasi altra cosa.

Di solito è molto più veloce pre-compilare un pattern regex e iterare sui dati con re.compile(vedere anche Vale la pena usare re.compile di Python? ). L'equivalente di list comp str.containsè simile a questo:

p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])

O,

ser2 = ser[[bool(p.search(x)) for x in ser]]

Se hai bisogno di gestire NaNs, puoi fare qualcosa di simile

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

La lista comp è equivalente a str.extract (senza gruppi) avrà un aspetto simile a:

df['col2'] = [p.search(x).group(0) for x in df['col']]

Se devi gestire no-match e NaN, puoi usare una funzione personalizzata (ancora più veloce!):

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']]

Il matcher funzione è molto estensibile. Può essere adattato per restituire un elenco per ogni gruppo di cattura, se necessario. Basta estrarre la querygroup o l' groupsattributo dell'oggetto matcher.

Per str.extractallcambiarep.search inp.findall .

Estrazione di stringhe
Considerare una semplice operazione di filtraggio. L'idea è di estrarre 4 cifre se precedute da una lettera maiuscola.

# 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

inserisci qui la descrizione dell'immagine

Altri esempi
Divulgazione completa - Sono l'autore (in parte o interamente) di questi post elencati di seguito.


Conclusione

Come mostrato dagli esempi precedenti, l'iterazione brilla quando si lavora con piccole righe di DataFrame, tipi di dati misti ed espressioni regolari.

La velocità che ottieni dipende dai tuoi dati e dal tuo problema, quindi il tuo chilometraggio può variare. La cosa migliore da fare è eseguire attentamente i test e vedere se il pagamento vale lo sforzo.

Le funzioni "vettorializzate" brillano nella loro semplicità e leggibilità, quindi se le prestazioni non sono critiche, dovresti assolutamente preferirle.

Un'altra nota a margine, alcune operazioni sulle stringhe si occupano di vincoli che favoriscono l'uso di NumPy. Ecco due esempi in cui un'attenta vettorizzazione di NumPy supera le prestazioni di Python:

Inoltre, a volte il solo funzionamento sugli array sottostanti tramite .valuesanziché su Series o DataFrame può offrire una velocità abbastanza salutare per gli scenari più comuni (vedere la nota nella sezione Confronto numerico sopra). Quindi, ad esempio df[df.A.values != df.B.values], mostrerebbe aumenti istantanei delle prestazioni df[df.A != df.B]. Utilizzando.values potrebbe non essere appropriato in ogni situazione, ma è un trucco utile da sapere.

Come accennato in precedenza, sta a te decidere se vale la pena implementare queste soluzioni.


Appendice: frammenti di codice

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
)

pd.Seriese pd.DataFrameora supporta la costruzione da iterabili. Ciò significa che si può semplicemente passare un generatore Python alle funzioni del costruttore piuttosto che dover costruire prima una lista (usando le comprensioni di lista), che potrebbe essere più lenta in molti casi. Tuttavia, la dimensione dell'uscita del generatore non può essere determinata in anticipo. Non sono sicuro di quanto tempo / sovraccarico di memoria ciò causerebbe.
GZ0

@ GZ0 IIRC, l'accettazione di iteratori è un'aggiunta più recente all'API. Quanto a "questo significa che si può semplicemente passare un generatore Python alle funzioni del costruttore invece di dover prima costruire una lista", non sono d'accordo. Forse ricordo, spettacolo no. Nella mia esperienza di solito è quasi sempre più veloce generare l'elenco e trasmetterlo. Elenco composizioni FTW.
cs95

@ cs95 Perché non pensi che i generatori possano portare a un aumento delle prestazioni? O hai fatto dei test su questo?
GZ0

@ GZ0 Non ho detto che i generatori non portano a un aumento delle prestazioni, sto dicendo che non guadagni tanto quanto faresti usando le comprensioni di lista. Ci sono spese generali associate all'inizializzazione di genexps, al mantenimento dello stato, ecc., Che le composizioni di elenco non hanno. Ci sono molti esempi qui con le comprensioni di elenchi che puoi confrontare con le comprensioni del generatore. Ecco un esempio che puoi eseguire adesso ser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000):; %timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95

@ cs95 Probabilmente hai ragione. Ho appena dato una rapida occhiata al codice del costruttore di pd.Seriese si è scoperto che se la dimensione dell'output iterabile non può essere determinata, un elenco sarebbe comunque costruito dall'iterabile. Quindi nella maggior parte dei casi non ci sarebbero vantaggi nell'usare i generatori. Tuttavia, quando ho eseguito gli esempi che hai fornito, la versione del generatore è effettivamente più lenta. Non sono sicuro da dove venga la differenza.
GZ0

1

In breve

  • for loop + iterrowsè estremamente lento. L'overhead non è significativo su ~ 1k righe, ma è evidente su 10k + righe.
  • for loop + itertuplesè molto più veloce di iterrowso apply.
  • la vettorizzazione è solitamente molto più veloce di itertuples

Prova delle prestazioni inserisci qui la descrizione dell'immagine

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.