Capítulo 30, Avançado
Encadeamento de métodos
Uma análise como uma receita: cada passo recebe o resultado do anterior. Encadeados, os métodos formam um fluxo que se lê de cima para baixo, sem variáveis intermediárias e sem alterar nada no meio do caminho.
O mesmo cálculo, de dois jeitos
Primeiro, do jeito que a gente aprende: um passo por vez, com uma variável nova (ou uma alteração) a cada linha. Depois, o mesmo em uma cadeia:
import pandas as pd
pd.set_option("display.width", 170)
pd.set_option("display.max_columns", 20)
pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
produtos = pd.read_csv("dados/produtos.csv")
# passo a passo
v = pedidos.merge(produtos, on="id_produto", how="left", validate="m:1")
v["receita"] = v["quantidade"] * v["preco_unitario"] * (1 - v["desconto"].fillna(0))
v["mes"] = v["data_pedido"].dt.to_period("M").astype(str)
v = v[v["quantidade"] >= 2]
passo_a_passo = (
v.groupby(["categoria", "mes"], as_index=False)
.agg(receita=("receita", "sum"), pedidos=("id_pedido", "count"))
.sort_values(["categoria", "mes"])
.reset_index(drop=True)
)
# em cadeia
encadeado = (
pedidos
.merge(produtos, on="id_produto", how="left", validate="m:1")
.assign(
receita=lambda d: d["quantidade"] * d["preco_unitario"] * (1 - d["desconto"].fillna(0)),
mes=lambda d: d["data_pedido"].dt.to_period("M").astype(str),
)
.query("quantidade >= 2")
.groupby(["categoria", "mes"], as_index=False)
.agg(receita=("receita", "sum"), pedidos=("id_pedido", "count"))
.sort_values(["categoria", "mes"])
.reset_index(drop=True)
)
pd.testing.assert_frame_equal(passo_a_passo, encadeado)
print(encadeado.shape, "as duas versões são idênticas")
print(encadeado.head(3).round(1))
(48, 4) as duas versões são idênticas
categoria mes receita pedidos
0 Casa 2025-01 19382.5 14
1 Casa 2025-02 20281.5 14
2 Casa 2025-03 13254.6 11
Os resultados são idênticos. A cadeia ganha em três coisas: lê-se de cima para baixo como uma receita, não deixa nomes soltos (v foi reatribuído quatro vezes na primeira versão), e não altera nenhuma tabela existente. E isso conversa direto com a cópia sob escrita do capítulo 27.
As peças que fazem a cadeia funcionar
O assign cria colunas e devolve uma tabela nova. A lambda d: recebe a tabela daquele ponto da cadeia (e não uma variável de fora), e é o que permite usar colunas que acabaram de ser criadas. O query filtra com uma expressão. E o .loc[lambda d: ...] filtra com uma função, quando a condição não cabe em texto:
com_total = (
pedidos
.assign(total=lambda d: d["quantidade"] * 10)
.assign(grande=lambda d: d["total"] > 50)
.loc[lambda d: d["grande"], ["id_pedido", "total"]]
)
print(com_total.shape, int(com_total["total"].min()))
(401, 2) 60
A segunda assign usa o total que a primeira acabou de criar. Isso só funciona com a lambda d:: escrever pedidos["total"] ali falharia, porque o pedidos original não tem essa coluna.
Olhar dentro da cadeia: `pipe`
Uma cadeia longa tem um defeito: quando algo dá errado, onde? O pipe encaixa qualquer função na cadeia, e uma função que só registra e devolve a tabela é a forma limpa de espiar o meio:
def registrar(d, etapa):
print(f"{etapa:<12} {d.shape[0]:>4} linhas, {d.shape[1]:>2} colunas")
return d
(
pedidos
.pipe(registrar, "lidos")
.merge(produtos, on="id_produto", how="left", validate="m:1")
.pipe(registrar, "com produto")
.query("quantidade >= 5")
.pipe(registrar, "filtrados")
.groupby("categoria").size()
)
lidos 800 linhas, 7 colunas
com produto 800 linhas, 10 colunas
filtrados 494 linhas, 10 colunas
Funções pequenas e nomeadas
Uma cadeia de dez linhas ainda é difícil de ler. O melhor desenho junta funções pequenas e com nome, cada uma recebendo e devolvendo uma tabela, e o pipe as compõe. A análise vira uma lista de passos nomeados:
def enriquecer(d: pd.DataFrame) -> pd.DataFrame:
return (
d.merge(produtos, on="id_produto", how="left", validate="m:1")
.assign(receita=lambda t: t["quantidade"] * t["preco_unitario"] * (1 - t["desconto"].fillna(0)))
)
def so_grandes(d: pd.DataFrame, minimo: int = 2) -> pd.DataFrame:
return d.query("quantidade >= @minimo")
def resumir_por_categoria(d: pd.DataFrame) -> pd.DataFrame:
return d.groupby("categoria", as_index=False).agg(receita=("receita", "sum")).sort_values("categoria")
resumo = pedidos.pipe(enriquecer).pipe(so_grandes, minimo=2).pipe(resumir_por_categoria)
print(resumo.round(0).to_dict("records")[0])
print(bool(resumo["receita"].sum() <= enriquecer(pedidos)["receita"].sum()))
{'categoria': 'Casa', 'receita': 233575.0}
True
Cada função pode ser testada sozinha (o capítulo 33), e a linha final conta a história da análise.
O que quebra uma cadeia
Os métodos que alteram no lugar e devolvem
None(inplace=True,df.sort_values(inplace=True)) não podem estar em uma cadeia: devolvemNone, e o método seguinte falha. É mais um motivo para evitar oinplace, e o que oruffaponta com a regraPD002.
Exercício 1
Reescrever em cadeia
Reescreva em uma cadeia este código: d = pedidos[pedidos["canal"] == "site"], depois d["total"] = d["quantidade"] * 2, depois d.sort_values("total", ascending=False).head(3), devolvendo só as colunas id_pedido e total. Confira que o resultado é igual ao do passo a passo (feito com copy).