TLDR; No, i for
loop 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:
- Quando i tuoi dati sono piccoli (... a seconda di cosa stai facendo),
- Quando si ha a che fare con
object
/ tipi misti
- Quando si utilizzano le
str
funzioni 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
- Allineamento indice / asse
- Gestione di tipi di dati misti
- 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
).
for
i loop, d'altra parte, sono più veloci di quanto pensi. Ciò che è ancora meglio è che le comprensioni di elenchi (che creano elenchi tramite for
cicli) 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'è seq
una 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 seq1
e seq2
sono 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 perfplot
pacchetto per eseguire tutti i test timeit in questo post. I tempi per le operazioni di cui sopra sono di seguito:
La comprensione dell'elenco supera le prestazioni query
per 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.unique
e 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
I risultati sono più pronunciati, Counter
vincono 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 for
ciclo. 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
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 / object
dtypes
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
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: map
e 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
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.
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
Sia itertools.chain.from_iterable
la comprensione della lista annidata che la comprensione dell'elenco sono puri costrutti Python e scalano molto meglio della stack
soluzione.
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.extract
e 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' groups
attributo dell'oggetto matcher.
Per str.extractall
cambiarep.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
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 .values
anziché 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.Series
epd.DataFrame
ora 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.