Indice / pandas: combinar y resumir / 6.4
groupby
Separar las filas por el valor de una columna, calcular algo en cada grupo y recomponer. Tres etapas que pandas hace en una línea, y un resultado que tiene una fila por grupo - que es el salto que cuesta dar.
Para qué sirve
«Cuánto vendió cada sucursal», «cuántas inscripciones tiene cada curso», «cuál es
la nota media por materia». Todas las preguntas que empiezan con «por cada…»
son un groupby.
Lo que hay que tener claro es la forma del resultado: entran 200 filas y salen 5. La clave deja de ser una columna y pasa a ser el índice, y desde ahí hay que decidir si volver a bajarla o no.
ELI5Explicado facil
Es repartir las cartas de un mazo en montones por palo y después contar cuántas hay en cada montón.
Tres pasos: separar, contar cada montón y anotar los resultados en una lista. Lo que devuelve pandas es esa lista final - cuatro renglones, uno por palo -, no las cartas.
El ejemplo mínimo
import pandas as pd
ventas = pd.DataFrame({
"vendedor": ["Ana", "Bruno", "Ana", "Carla", "Bruno", "Ana"],
"importe": [100.0, 250.0, 80.0, 300.0, 120.0, 60.0],
})
print("6 filas de entrada:")
print(ventas.to_string(index=False))
print()
print('groupby("vendedor").size() -> cuantas filas tiene cada grupo:')
print(ventas.groupby("vendedor").size().to_string())
print()
print('groupby("vendedor")["importe"].sum():')
print(ventas.groupby("vendedor")["importe"].sum().to_string())
print()
print("El resultado tiene 3 filas: una por vendedor. Ese es el salto.")
print("Y el vendedor dejo de ser una columna: ahora es el INDICE.")
resumen = ventas.groupby("vendedor")["importe"].sum()
print("indice:", list(resumen.index))
6 filas de entrada:
vendedor importe
Ana 100.0
Bruno 250.0
Ana 80.0
Carla 300.0
Bruno 120.0
Ana 60.0
groupby("vendedor").size() -> cuantas filas tiene cada grupo:
vendedor
Ana 3
Bruno 2
Carla 1
groupby("vendedor")["importe"].sum():
vendedor
Ana 240.0
Bruno 370.0
Carla 300.0
El resultado tiene 3 filas: una por vendedor. Ese es el salto.
Y el vendedor dejo de ser una columna: ahora es el INDICE.
indice: ['Ana', 'Bruno', 'Carla']
Lo mismo, paso a paso
Las tres etapas a la vista: la tabla con cada fila pintada según su grupo, y debajo el resultado, una fila por grupo y del mismo color.
- Punto de partida7 productos de tres categorias, mezclados como vienen en el archivo. groupby no cambia estas filas: cambia como se las mira.
inv = pd.read_csv("inventario_productos.csv") - splitPrimera etapa. pandas separa las filas por el valor de la columna clave. Aca estan reordenadas para que se vea, pero el DataFrame original no se toco: la separacion es interna.
inv.groupby("categoria") - apply + combineSegunda y tercera etapa juntas: se aplica una funcion a cada grupo y los resultados se recombinan en una tabla nueva. Mira el resultado: tiene una fila por GRUPO, no por fila original. Ese es el salto que cuesta dar.
inv.groupby("categoria").size() - Otra funcionLa misma separacion, otra funcion. Cambiar sum por mean o por max no cambia los grupos: cambia que se calcula dentro de cada uno.
inv.groupby("categoria")["cantidad"].sum() - aggY varias a la vez, cada una con el nombre que quieras. Esta es la forma que conviene aprender: dice en una sola expresion que columna, que funcion y como se va a llamar el resultado.
inv.groupby("categoria").agg( productos=("codigo", "size"), stock=("cantidad", "sum"), precio_medio=("precio_unitario", "mean"))
agg
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
resumen = (tx.groupby("sucursal")
.agg(operaciones=("id", "size"),
total=("importe", "sum"),
promedio=("importe", "mean"),
maximo=("importe", "max"))
.round(2))
print(resumen.to_string())
print()
print("La forma es siempre la misma: nombre_de_salida=(columna, funcion).")
print("Se lee de corrido y el resultado ya viene con los nombres puestos.")
print()
print("Mira la sucursal Sur: su maximo es de casi 100 millones y su promedio")
print("se fue con el. Un solo valor puede dominar un grupo entero.")
print("Por eso conviene mirar la mediana al lado de la media:")
print(tx.groupby("sucursal")["importe"].median().round(2).to_string())
operaciones total promedio maximo
sucursal
Centro 36 1.344753e+05 3735.42 6288.49
Este 39 1.248336e+05 3285.10 6428.97
Norte 32 1.018894e+05 3184.04 6246.81
Oeste 41 1.337894e+05 3344.74 6386.45
Sur 52 1.001482e+08 1925927.16 99999999.99
La forma es siempre la misma: nombre_de_salida=(columna, funcion).
Se lee de corrido y el resultado ya viene con los nombres puestos.
Mira la sucursal Sur: su maximo es de casi 100 millones y su promedio
se fue con el. Un solo valor puede dominar un grupo entero.
Por eso conviene mirar la mediana al lado de la media:
sucursal
Centro 3943.23
Este 3099.60
Norte 3117.62
Oeste 3802.20
Sur 2816.10
agg(nombre_de_salida=(columna, funcion)). Se lee de corrido, dice las
tres cosas en una expresión y el resultado ya viene con los nombres puestos. Las otras
formas de agg - un diccionario, una lista de funciones -
producen columnas con nombres que después hay que arreglar.
Y mirá la sucursal Sur: su máximo es de casi 100 millones y el promedio se fue con él. Un solo valor domina el grupo entero. La mediana al lado de la media es la comprobación más barata de que eso no está pasando.
size() no es count()
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
comparacion = pd.DataFrame({
"size": tx.groupby("sucursal").size(),
"count_importe": tx.groupby("sucursal")["importe"].count(),
})
comparacion["diferencia"] = comparacion["size"] - comparacion["count_importe"]
print(comparacion.to_string())
print()
print("size() cuenta FILAS del grupo.")
print("count() cuenta VALORES no ausentes de esa columna.")
print("La diferencia son los importes que faltan:",
int(tx["importe"].isna().sum()), "en todo el archivo.")
print()
print("Importa mas de lo que parece: si informas 'operaciones' con count()")
print("sobre una columna que tiene ausentes, el conteo baja sin motivo.")
print("Y si calculas un promedio dividiendo por size() en vez de por count(),")
print("el promedio baja.")
print()
# Este es una de las dos sucursales a las que les falta un importe.
suma = tx.groupby("sucursal")["importe"].sum()
print("Este, promedio con mean() :",
round(tx.groupby("sucursal")["importe"].mean().loc["Este"], 2))
print("Este, suma / size() (mal) :",
round((suma / tx.groupby("sucursal").size()).loc["Este"], 2))
print("La diferencia es dividir 38 valores por 39 filas.")
size count_importe diferencia
sucursal
Centro 36 36 0
Este 39 38 1
Norte 32 32 0
Oeste 41 40 1
Sur 52 52 0
size() cuenta FILAS del grupo.
count() cuenta VALORES no ausentes de esa columna.
La diferencia son los importes que faltan: 2 en todo el archivo.
Importa mas de lo que parece: si informas 'operaciones' con count()
sobre una columna que tiene ausentes, el conteo baja sin motivo.
Y si calculas un promedio dividiendo por size() en vez de por count(),
el promedio baja.
Este, promedio con mean() : 3285.1
Este, suma / size() (mal) : 3200.86
La diferencia es dividir 38 valores por 39 filas.
size() cuenta filas del grupo.
count() cuenta valores no ausentes de una columna. La
diferencia son exactamente los ausentes.
El caso que se cobra: informar «operaciones» con count() sobre una
columna que tiene ausentes hace bajar el conteo sin ningún motivo visible. Y dividir
una suma por size() en vez de por count() da un promedio
más bajo que el de mean() - acá, 3200.86 contra 3285.10.
El grupo que desaparece
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
print("filas:", len(tx))
print("categorias ausentes:", int(tx["categoria"].isna().sum()))
por_defecto = tx.groupby("categoria")["importe"].sum()
con_nulos = tx.groupby("categoria", dropna=False)["importe"].sum()
print()
print("groupby('categoria') ->", len(por_defecto), "grupos")
print("groupby('categoria', dropna=False) ->", len(con_nulos), "grupos")
print()
print("total del archivo :", round(tx["importe"].sum(), 2))
print("suma de los grupos (default):", round(por_defecto.sum(), 2))
print("diferencia :",
round(tx["importe"].sum() - por_defecto.sum(), 2))
print()
print("Esa diferencia es una transaccion real que se cayo del informe")
print("porque no tiene categoria. El default de groupby es dropna=True,")
print("y por eso los totales agrupados pueden no sumar el total.")
filas: 200
categorias ausentes: 1
groupby('categoria') -> 9 grupos
groupby('categoria', dropna=False) -> 10 grupos
total del archivo : 100643200.19
suma de los grupos (default): 100642713.54
diferencia : 486.65
Esa diferencia es una transaccion real que se cayo del informe
porque no tiene categoria. El default de groupby es dropna=True,
y por eso los totales agrupados pueden no sumar el total.
El default de groupby es dropna=True: las filas
cuya clave es ausente no forman ningún grupo y desaparecen del resultado. Acá
son 486,65 pesos de una transacción real que no llegan al informe.
Es un caso más de lo mismo que viene apareciendo en todo el curso: los totales
agrupados pueden no sumar el total, y el motivo no se ve en ningún lado. La
comprobación es una línea:
assert resumen["total"].sum() == df["importe"].sum().
Dos claves, y volver a columnas
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
dos = (tx.groupby(["sucursal", "tipo"])
.agg(operaciones=("id", "size"), total=("importe", "sum"))
.round(2))
print(dos.head(6).to_string())
print()
print("El indice tiene dos niveles:", dos.index.names)
print("Es comodo para mirar y molesto para seguir trabajando: filtrar,")
print("ordenar o escribir un CSV es mas simple con columnas normales.")
print()
plano = (tx.groupby(["sucursal", "tipo"], as_index=False)
.agg(operaciones=("id", "size"), total=("importe", "sum"))
.round(2))
print("con as_index=False:")
print(plano.head(4).to_string(index=False))
print("columnas:", list(plano.columns))
print()
print("reset_index() sobre el primero da exactamente lo mismo.")
print("los dos coinciden:", bool(dos.reset_index().equals(plano)))
print()
print("Ojo con el tipo 'Ingresso', que es un error de tipeo y arma su")
print("propio grupo. groupby no normaliza nada: agrupa por el valor exacto.")
print(tx["tipo"].value_counts().to_string())
operaciones total
sucursal tipo
Centro Gasto 13 50618.60
Ingreso 23 83856.67
Este Gasto 19 66023.42
Ingreso 20 58810.22
Norte Gasto 20 61367.48
Ingreso 12 40521.93
El indice tiene dos niveles: ['sucursal', 'tipo']
Es comodo para mirar y molesto para seguir trabajando: filtrar,
ordenar o escribir un CSV es mas simple con columnas normales.
con as_index=False:
sucursal tipo operaciones total
Centro Gasto 13 50618.60
Centro Ingreso 23 83856.67
Este Gasto 19 66023.42
Este Ingreso 20 58810.22
columnas: ['sucursal', 'tipo', 'operaciones', 'total']
reset_index() sobre el primero da exactamente lo mismo.
los dos coinciden: True
Ojo con el tipo 'Ingresso', que es un error de tipeo y arma su
propio grupo. groupby no normaliza nada: agrupa por el valor exacto.
tipo
Gasto 104
Ingreso 95
Ingresso 1
as_index=False o reset_index()
Dan exactamente el mismo resultado. El primero se lee mejor cuando la agrupación es una etapa más de una cadena; el segundo, cuando ya tenías el resultado agrupado y lo querés aplanar. Cualquiera de los dos, pero uno: un índice de dos niveles es incómodo para filtrar, ordenar y escribir.
Dónde se rompe
Causa: hay filas con la clave ausente y el default
dropna=True las descartó.
Arreglo: dropna=False para verlas como un grupo propio, o
contarlas aparte. Y un assert que compare las dos sumas.
Causa: groupby agrupa por el valor exacto.
"Ingreso" e "Ingresso" son dos grupos, igual que
"Rosario" y " rosario".
Arreglo: normalizar antes de agrupar (lección 5.2). Es el mismo orden de siempre: primero normalizar, después operar.
Causa: la clave dejó de ser una columna y pasó a ser el índice.
Arreglo: as_index=False o reset_index().
Causa: se dividió la suma por size() en vez de usar
mean(). Las filas con el valor ausente entran en el denominador y
no en el numerador.
Arreglo: mean(), que ya hace lo correcto. Si necesitás el
otro promedio, es una decisión y hay que escribirla.
Causa: se usó agg con una lista o una función suelta, y
pandas nombró la columna como pudo.
Arreglo: la forma con nombres:
agg(total=("importe", "sum")).
Recetario
| Expresión | Qué devuelve | Cuándo |
|---|---|---|
df.groupby("k").size() | Filas por grupo | «Cuántos hay de cada uno» |
df.groupby("k")["c"].sum() | Una Series por grupo | Un solo cálculo |
df.groupby("k").agg(n=("c", "sum")) | DataFrame con nombres propios | La forma que conviene aprender |
df.groupby(["a", "b"]) | Índice de dos niveles | Cruce de dos dimensiones |
groupby(..., as_index=False) | La clave queda como columna | Para seguir trabajando o escribir |
groupby(..., dropna=False) | Los ausentes forman su propio grupo | Cuando el total tiene que cerrar |
df.groupby("k")["c"].count() | Valores no ausentes | Distinto de size() |
df.groupby("k")["c"].median() | Mediana por grupo | Al lado de la media, siempre |
df.groupby("k")["c"].nunique() | Valores distintos por grupo | «Cuántos clientes distintos» |
df.groupby("k").agg(...).reset_index() | Resultado plano | Antes de escribir el CSV |
resumen["t"].sum() == df["c"].sum() | bool | El assert que atrapa el dropna |