Saltar al contenido

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

EjemploUna fila por grupo, no una fila por fila
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.

Paso a pasosplit - apply - combine: de una fila por producto a una por categoria
  1. 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")
  2. 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")
  3. 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()
  4. 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()
  5. 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

Ejemploagg: varias funciones, con el nombre que vos elijas
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
La forma que conviene aprender

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()

Ejemplosize() y count() no son lo mismo
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.
Dos conteos que casi siempre coinciden, hasta que no

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

EjemploEl grupo que desaparece: dropna en las claves
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

EjemploAgrupar por dos columnas, y volver a tener 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

(sin error) los totales por grupo no suman el total

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.

(sin error) aparece un grupo con un nombre casi igual a otro

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.

KeyError después de agrupar

Causa: la clave dejó de ser una columna y pasó a ser el índice.

Arreglo: as_index=False o reset_index().

(sin error) el promedio da más bajo de lo esperado

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.

(sin error) el resultado tiene una columna llamada 0

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

groupby
ExpresiónQué devuelveCuándo
df.groupby("k").size()Filas por grupo«Cuántos hay de cada uno»
df.groupby("k")["c"].sum()Una Series por grupoUn solo cálculo
df.groupby("k").agg(n=("c", "sum"))DataFrame con nombres propiosLa forma que conviene aprender
df.groupby(["a", "b"])Índice de dos nivelesCruce de dos dimensiones
groupby(..., as_index=False)La clave queda como columnaPara seguir trabajando o escribir
groupby(..., dropna=False)Los ausentes forman su propio grupoCuando el total tiene que cerrar
df.groupby("k")["c"].count()Valores no ausentesDistinto de size()
df.groupby("k")["c"].median()Mediana por grupoAl 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 planoAntes de escribir el CSV
resumen["t"].sum() == df["c"].sum()boolEl assert que atrapa el dropna

Ponete a prueba

1. Un archivo de 200 filas se agrupa por una columna con 5 valores distintos. ¿Cuantas filas tiene el resultado de agg?Nivel 1 · Recordar
2. Despues de df.groupby("sucursal").sum(), ¿donde quedo la columna sucursal?Nivel 1 · Recordar
3. ¿Que diferencia hay entre groupby("k").size() y groupby("k")["c"].count()?Nivel 2 · Predecir la salida
4. Los totales por categoria suman 100.642.713,54 y el total del archivo es 100.643.200,19. ¿Que pasa?Nivel 2 · Predecir la salida
5. Al agrupar por tipo aparecen tres grupos: Gasto, Ingreso e Ingresso. ¿Que conviene hacer?Nivel 3 · Elegir entre dos casi correctas
6. ¿Cual es la forma de agg que conviene usar?Nivel 3 · Elegir entre dos casi correctas
7. Este informe tiene que dar el promedio de importe por sucursal. Da mas bajo que mean() y nadie entiende por que.
suma = tx.groupby("sucursal")["importe"].sum()
filas = tx.groupby("sucursal").size()
promedio = suma / filas
Nivel 4 · Encontrar el defecto

Prácticas de esta lección