TLDR; Não, os for
loops não são totalmente "ruins", pelo menos, nem sempre. É provavelmente mais preciso dizer que algumas operações vetorizadas são mais lentas do que a iteração , em vez de dizer que a iteração é mais rápida do que algumas operações vetorizadas. Saber quando e por que é fundamental para obter o máximo desempenho de seu código. Em suma, essas são as situações em que vale a pena considerar uma alternativa às funções de pandas vetorizadas:
- Quando seus dados são pequenos (... dependendo do que você está fazendo),
- Ao lidar com
object
/ tipos d mistos
- Ao usar as
str
funções de acesso / regex
Vamos examinar essas situações individualmente.
Iteração v / s Vetorização em dados pequenos
O Pandas segue uma abordagem de "convenção sobre configuração" em seu design de API. Isso significa que a mesma API foi ajustada para atender a uma ampla variedade de dados e casos de uso.
Quando uma função do pandas é chamada, as seguintes coisas (entre outras) devem ser tratadas internamente pela função, para garantir o funcionamento
- Índice / alinhamento do eixo
- Tratamento de tipos de dados mistos
- Tratamento de dados ausentes
Quase todas as funções terão que lidar com isso em vários graus, e isso representa uma sobrecarga . A sobrecarga é menor para funções numéricas (por exemplo, Series.add
), enquanto é mais pronunciada para funções de string (por exemplo, Series.str.replace
).
for
os loops, por outro lado, são mais rápidos do que você pensa. O que é ainda melhor é que as compreensões de lista (que criam listas por meio de for
loops) são ainda mais rápidas, pois são mecanismos iterativos otimizados para a criação de listas.
As compreensões de lista seguem o padrão
[f(x) for x in seq]
Onde seq
está uma série pandas ou coluna DataFrame. Ou, ao operar em várias colunas,
[f(x, y) for x, y in zip(seq1, seq2)]
Onde seq1
e seq2
estão as colunas.
Comparação numérica
Considere uma operação simples de indexação booleana. O método de compreensão de lista foi cronometrado contra Series.ne
( !=
) e query
. Aqui estão as funções:
# 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
Para simplificar, usei o perfplot
pacote para executar todos os testes timeit neste post. Os tempos para as operações acima estão abaixo:
A compreensão de lista supera query
para N de tamanho moderado e até supera a comparação vetorizada não igual para N.
Nota
É importante mencionar que muito do benefício da compreensão de lista vem de não ter que se preocupar com o alinhamento do índice, mas isso significa que se o seu código depender do alinhamento da indexação, isso será interrompido. Em alguns casos, as operações vetorizadas sobre as matrizes NumPy subjacentes podem ser consideradas como trazendo o "melhor dos dois mundos", permitindo a vetorização sem toda a sobrecarga desnecessária das funções do pandas. Isso significa que você pode reescrever a operação acima como
df[df.A.values != df.B.values]
O que supera tanto os pandas quanto os equivalentes de compreensão de lista: a
vetorização do NumPy está fora do escopo deste post, mas definitivamente vale a pena considerar, se o desempenho for importante.
Value Counts
Tomando outro exemplo - desta vez, com outra construção vanilla python que é mais rápida do que um loop for - collections.Counter
. Um requisito comum é calcular as contagens de valor e retornar o resultado como um dicionário. Isto é feito com 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
Os resultados são mais pronunciados, Counter
vencendo ambos os métodos vetorizados para um intervalo maior de N pequeno (~ 3500).
Nota
Mais curiosidades (cortesia @ user2357112). O Counter
é implementado com um acelerador C , portanto, embora ainda precise trabalhar com objetos Python em vez dos tipos de dados C subjacentes, ainda é mais rápido do que um for
loop. Poder Python!
Obviamente, a conclusão é que o desempenho depende de seus dados e caso de uso. O objetivo desses exemplos é convencê-lo a não descartar essas soluções como opções legítimas. Se eles ainda não proporcionarem o desempenho de que você precisa, há sempre cython e numba . Vamos adicionar este teste à mistura.
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 oferece compilação JIT de código Python loopy para código vetorizado muito poderoso. Compreender como fazer o numba funcionar envolve uma curva de aprendizado.
Operações com object
tipos mistos / d
Comparação baseada em string
Revisitando o exemplo de filtragem da primeira seção, e se as colunas comparadas forem strings? Considere as mesmas 3 funções acima, mas com a entrada DataFrame convertida em 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
Então, o que mudou? O que se deve notar aqui é que as operações de string são inerentemente difíceis de vetorizar. O Pandas trata strings como objetos, e todas as operações em objetos retornam a uma implementação lenta e em loop.
Agora, como essa implementação de loop é cercada por toda a sobrecarga mencionada acima, há uma diferença de magnitude constante entre essas soluções, embora tenham a mesma escala.
Quando se trata de operações em objetos mutáveis / complexos, não há comparação. A compreensão de listas supera todas as operações envolvendo dictos e listas.
Acessando Valor (es) do Dicionário por Chave
Aqui estão os tempos para duas operações que extraem um valor de uma coluna de dicionários: map
e a compreensão da lista. A configuração está no Apêndice, sob o título "Trechos de código".
# Dictionary value extraction.
ser.map(operator.itemgetter('value')) # map
pd.Series([x.get('value') for x in ser]) # list comprehension
Lista de posição de indexação
prazos para 3 operações que extraem o elemento 0 em uma lista de colunas (tratamento de exceções), map
, str.get
acessor método , ea compreensão da lista:
# 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
Observação
Se o índice for importante, você deve fazer:
pd.Series([...], index=ser.index)
Ao reconstruir a série.
Achatamento de listas
Um exemplo final é o achatamento de listas. Este é outro problema comum e demonstra o quão poderoso o Python puro é aqui.
# 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
Ambos itertools.chain.from_iterable
e a compreensão da lista aninhada são construções puramente python e escalam muito melhor do que a stack
solução.
Esses tempos são uma forte indicação do fato de que os pandas não estão equipados para trabalhar com dtipos mistos, e que você provavelmente deve evitar usá-los para isso. Sempre que possível, os dados devem estar presentes como valores escalares (ints / floats / strings) em colunas separadas.
Por fim, a aplicabilidade dessas soluções depende amplamente de seus dados. Portanto, a melhor coisa a fazer seria testar essas operações em seus dados antes de decidir o que fazer. Observe como eu não cronometrei apply
nessas soluções, porque isso distorceria o gráfico (sim, é muito lento).
Operações Regex e .str
Métodos de Acesso
Pandas pode aplicar operações regex, como str.contains
, str.extract
, e str.extractall
, assim como outros "vectorized" operações de cadeia (tais como str.split
, str.find ,
str.translate`, e assim por diante) em colunas de cordas. Essas funções são mais lentas do que as compreensões de lista e pretendem ser mais funções de conveniência do que qualquer outra coisa.
Geralmente é muito mais rápido pré-compilar um padrão regex e iterar seus dados com re.compile
(consulte também Vale a pena usar o re.compile do Python? ). A lista comp equivalente a se str.contains
parece com isto:
p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])
Ou,
ser2 = ser[[bool(p.search(x)) for x in ser]]
Se você precisa lidar com NaNs, pode fazer algo como
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
A lista equivalente a str.extract
(sem grupos) será semelhante a:
df['col2'] = [p.search(x).group(0) for x in df['col']]
Se você precisa lidar com no-match e NaNs, você pode usar uma função personalizada (ainda mais rápido!):
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']]
A matcher
função é muito extensível. Ele pode ser ajustado para retornar uma lista para cada grupo de captura, conforme necessário. Basta extrair a consulta group
ou o groups
atributo do objeto matcher.
Para str.extractall
, mude p.search
para p.findall
.
Extração de string
Considere uma operação de filtragem simples. A ideia é extrair 4 dígitos se for precedido de uma letra maiúscula.
# 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
Mais exemplos
Divulgação completa - eu sou o autor (em parte ou na totalidade) das postagens listadas abaixo.
Conclusão
Conforme mostrado nos exemplos acima, a iteração brilha ao trabalhar com pequenas linhas de DataFrames, tipos de dados mistos e expressões regulares.
A aceleração que você obtém depende dos seus dados e do seu problema, portanto, sua milhagem pode variar. A melhor coisa a fazer é executar testes cuidadosamente e ver se o pagamento compensa o esforço.
As funções "vetorizadas" brilham em sua simplicidade e legibilidade, portanto, se o desempenho não for crítico, você definitivamente deve preferi-las.
Outra observação, certas operações de string lidam com restrições que favorecem o uso de NumPy. Aqui estão dois exemplos em que a vetorização cuidadosa de NumPy supera o desempenho de python:
Além disso, às vezes, apenas operar nos arrays subjacentes via .values
em vez de na Série ou DataFrames pode oferecer uma aceleração saudável o suficiente para a maioria dos cenários usuais (consulte a Nota na seção Comparação Numérica acima). Assim, por exemplo df[df.A.values != df.B.values]
, mostraria aumentos de desempenho instantâneos df[df.A != df.B]
. O uso .values
pode não ser apropriado em todas as situações, mas é um hack útil de se conhecer.
Conforme mencionado acima, cabe a você decidir se vale a pena implementar essas soluções.
Apêndice: trechos de código
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
agora suporta construção a partir de iteráveis. Isso significa que é possível simplesmente passar um gerador Python para as funções do construtor em vez de precisar construir uma lista primeiro (usando compreensões de lista), o que pode ser mais lento em muitos casos. No entanto, o tamanho da saída do gerador não pode ser determinado de antemão. Não tenho certeza de quanta sobrecarga de tempo / memória isso causaria.