Capítulo 28, Avançado
Desempenho: vetorizar e evitar o laço
Em Pandas, a mesma conta pode levar milissegundos ou minutos, dependendo de **como** você a escreve. A diferença quase nunca está no computador: está em percorrer as linhas em Python em vez de operar sobre a coluna.
As quatro maneiras de calcular a mesma coluna
Quatro formas de calcular a receita de cada linha. Todas dão o mesmo resultado, e os tempos são muito diferentes:
import time
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
n = 50_000
base = pd.DataFrame(
{
"quantidade": rng.integers(1, 10, n),
"preco": rng.uniform(5, 500, n).round(2),
"desconto": rng.choice([0.0, 0.05, 0.1], n),
}
)
def com_iterrows(d):
return pd.Series([l["quantidade"] * l["preco"] * (1 - l["desconto"]) for _, l in d.iterrows()], index=d.index)
def com_itertuples(d):
return pd.Series([t.quantidade * t.preco * (1 - t.desconto) for t in d.itertuples()], index=d.index)
def com_apply(d):
return d.apply(lambda l: l["quantidade"] * l["preco"] * (1 - l["desconto"]), axis=1)
def vetorizada(d):
return d["quantidade"] * d["preco"] * (1 - d["desconto"])
def medir(funcao, repeticoes=1):
melhor = float("inf")
for _ in range(repeticoes):
inicio = time.perf_counter()
resultado = funcao(base)
melhor = min(melhor, time.perf_counter() - inicio)
return resultado, melhor
r_vet, t_vet = medir(vetorizada, repeticoes=20)
r_tup, t_tup = medir(com_itertuples)
r_apply, t_apply = medir(com_apply)
r_iter, t_iter = medir(com_iterrows)
print(all(np.allclose(r_vet, r) for r in (r_tup, r_apply, r_iter)))
print("itertuples mais rápido que iterrows:", t_tup < t_iter)
print("vetorizada pelo menos 30 vezes mais rápida que todas:", all(t > 30 * t_vet for t in (t_tup, t_apply, t_iter)))
True
itertuples mais rápido que iterrows: True
vetorizada pelo menos 30 vezes mais rápida que todas: True
A versão vetorizada é a única que não percorre as linhas em Python: ela entrega a conta inteira ao código compilado, como no NumPy. Eu cronometro a vetorizada com o melhor de 20 repetições (uma só execução a frio a faz parecer mais lenta do que é) e as lentas uma vez, porque já demoram. Na máquina em que testei, o itertuples ficou na casa de 80 vezes mais lento que a versão vetorizada, o apply(axis=1) perto de 500 vezes, e o iterrows acima de 1.000 vezes. Os números variam de máquina para máquina, mas a ordem entre eles não. O iterrows é o mais lento, porque cria uma Series para cada linha. O itertuples é bem mais leve, e o apply(axis=1), por baixo, também percorre linha a linha. Uma regra que serve: se você escreveu axis=1, iterrows ou um for sobre linhas, pare e procure a versão sobre colunas.
Escrever linha a linha também é lento
O mesmo vale para atribuir em um laço. Cada df.loc[i, "x"] = valor é uma operação completa de indexação:
pequena = base.head(5000).copy()
def atribuir_no_laco():
resultado = pequena.copy()
resultado["total"] = 0.0
for i in range(len(resultado)):
resultado.loc[i, "total"] = resultado.loc[i, "quantidade"] * resultado.loc[i, "preco"]
return resultado
def atribuir_vetorizado():
return pequena.assign(total=pequena["quantidade"] * pequena["preco"])
inicio = time.perf_counter()
a = atribuir_no_laco()
t_laco = time.perf_counter() - inicio
inicio = time.perf_counter()
b = atribuir_vetorizado()
t_vet2 = time.perf_counter() - inicio
print(bool(np.allclose(a["total"], b["total"])), t_laco > 100 * t_vet2)
True True
`eval` e `query`: a conta como texto
O eval aceita a expressão como texto e a avalia sobre as colunas, o que deixa expressões longas mais legíveis. O resultado é idêntico ao da conta direta:
por_eval = base.eval("quantidade * preco * (1 - desconto)")
print(bool(np.allclose(por_eval, r_vet)))
print(len(base.query("quantidade >= 5 and desconto > 0")))
True
18417
Com a biblioteca opcional numexpr instalada, o eval pode ser mais rápido em tabelas muito grandes, porque evita criar as colunas intermediárias. Eu não a usei neste curso, então não faço afirmações sobre o ganho. Para tabelas pequenas ou médias, o eval vale pela legibilidade, e não pela velocidade.
O custo de olhar sem medir
Antes de otimizar, meça. Uma medição simples basta, e é a única forma de saber onde o tempo vai. Os resultados costumam surpreender: o gargalo quase nunca é onde a intuição aponta.
def medir_etapas():
etapas = {}
inicio = time.perf_counter()
d = base.assign(receita=vetorizada(base))
etapas["conta"] = time.perf_counter() - inicio
inicio = time.perf_counter()
d.groupby("quantidade")["receita"].sum()
etapas["groupby"] = time.perf_counter() - inicio
inicio = time.perf_counter()
d.sort_values("receita")
etapas["ordenar"] = time.perf_counter() - inicio
return etapas
etapas = medir_etapas()
print(sorted(etapas), all(valor > 0 for valor in etapas.values()))
['conta', 'groupby', 'ordenar'] True
A ordem de escolha
- Operação vetorizada sobre colunas. 2. Funções internas do Pandas (
map,cut,transform("mean"),str.*). 3.np.selectenp.wherepara regras com condições. 4.itertuples, só se for inevitável. 5.apply(axis=1)eiterrows, quase nunca. E, entre um passo e outro, meça.
Exercício 1
Trocar o `apply` por uma conta vetorizada
Escreva margem_vetorizada(d), que calcule (preco * quantidade) * 0.2 - desconto * preco * quantidade sem percorrer as linhas, e confira que o resultado é igual ao da versão com apply(axis=1).