Pular para o conteúdo

    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:

    avancado/cap30_encadeamento.pylinhas 10 a 46
    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))
    
    Saída
    (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:

    avancado/cap30_encadeamento.pylinhas 51 a 57
    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()))
    
    Saída
    (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:

    avancado/cap30_encadeamento.pylinhas 62 a 75
    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()
    )
    
    Saída
    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:

    avancado/cap30_encadeamento.pylinhas 80 a 97
    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()))
    
    Saída
    {'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: devolvem None, e o método seguinte falha. É mais um motivo para evitar o inplace, e o que o ruff aponta com a regra PD002.

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