groupby() y .agg()
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: int64Lectura: 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 grupoAgrupar 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 unaNamed 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 3Cada 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"] * 100transform("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 datossort=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íosA 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=Truecon Categoricals. Con pandas < 2.1, agrupar por una columnacategorycon muchos niveles genera filas vacías por cada combinación no observada. Resultado:groupbymuy lento y output gigantesco. Usaobserved=Truesiempre. reset_index()olvidado. Después de ungroupby().agg(), los grupos quedan en el índice. Si quieres un DataFrame “plano”,.reset_index(). Patrón frecuente al final del chain.as_index=Falsepara 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
aggyapply. Si necesitas un escalar por grupo, siempreagg(más rápido, más claro).applyes 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.