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:
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))
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:
com_lambda = grupo.transform(lambda s: (s - s.mean()) / s.std())
print(bool(np.allclose(com_lambda.dropna(), df["z_no_depto"].dropna())))
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:
grandes = df.groupby("departamento").filter(lambda g: len(g) >= 20)
print(df["departamento"].nunique(), grandes["departamento"].nunique(), len(df), len(grandes))
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:
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))
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:
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))
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:
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)
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:
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"))
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:
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())
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:
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))
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, ocumsume ocumcountdependem 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.