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.apply
e Series.apply
são funções de conveniência definidas no objeto DataFrame e Series, respectivamente. apply
aceita 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 apply
podem 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
agg
ou transform
, nesses casos)
- Realize transformações em elementos
- Transmita os resultados agregados para as linhas originais (consulte o
result_type
argumento).
- 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 é apply
ruim? É 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 apply
incorre em uma grande sobrecarga em cada iteração. Além disso, apply
consome muito mais memória, o que é um desafio para aplicativos limitados por memória.
Existem muito poucas situações em apply
que 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 apply
para 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 raw
argumento, 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 stack
ou 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
: astype
versusapply
Isso parece uma idiossincrasia da API. Usar apply
para converter inteiros em uma série em string é comparável (e às vezes mais rápido) do que usar astype
.
O gráfico foi traçado usando a perfplot
biblioteca.
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.apply
não foi discutido até agora, mas GroupBy.apply
também é uma função de conveniência iterativa para lidar com tudo o que as GroupBy
funçõ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 groupby
chamada (porque groupby
também é bastante caro).
Outras advertências
Além das ressalvas mencionadas acima, também vale a pena mencionar que apply
opera na primeira linha (ou coluna) duas vezes. Isso é feito para determinar se a função tem efeitos colaterais. Caso contrário, apply
pode 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.apply
versões do pandas <0,25 (foi corrigido para 0,25, consulte aqui para obter mais informações ).
returns.add(1).apply(np.log)
vs.np.log(returns.add(1)
é um caso emapply
que geralmente será ligeiramente mais rápido, que é a caixa verde inferior direita no diagrama de jpp abaixo.