Os loops-for em pandas são realmente ruins? Quando devo me preocupar?

106

Os forloops são realmente "ruins"? Se não, em que situação (ões) eles seriam melhores do que usar uma abordagem "vetorizada" mais convencional? 1

Estou familiarizado com o conceito de "vetorização" e como o pandas emprega técnicas vetorizadas para acelerar a computação. As funções vetorizadas transmitem operações por toda a série ou DataFrame para obter acelerações muito maiores do que a iteração convencional sobre os dados.

No entanto, estou bastante surpreso ao ver uma grande quantidade de código (incluindo de respostas no Stack Overflow) oferecendo soluções para problemas que envolvem loop através de dados usando forloops e compreensões de lista. A documentação e a API dizem que os loops são "ruins" e que "nunca" deve-se iterar em arrays, séries ou DataFrames. Então, por que às vezes vejo usuários sugerindo soluções baseadas em loop?


1 - Embora seja verdade que a questão pareça um tanto ampla, a verdade é que há situações muito específicas em que os forloops são geralmente melhores do que a iteração convencional sobre os dados. Esta postagem visa capturar isso para a posteridade.

cs95
fonte

Respostas:

143

TLDR; Não, os forloops 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:

  1. Quando seus dados são pequenos (... dependendo do que você está fazendo),
  2. Ao lidar com object/ tipos d mistos
  3. Ao usar as strfunçõ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

  1. Índice / alinhamento do eixo
  2. Tratamento de tipos de dados mistos
  3. 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).

foros 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 forloops) 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 seqestá 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 seq1e seq2estã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 perfplotpacote para executar todos os testes timeit neste post. Os tempos para as operações acima estão abaixo:

insira a descrição da imagem aqui

A compreensão de lista supera querypara 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.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

insira a descrição da imagem aqui

Os resultados são mais pronunciados, Countervencendo 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 forloop. 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

insira a descrição da imagem aqui

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 objecttipos 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

insira a descrição da imagem aqui

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: mape 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

insira a descrição da imagem aqui

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.getacessor 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.

insira a descrição da imagem aqui

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

insira a descrição da imagem aqui

Ambos itertools.chain.from_iterablee a compreensão da lista aninhada são construções puramente python e escalam muito melhor do que a stacksoluçã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 applynessas soluções, porque isso distorceria o gráfico (sim, é muito lento).


Operações Regex e .strMé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.containsparece 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 matcherfunção é muito extensível. Ele pode ser ajustado para retornar uma lista para cada grupo de captura, conforme necessário. Basta extrair a consulta groupou o groupsatributo do objeto matcher.

Para str.extractall, mude p.searchpara 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

insira a descrição da imagem aqui

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 .valuesem 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 .valuespode 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
)
cs95
fonte
pd.Seriese pd.DataFrameagora 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.
GZ0
@ GZ0 IIRC, aceitar iteradores é uma adição mais recente à API. Quanto a "isso significa que alguém pode simplesmente passar um gerador Python para as funções do construtor em vez de precisar construir uma lista primeiro", eu discordo. Memória talvez, desempenho não. Na minha experiência, geralmente é quase sempre mais rápido gerar a lista e passá-la adiante. Listar composições FTW.
cs95
@ cs95 Por que você não acha que os geradores podem levar a ganhos de desempenho? Ou você fez algum teste nisso?
GZ0 de
@ GZ0 Eu não disse que geradores não levam a um ganho de desempenho, estou dizendo que você não ganha tanto quanto ganharia usando compreensões de lista. Existem overheads associados à inicialização de genexps, manutenção de estado, etc, que os comps de lista não têm. Há muitos exemplos aqui com compreensões de lista que você pode comparar com as compreensões de gerador. Aqui está um exemplo que você pode executar agora 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 Você provavelmente está certo. Acabei de dar uma olhada rápida no código do construtor de pd.Seriese descobri que, se o tamanho da saída iterável não puder ser determinado, uma lista seria construída a partir do iterável de qualquer maneira. Portanto, na maioria dos casos, não haveria ganhos com o uso de geradores. No entanto, quando executei os exemplos que você forneceu, a versão do gerador ficou mais lenta. Não tenho certeza de onde vem a diferença.
GZ0
1

Em resumo

  • para loop + iterrowsé extremamente lento. A sobrecarga não é significativa em cerca de 1 mil linhas, mas é perceptível em mais de 10 mil linhas.
  • para loop + itertuplesé muito mais rápido que iterrowsou apply.
  • a vetorização é geralmente muito mais rápida do que itertuples

Benchmark insira a descrição da imagem aqui

Artoby
fonte