groupby() y .agg()

python
pandas
eda
El motor de análisis de pandas. Split-apply-combine, agregaciones múltiples con .agg(), named aggregations al estilo dplyr, transform vs agg vs apply y por qué observed=True salva análisis con factores.

Split-apply-combine

groupby() implementa un patrón fundamental del análisis: dividir los datos en grupos, aplicar una función a cada grupo, combinar los resultados. Es el motor de la mayoría de los resúmenes de EDA.

Equivalente directo a group_by() + summarise() de dplyr, con más flexibilidad y algo más de sintaxis.

import numpy as np
import pandas as pd

df = pd.DataFrame({
    "region":     ["Norte", "Sur", "Norte", "Sur", "Norte", "Sur"],
    "trimestre":  ["Q1", "Q1", "Q2", "Q2", "Q3", "Q3"],
    "ventas":     [1500, 2100, 1800, 2400, 1600, 2200],
    "clientes":   [120, 180, 145, 200, 130, 190],
})

df.groupby("region")["ventas"].sum()
# region
# Norte    4900
# Sur      6700
# Name: ventas, dtype: int64

Lectura: agrupar por region, tomar la columna ventas, sumar dentro de cada grupo. El resultado es una Series con region como índice.

Anatomía de groupby()

df.groupby("col") devuelve un objeto GroupBy, no calcula nada todavía. Es un “plan de agrupación” que se evalúa cuando aplicas una operación:

g = df.groupby("region")
g                              # <DataFrameGroupBy object>
g.sum(numeric_only=True)       # un DataFrame por suma
g.mean(numeric_only=True)      # un DataFrame por media
g.size()                       # tamaño de cada grupo
g.first()                      # primera fila de cada grupo

Agrupar por varias columnas:

df.groupby(["region", "trimestre"])["ventas"].sum()
# region  trimestre
# Norte   Q1           1500
#         Q2           1800
#         Q3           1600
# Sur     Q1           2100
#         ...

El resultado tiene MultiIndex, un índice jerárquico. Para volver a columnas planas: .reset_index().

.agg(): resúmenes múltiples

.agg() es la operación más expresiva. Te permite especificar qué función aplicar a qué columna.

Forma simple: una función, varias columnas

df.groupby("region").agg("sum")        # suma de todas las columnas numéricas
df.groupby("region").agg(["sum", "mean"])  # suma y media de cada una

Named aggregations: el patrón idiomático

df.groupby("region").agg(
    total_ventas=("ventas", "sum"),
    media_ventas=("ventas", "mean"),
    n_clientes=("clientes", "sum"),
    n_periodos=("trimestre", "nunique"),
)
#         total_ventas  media_ventas  n_clientes  n_periodos
# region
# Norte           4900    1633.333..         395           3
# Sur             6700    2233.333..         570           3

Cada parámetro keyword (total_ventas=) es el nombre de la columna resultante, y la tupla ("ventas", "sum") es (columna_origen, función_agregadora). Equivalente exacto a summarise(total_ventas = sum(ventas), ...) en dplyr.

Este patrón es el idiomático moderno. Es legible, controla los nombres del resultado, soporta funciones personalizadas. Úsalo por defecto.

Funciones personalizadas

def rango(x):
    return x.max() - x.min()

df.groupby("region").agg(
    rango_ventas=("ventas", rango),
    p90_ventas=("ventas", lambda x: x.quantile(0.9)),
)

Acepta cualquier función que tome una Series y devuelva un escalar. Útil para percentiles, métricas de negocio, ratios.

transform(): mantener el shape original

.agg() reduce: 6 filas, 2 grupos → 2 filas. A veces quieres mantener las 6 filas pero añadir información del grupo. Eso es .transform():

df["ventas_regionales"] = df.groupby("region")["ventas"].transform("sum")
df["pct_region"] = df["ventas"] / df["ventas_regionales"] * 100

transform("sum") calcula la suma por región, pero devuelve un valor por cada fila original (replicando el total del grupo en cada fila del grupo). Equivalente al mutate(x = sum(y)) dentro de un group_by() en dplyr.

Uso típico: calcular porcentajes sobre el grupo, normalizar (z-score por grupo), centrar por grupo.

df["z_ventas"] = df.groupby("region")["ventas"].transform(
    lambda x: (x - x.mean()) / x.std()
)

apply() en groupby: la última opción

.apply() con groupby es muy flexible, puedes hacer cualquier cosa con cada grupo. También es el más lento y a menudo innecesario:

def top_2_clientes(grupo):
    return grupo.nlargest(2, "clientes")

df.groupby("region").apply(top_2_clientes, include_groups=False)

Usa apply solo cuando la operación devuelve un DataFrame por grupo (no un escalar), como en este caso. Para reducciones, siempre .agg().

El argumento include_groups=False (pandas 2.2+) excluye las columnas de agrupación del DataFrame que recibe la función, recomendado para evitar warnings de futuras versiones.

agg vs transform vs apply

Método Devuelve Cuándo
.agg() Reducción (1 valor por grupo) Resúmenes
.transform() Mismo tamaño (1 valor por fila) Nuevas columnas con info de grupo
.apply() Arbitrario (lo que la función decida) Operaciones complejas no estándar

90% del análisis cabe en .agg(). El otro 10% se reparte entre .transform() y, raramente, .apply().

Sort y observed

Dos argumentos de groupby() que importan:

df.groupby("region", sort=False)        # no ordena alfabéticamente
df.groupby("categoria", observed=True)  # ignora niveles sin datos

sort=False deja los grupos en el orden de aparición, más rápido y a menudo más útil.

observed=True es crítico con Categoricals (columnas con tipo category, equivalente a factores de R). Sin él, pandas incluye todos los niveles definidos aunque no aparezcan en los datos, dando filas vacías. Es uno de los gotchas más sutiles de pandas:

df["region"] = df["region"].astype("category")
df.groupby("region", observed=True).agg(...)   # solo grupos presentes
df.groupby("region", observed=False).agg(...)  # todos los niveles, incluso vacíos

A partir de pandas 2.1, el default es observed=True. En versiones anteriores, siempre especifica explícito, el default antiguo (False) es una fuente clásica de bugs.

Filtrar grupos

.filter() mantiene los grupos que cumplen una condición:

# Solo regiones con más de 5000 en ventas totales
df.groupby("region").filter(lambda g: g["ventas"].sum() > 5000)

Recibe un grupo, devuelve True o False. Los grupos que cumplen quedan. Los que no, se descartan. Equivalente al group_by() %>% filter() con condición agregada de dplyr.

Trampas habituales

  • Olvidar observed=True con Categoricals. Con pandas < 2.1, agrupar por una columna category con muchos niveles genera filas vacías por cada combinación no observada. Resultado: groupby muy lento y output gigantesco. Usa observed=True siempre.
  • reset_index() olvidado. Después de un groupby().agg(), los grupos quedan en el índice. Si quieres un DataFrame “plano”, .reset_index(). Patrón frecuente al final del chain.
  • as_index=False para evitarlo. df.groupby("region", as_index=False).agg(...) deja los grupos como columna. Más limpio para análisis ad-hoc.
  • MultiIndex tras agrupar por varias columnas. Si te incomoda, .reset_index() lo aplana. O úsalo: filtrar .loc[("Norte", "Q1")] es elegante con MultiIndex.
  • Confundir agg y apply. Si necesitas un escalar por grupo, siempre agg (más rápido, más claro). apply es para casos donde cada grupo devuelve un DataFrame.

En la siguiente entrega

Tienes resúmenes potentes. Ahora hay dos operaciones que reorganizan los datos: melt (de wide a long) y pivot (de long a wide). Son el equivalente a pivot_longer y pivot_wider de tidyr. Lo siguiente.