Quando devo usar o Pandas apply () no meu código?

110

Tenho visto muitas respostas postadas para perguntas no Stack Overflow envolvendo o uso do método Pandas apply. Eu também vi usuários comentando abaixo deles dizendo que " applyé lento e deve ser evitado".

Eu li muitos artigos sobre o tópico de desempenho que explicam que applyé lento. Também vi um aviso de isenção de responsabilidade nos documentos sobre como applyé simplesmente uma função de conveniência para passar UDFs (não consigo encontrar isso agora). Portanto, o consenso geral é que applydeve ser evitado, se possível. No entanto, isso levanta as seguintes questões:

  1. Se applyé tão ruim, por que está na API?
  2. Como e quando devo tornar meu código applygrátis?
  3. Existem situações em que applyé bom (melhor do que outras soluções possíveis)?
cs95
fonte
1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)é um caso em applyque geralmente será ligeiramente mais rápido, que é a caixa verde inferior direita no diagrama de jpp abaixo.
Alexander
@Alexander obrigado. Não apontei exaustivamente essas situações, mas é útil conhecê-las!
cs95

Respostas:

107

apply, a função de conveniência que você nunca precisou

Começamos abordando as questões do OP, uma a uma.

" Se aplicar é tão ruim, por que está na API? "

DataFrame.applye Series.applysão funções de conveniência definidas no objeto DataFrame e Series, respectivamente. applyaceita qualquer função definida pelo usuário que aplica uma transformação / agregação em um DataFrame. applyé efetivamente uma bala de prata que faz tudo o que as funções existentes dos pandas não podem fazer.

Algumas das coisas applypodem fazer:

  • Execute qualquer função definida pelo usuário em um DataFrame ou Series
  • Aplicar uma função tanto por linha ( axis=1) quanto por coluna ( axis=0) em um DataFrame
  • Realize o alinhamento do índice enquanto aplica a função
  • Realize a agregação com funções definidas pelo usuário (no entanto, geralmente preferimos aggou transform, nesses casos)
  • Realize transformações em elementos
  • Transmita os resultados agregados para as linhas originais (consulte o result_typeargumento).
  • Aceite argumentos posicionais / palavra-chave para passar para as funções definidas pelo usuário.

...Entre outros. Para obter mais informações, consulte Aplicação de função por linha ou coluna na documentação.

Então, com todos esses recursos, por que é applyruim? É porque applyé lento . O Pandas não faz suposições sobre a natureza da sua função e, portanto, aplica iterativamente a sua função a cada linha / coluna conforme necessário. Além disso, lidar com todas as situações acima significa que applyincorre em uma grande sobrecarga em cada iteração. Além disso, applyconsome muito mais memória, o que é um desafio para aplicativos limitados por memória.

Existem muito poucas situações em applyque o uso é apropriado (mais sobre isso abaixo). Se você não tem certeza se deve usar apply, provavelmente não deveria.


Vamos abordar a próxima questão.

" Como e quando devo tornar meu código aplicável gratuitamente? "

Para reformular, aqui estão algumas situações comuns em que você desejará se livrar de todas as chamadas para apply.

Dados Numéricos

Se você estiver trabalhando com dados numéricos, provavelmente já existe uma função de cíton vetorizada que faz exatamente o que você está tentando fazer (se não, faça uma pergunta no Stack Overflow ou abra uma solicitação de recurso no GitHub).

Compare o desempenho de applypara uma operação de adição simples.

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

Em termos de desempenho, não há comparação, o equivalente citonizado é muito mais rápido. Não há necessidade de gráfico, porque a diferença é óbvia até mesmo para dados de brinquedos.

%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)

Mesmo se você habilitar a passagem de matrizes brutas com o rawargumento, ainda será duas vezes mais lento.

%timeit df.apply(np.sum, raw=True)
840 µs ± 691 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Outro exemplo:

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)

Em geral, procure alternativas vetorizadas, se possível.

String / Regex

O Pandas fornece funções de string "vetorizadas" na maioria das situações, mas há casos raros em que essas funções não ... "se aplicam", por assim dizer.

Um problema comum é verificar se um valor em uma coluna está presente em outra coluna da mesma linha.

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

Isso deve retornar a segunda e a terceira linha, uma vez que "donald" e "minnie" estão presentes em suas respectivas colunas de "Título".

Usando aplicar, isso seria feito 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

No entanto, existe uma solução melhor usando as compreensões de lista.

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)

O que se deve notar aqui é que as rotinas iterativas são mais rápidas do que apply, devido à menor sobrecarga. Se você precisa lidar com NaNs e dtypes inválidos, você pode construir sobre isso usando uma função personalizada que você pode chamar com argumentos dentro da compreensão da lista.

Para obter mais informações sobre quando as compreensões de lista devem ser consideradas uma boa opção, consulte meu artigo: Para loops com pandas - Quando devo me importar? .

Nota
As operações de data e data e hora também têm versões vetorizadas. Portanto, por exemplo, você deve preferir pd.to_datetime(df['date']), por exemplo df['date'].apply(pd.to_datetime),.

Leia mais na documentação .

Uma armadilha comum: explodir colunas de listas

s = pd.Series([[1, 2]] * 3)
s

0    [1, 2]
1    [1, 2]
2    [1, 2]
dtype: object

As pessoas são tentadas a usar apply(pd.Series). Isso é horrível em termos de desempenho.

s.apply(pd.Series)

   0  1
0  1  2
1  1  2
2  1  2

A melhor opção é listar a coluna e passá-la para 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)

Por último,

" Existem situações em que apply é bom? "

Aplicar é uma função de conveniência, por isso não são situações em que a sobrecarga é bastante insignificante para perdoar. Realmente depende de quantas vezes a função é chamada.

Funções que são vetorizadas para séries, mas não DataFrames
E se você quiser aplicar uma operação de string em várias colunas? E se você quiser converter várias colunas em data e hora? Essas funções são vetorizadas apenas para séries, portanto, devem ser aplicadas em cada coluna que você deseja converter / operar.

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

Este é um caso admissível para apply:

df.apply(pd.to_datetime, errors='coerce').dtypes

date1    datetime64[ns]
date2    datetime64[ns]
dtype: object

Observe que também faria sentido stackou apenas usar um loop explícito. Todas essas opções são um pouco mais rápidas do que usar apply, mas a diferença é pequena o suficiente para perdoar.

%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)

Você pode fazer um caso semelhante para outras operações, como operações de string ou conversão para 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 assim por diante...

Convertendo séries em str: astypeversusapply

Isso parece uma idiossincrasia da API. Usar applypara converter inteiros em uma série em string é comparável (e às vezes mais rápido) do que usar astype.

insira a descrição da imagem aqui O gráfico foi traçado usando a perfplotbiblioteca.

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())

Com os flutuadores, vejo que astypeé consistentemente tão rápido ou ligeiramente mais rápido que apply. Portanto, isso tem a ver com o fato de que os dados no teste são do tipo inteiro.

GroupBy operações com transformações encadeadas

GroupBy.applynão foi discutido até agora, mas GroupBy.applytambém é uma função de conveniência iterativa para lidar com tudo o que as GroupByfunções existentes não fazem.

Um requisito comum é realizar um GroupBy e, em seguida, duas operações principais, como um "cumsum defasado":

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

Você precisaria de duas chamadas em grupo sucessivas aqui:

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, você pode encurtar isso para uma única chamada.

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

É muito difícil quantificar o desempenho porque depende dos dados. Mas, em geral, applyé uma solução aceitável se o objetivo for reduzir uma groupbychamada (porque groupbytambém é bastante caro).


Outras advertências

Além das ressalvas mencionadas acima, também vale a pena mencionar que applyopera na primeira linha (ou coluna) duas vezes. Isso é feito para determinar se a função tem efeitos colaterais. Caso contrário, applypode ser capaz de usar um caminho rápido para avaliar o resultado, caso contrário, ele retorna para uma implementação 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

Esse comportamento também é visto nas GroupBy.applyversões do pandas <0,25 (foi corrigido para 0,25, consulte aqui para obter mais informações ).

cs95
fonte
Acho que precisamos ter cuidado ... com %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')certeza, após a primeira iteração, será muito mais rápido, já que você está convertendo datetimepara ... datetime?
jpp
@jpp Eu tive a mesma preocupação. Mas você ainda precisa fazer uma varredura linear de qualquer maneira, chamar to_datetime em strings é tão rápido quanto chamá-los em objetos datetime, se não mais rápido. Os tempos aproximados são os mesmos. A alternativa seria implementar alguma etapa de pré-cópia para cada solução cronometrada que retira o ponto principal. Mas é uma preocupação válida.
cs95
"Invocar to_datetimestrings é tão rápido quanto em ... datetimeobjetos" .. sério? Incluí a criação de dataframe (custo fixo) em tempos de loop applyvs fore a diferença é muito menor.
jpp de
@jpp Bem, isso é o que eu obtive com meus (reconhecidamente limitados) testes. Tenho certeza que depende dos dados, mas a ideia geral é que, para fins de ilustração, a diferença é "sério, não se preocupe com isso".
cs95 de
1
@ cs95, Feliz Ano Novo!
jpp
48

Nem todos applysão iguais

O gráfico abaixo sugere quando considerar apply1 . Verde significa possivelmente eficiente; evitar vermelho.

insira a descrição da imagem aqui

Parte disso é intuitivo: pd.Series.applyé um loop em nível de linha de Python, idem em pd.DataFrame.applylinha ( axis=1). Os abusos deles são muitos e abrangentes. A outra postagem trata deles com mais profundidade. As soluções populares são o uso de métodos vetorizados, compreensões de lista (assume dados limpos) ou ferramentas eficientes como o pd.DataFrameconstrutor (por exemplo, para evitar apply(pd.Series)).

Se você estiver usando a pd.DataFrame.applylinha, especificar raw=True(quando possível) geralmente é benéfico. Nesta fase, numbageralmente é uma escolha melhor.

GroupBy.apply: geralmente favorecido

Repetir groupbyoperações a serem evitadas applyprejudicará o desempenho. GroupBy.applynormalmente está bem aqui, desde que os métodos que você usa em sua função personalizada sejam vetorizados. Às vezes, não existe um método nativo do Pandas para uma agregação groupwise que você deseja aplicar. Nesse caso, um pequeno número de grupos applycom uma função personalizada ainda pode oferecer um desempenho razoável.

pd.DataFrame.apply coluna: um saco misto

pd.DataFrame.applycoluna-wise ( axis=0) é um caso interessante. Para um pequeno número de linhas versus um grande número de colunas, quase sempre é caro. Para um grande número de linhas em relação às colunas, o caso mais comum, às vezes você pode ver melhorias significativas de desempenho 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 Existem exceções, mas geralmente são marginais ou incomuns. Alguns exemplos:

  1. df['col'].apply(str)pode ter um desempenho ligeiramente superior df['col'].astype(str).
  2. df.apply(pd.to_datetime)trabalhar em cordas não escalona bem com linhas em comparação com um forloop regular .
jpp
fonte
2
Obrigado por contribuir, aprecio várias perspectivas :) +1
cs95
1
@coldspeed, Obrigado, não há nada de errado com a sua postagem (além de alguns benchmarking contraditórios contra o meu, mas pode ser baseado em entrada ou configuração). Achei que há uma maneira diferente de ver o problema.
jpp
@jpp Eu sempre usei seu fluxograma excelente como orientação, até quando vi hoje que uma linhaapply é significativamente mais rápida do que minha solução com any. Alguma opinião sobre isso?
Stef
1
@jpp: você está certo: para 1mio linhas x 100 cols anyé cerca de 100 vezes mais rápido do que apply. Fiz meus primeiros testes com 2.000 linhas x 1.000 colunas e aqui applyfoi duas vezes mais rápido queany
Stef
1
@jpp gostaria de usar sua imagem em uma apresentação / artigo. Você está bem com isso? Obviamente, mencionarei a fonte. Obrigado
Erfan
3

Para axis=1(isto é, funções de linha), então você pode simplesmente usar a seguinte função no lugar de apply. Eu me pergunto por que esse não é o pandascomportamento. (Não testado com índices compostos, mas parece ser muito mais rápido do que apply)

def faster_df_apply(df, func):
    cols = list(df.columns)
    data, index = [], []
    for row in df.itertuples(index=True):
        row_dict = {f:v for f,v in zip(cols, row[1:])}
        data.append(func(row_dict))
        index.append(row[0])
    return pd.Series(data, index=index)
Pete Cacioppi
fonte
Fiquei muito surpreso ao descobrir que isso me deu melhor desempenho em alguns casos. Era especialmente útil quando eu precisava fazer várias coisas, cada uma com um subconjunto diferente de valores de coluna. A resposta "Todos os aplicativos não são iguais" pode ajudar a descobrir quando é provável que ajude, mas não é muito difícil testar em uma amostra de seus dados.
denson de
Algumas dicas: para desempenho, uma compreensão de lista superaria o loop for; zip(df, row[1:])é suficiente aqui; realmente, neste estágio, considere numbase func é um cálculo numérico. Veja esta resposta para uma explicação.
jpp de
@jpp - se você tiver uma função melhor, compartilhe. Acho que isso está muito próximo do ideal pela minha análise. Sim, numbaé mais rápido, faster_df_applydestina-se a pessoas que querem apenas algo equivalente, mas mais rápido do que o DataFrame.apply(o que é estranhamente lento).
Pete Cacioppi
2

Existem situações em que applyé bom? Sim as vezes.

Tarefa: decodificar strings 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

Atualização que
eu não estava de forma alguma defendendo o uso de apply, apenas pensando, uma vez NumPyque não pode lidar com a situação acima, poderia ter sido um bom candidato para pandas apply. Mas eu estava esquecendo a simples compreensão da lista graças ao lembrete de @jpp.

astro123
fonte
Bem não. Como isso é melhor do que [unidecode.unidecode(x) for x in s]ou list(map(unidecode.unidecode, s))?
jpp
1
Como já era uma série de pandas, fiquei tentado a usar aplicar, sim, você está certo, é melhor usar list-comp do que aplicar, mas downvote foi um pouco difícil, eu não estava defendendo apply, apenas pensei que poderia ter sido uma boa caso de uso.
astro123