Pular para o conteúdo

    Capítulo 23, Intermediário

    Groupby avançado

    O `agg` reduz cada grupo a uma linha. Há três outras operações por grupo que resolvem o resto: o `transform` (um valor por linha original), o `filter` (descartar grupos inteiros) e o `apply` (qualquer coisa), e cada uma tem um preço.

    `transform`: o resultado volta com a forma original

    O transform calcula por grupo e devolve uma linha para cada linha da tabela, alinhada com ela. Isso permite comparar cada pessoa com o seu grupo: o desvio em relação à média do departamento, a participação no total do departamento:

    intermediario/cap23_groupby_avancado.pylinhas 10 a 34
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    CANONICO = {"engenharia": "Engenharia", "marketing": "Marketing", "finanças": "Finanças",
                "ciência de dados": "Ciência de Dados", "rh": "RH"}
    
    
    def carregar_100() -> pd.DataFrame:
        df = pd.read_csv("dados/funcionarios_100.csv")
        df["salario"] = pd.to_numeric(df["salario"].str.replace("R$ ", "", regex=False))
        df["departamento"] = df["departamento"].str.strip().str.lower().map(CANONICO)
        iso = pd.to_datetime(df["data_admissao"], format="%Y-%m-%d", errors="coerce")
        df["data_admissao"] = iso.fillna(pd.to_datetime(df["data_admissao"], format="%d/%m/%Y", errors="coerce"))
        return df.drop_duplicates("id_funcionario", keep="last").reset_index(drop=True)
    
    
    df = carregar_100()
    grupo = df.groupby("departamento")["salario"]
    df["z_no_depto"] = (df["salario"] - grupo.transform("mean")) / grupo.transform("std")
    df["participacao"] = df["salario"] / grupo.transform("sum")
    print(df[["nome", "departamento", "salario", "z_no_depto"]].head(3).round(2))
    print(round(float(df.groupby("departamento")["participacao"].sum().round(6).mean()), 3))
    
    Saída
               nome departamento  salario  z_no_depto
    0   Lucas Nunes           RH   4520.0       -1.20
    1    João Rocha   Engenharia  10740.0        0.81
    2  Olívia Nunes     Finanças   8610.0       -0.34
    1.0
    

    O z_no_depto diz quantos desvios padrão a pessoa está acima ou abaixo do próprio departamento. E a participação, somada dentro de cada departamento, dá 1 (o 1.0 impresso). Eu monto o z-score com as funções internas do transform ("mean" e "std"), em vez de uma lambda, porque as internas são vetorizadas e muito mais rápidas:

    intermediario/cap23_groupby_avancado.pylinhas 36 a 37
    com_lambda = grupo.transform(lambda s: (s - s.mean()) / s.std())
    print(bool(np.allclose(com_lambda.dropna(), df["z_no_depto"].dropna())))
    
    Saída
    True
    

    O resultado é o mesmo. Preferir a função pelo nome é o hábito que mais acelera um groupby.

    `filter`: descartar grupos inteiros

    O filter mantém todas as linhas dos grupos que passam em um teste e descarta os demais. Difere do filtro comum (que olha linha por linha): aqui o teste é sobre o grupo:

    intermediario/cap23_groupby_avancado.pylinhas 42 a 43
    grandes = df.groupby("departamento").filter(lambda g: len(g) >= 20)
    print(df["departamento"].nunique(), grandes["departamento"].nunique(), len(df), len(grandes))
    
    Saída
    5 3 100 70
    

    `apply`: quando nada mais serve

    O apply entrega cada grupo como uma tabela à sua função, e junta o que ela devolver. É o mais flexível, e o mais lento. Um uso legítimo: os dois maiores salários de cada departamento:

    intermediario/cap23_groupby_avancado.pylinhas 48 a 49
    top2 = df.groupby("departamento").apply(lambda g: g.nlargest(2, "salario")).reset_index(level=0)
    print(top2[["nome", "departamento", "salario"]].sort_values(["departamento", "salario"], ascending=[True, False]).head(4))
    
    Saída
                   nome      departamento  salario
    32  Renata Carvalho  Ciência de Dados  13620.0
    88  Marina Carvalho  Ciência de Dados  13470.0
    5     Isabela Gomes        Engenharia  13680.0
    43   Olívia Pereira        Engenharia  11660.0
    

    Repare no reset_index(level=0): o departamento não está dentro de cada grupo que a função recebe (uma mudança do Pandas 3, explicada mais abaixo), e por isso ele vem no índice do resultado. O reset_index o devolve como coluna.

    Mas aqui há uma alternativa que não usa apply, mais rápida e igualmente clara: ordenar e pegar as n primeiras linhas de cada grupo com o head:

    intermediario/cap23_groupby_avancado.pylinhas 51 a 52
    top2_rapido = df.sort_values("salario", ascending=False).groupby("departamento").head(2)
    print(sorted(top2["id_funcionario"]) == sorted(top2_rapido["id_funcionario"]), len(top2_rapido))
    
    Saída
    True 10
    

    Mudança do Pandas 3: a função do apply não recebe mais a coluna de agrupamento. Código antigo que usava g["departamento"] dentro da função quebra, e o parâmetro include_groups=True foi removido:

    intermediario/cap23_groupby_avancado.pylinhas 54 a 62
    def tem_coluna_de_grupo(g):
        return pd.Series({"tem_departamento": "departamento" in g.columns, "linhas": len(g)})
    
    
    print(bool(df.groupby("departamento").apply(tem_coluna_de_grupo)["tem_departamento"].iloc[0]))
    try:
        df.groupby("departamento").apply(tem_coluna_de_grupo, include_groups=True)
    except ValueError as erro:
        print("ValueError:", erro)
    
    Saída
    False
    ValueError: include_groups=True is no longer allowed.
    

    Se a função precisar do valor do grupo, ele está no nome do grupo (o índice do resultado), e não na tabela.

    Ranking e posição dentro do grupo

    O rank funciona por grupo, e o cumcount numera as linhas dentro de cada grupo:

    intermediario/cap23_groupby_avancado.pylinhas 67 a 68
    df["posicao_no_depto"] = df.groupby("departamento")["salario"].rank(method="min", ascending=False)
    print(df.loc[df["posicao_no_depto"] == 1, ["nome", "departamento", "salario"]].sort_values("departamento"))
    
    Saída
                   nome      departamento  salario
    32  Renata Carvalho  Ciência de Dados  13620.0
    5     Isabela Gomes        Engenharia  13680.0
    89     João Moreira          Finanças  13890.0
    64    Lucas Pereira         Marketing  12020.0
    78      Lucas Souza                RH  11050.0
    

    Várias funções, inclusive as suas

    A agregação nomeada aceita funções próprias. E quando há várias funções por coluna, as colunas do resultado ganham dois níveis, que dá para achatar:

    intermediario/cap23_groupby_avancado.pylinhas 73 a 82
    resumo = df.groupby("departamento").agg(
        media=("salario", "mean"),
        p90=("salario", lambda s: s.quantile(0.9)),
        funcionarios=("id_funcionario", "count"),
    ).round(0)
    print(resumo)
    
    duplo = df.groupby("departamento").agg({"salario": ["mean", "max"]})
    duplo.columns = ["_".join(par) for par in duplo.columns]
    print(duplo.columns.tolist())
    
    Saída
                        media      p90  funcionarios
    departamento                                    
    Ciência de Dados  10152.0  13110.0            20
    Engenharia         9012.0  11198.0            23
    Finanças           9305.0  11488.0            27
    Marketing          8470.0  11665.0            16
    RH                 7924.0  10564.0            11
    ['salario_mean', 'salario_max']
    

    Dentro do grupo, no tempo: acumular e deslocar

    Com uma tabela ordenada por data, o cumsum acumula por grupo, e o shift traz o valor da linha anterior do mesmo grupo. É assim que se calcula o intervalo entre a compra atual e a anterior de cada cliente:

    intermediario/cap23_groupby_avancado.pylinhas 87 a 101
    def carregar_vendas() -> pd.DataFrame:
        pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
        produtos = pd.read_csv("dados/produtos.csv")
        v = pedidos.merge(produtos, on="id_produto", how="left", validate="m:1")
        v["receita"] = v["quantidade"] * v["preco_unitario"] * (1 - v["desconto"].fillna(0))
        return v.sort_values(["id_cliente", "data_pedido", "id_pedido"]).reset_index(drop=True)
    
    
    vendas = carregar_vendas()
    por_cliente = vendas.groupby("id_cliente")
    vendas["receita_acumulada"] = por_cliente["receita"].cumsum()
    vendas["numero_da_compra"] = por_cliente.cumcount() + 1
    vendas["dias_desde_a_anterior"] = (vendas["data_pedido"] - por_cliente["data_pedido"].shift(1)).dt.days
    cliente_1 = vendas[vendas["id_cliente"] == 1][["numero_da_compra", "dias_desde_a_anterior", "receita_acumulada"]]
    print(cliente_1.head(3).round(1))
    
    Saída
       numero_da_compra  dias_desde_a_anterior  receita_acumulada
    0                 1                    NaN             1541.5
    1                 2                   58.0             5245.1
    2                 3                   21.0             6223.8
    

    A primeira compra de cada cliente fica com NaN nos dias, porque não tem anterior.

    A ordem das linhas importa

    O shift, o cumsum e o cumcount dependem da ordem em que as linhas estão. Se a tabela não estiver ordenada por data, o "anterior" é só a linha de cima, e o resultado é errado sem erro nenhum. Ordene antes (como fiz acima) e, em um relatório, confira com uma amostra.

    Exercício 1

    Os três maiores de cada departamento

    Escreva tres_maiores_por_depto(tabela), sem apply, que devolva, para cada departamento, os 3 maiores salários (sort_values e groupby().head(3)), e confira que nenhum departamento tem mais de 3 linhas.