Indice / pandas: combinar y resumir / 6.5
pivot_table y reshape
Los mismos datos tienen dos formas: ancha, que es como se llena una planilla, y larga, que es como los quiere pandas. Pasar de una a otra es una línea, y saber cuál necesitás es media unidad 6.
Para qué sirve
Una planilla de asistencia tiene un alumno por fila y una columna por sesión. Es cómoda de llenar y de mirar, y es hostil para trabajar: contar ausencias obliga a sumar doce columnas a mano, y si el mes que viene hay trece sesiones hay que cambiar el código.
En formato largo cada celda es una fila - alumno, sesión, estado - y
la misma pregunta es un groupby de una línea que no depende de cuántas
sesiones haya.
ELI5Explicado facil
Es la diferencia entre una grilla de asistencia pegada en la pared y un cuaderno donde anotás un renglón cada vez que alguien falta.
La grilla se lee mejor de un vistazo. El cuaderno se cuenta mejor. Los dos tienen la misma información, y hay que saber pasar de uno al otro porque cada tarea pide uno.
El ejemplo mínimo
import pandas as pd
ancho = pd.DataFrame({
"legajo": ["L01", "L02"],
"sesion_1": ["P", "A"],
"sesion_2": ["A", "P"],
"sesion_3": ["P", "P"],
})
print("formato ancho:")
print(ancho.to_string(index=False))
print("2 filas x 3 sesiones = 6 celdas de datos")
largo = ancho.melt(id_vars="legajo", var_name="sesion", value_name="estado")
print()
print("formato largo:")
print(largo.to_string(index=False))
print(len(largo), "filas: una por celda")
print()
print("id_vars son las columnas que NO se derriten: quedan repetidas.")
print("var_name es como se llama la columna con los nombres viejos.")
print("value_name es como se llama la columna con el contenido.")
formato ancho:
legajo sesion_1 sesion_2 sesion_3
L01 P A P
L02 A P P
2 filas x 3 sesiones = 6 celdas de datos
formato largo:
legajo sesion estado
L01 sesion_1 P
L02 sesion_1 A
L01 sesion_2 A
L02 sesion_2 P
L01 sesion_3 P
L02 sesion_3 P
6 filas: una por celda
id_vars son las columnas que NO se derriten: quedan repetidas.
var_name es como se llama la columna con los nombres viejos.
value_name es como se llama la columna con el contenido.
Lo mismo, paso a paso
Seguí los colores: las cuatro celdas de un alumno en la tabla ancha son sus cuatro filas en la larga.
- Formato anchoTres alumnos y cuatro sesiones: 3 filas por 4 columnas. Es como se llena una planilla a mano, y es comodo de mirar. Cada color es un alumno.
asis = pd.read_csv("asistencia_sesiones.csv") - meltCada celda de datos se convierte en una FILA. Lo que era el nombre de la columna pasa a ser un valor de la columna sesion, y el contenido de la celda va a la columna estado. Seguí los colores: las cuatro celdas de un alumno son sus cuatro filas.
asis.melt(id_vars="legajo", value_vars=[...], var_name="sesion", value_name="estado") - Por que sirveEn formato largo, «cuantas ausencias tuvo cada alumno» es un groupby de una linea. En formato ancho hay que sumar doce columnas a mano, y si manana hay una sesion mas hay que cambiar el codigo.
largo[largo["estado"] == "A"].groupby("legajo").size() - pivot_tableY la vuelta: pivot_table toma el formato largo y reconstruye la planilla. index dice que va en las filas, columns que va en las columnas, y values que se pone adentro.
largo.pivot_table(index="legajo", columns="sesion", values="estado", aggfunc="first") - Sobre el archivo enteroEl archivo real tiene 30 alumnos y 12 sesiones. En largo son 360 filas, y ahi adentro estan los cinco valores raros que la planilla ancha esconde entre 360 celdas.
largo["estado"].value_counts(dropna=False)
Sobre datos de verdad
import pandas as pd
asis = pd.read_csv("asistencia_sesiones.csv")
sesiones = [c for c in asis.columns if c.startswith("sesion_")]
print("formato ancho:", asis.shape, "|", len(sesiones), "sesiones")
largo = asis.melt(id_vars=["legajo", "estudiante"], value_vars=sesiones,
var_name="sesion", value_name="estado")
print("formato largo:", largo.shape)
print(len(asis), "x", len(sesiones), "=", len(asis) * len(sesiones))
print()
print("Y ahora una linea contesta que hay adentro de las 360 celdas:")
print(largo["estado"].value_counts(dropna=False).to_string())
print()
print("P y A son 355. Los otros 5 son basura repartida en la planilla:")
raros = largo[~largo["estado"].isin(["P", "A"])]
print(raros[["legajo", "sesion", "estado"]].to_string(index=False))
print()
print("En formato ancho habria que revisar 12 columnas una por una.")
print("En largo es un value_counts.")
formato ancho: (30, 14) | 12 sesiones
formato largo: (360, 4)
30 x 12 = 360
Y ahora una linea contesta que hay adentro de las 360 celdas:
estado
P 283
A 72
X 1
PRESENTE 1
NaN 1
p 1
- 1
P y A son 355. Los otros 5 son basura repartida en la planilla:
legajo sesion estado
L2711 sesion_01 X
L2724 sesion_03 PRESENTE
L2703 sesion_05 NaN
L2717 sesion_08 p
L2728 sesion_10 -
En formato ancho habria que revisar 12 columnas una por una.
En largo es un value_counts.
La planilla ancha tiene 360 celdas repartidas en 12 columnas. Revisarlas obliga a
mirar columna por columna. En formato largo, un solo
value_counts encuentra los cinco valores que no son ni
P ni A: una X, un PRESENTE, una
p minúscula, un guion y un ausente.
Cinco defectos escondidos entre 360 celdas, en una línea.
pivot_table
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
tx["mes"] = tx["fecha"].str.slice(0, 7) # 2026-07, sin usar fechas todavia
panel = tx.pivot_table(index="sucursal", columns="mes", values="importe",
aggfunc="sum")
print("forma del panel:", panel.shape, "= sucursales x meses")
print("columnas:", list(panel.columns))
print()
print("Hay 7 meses de verdad y una columna que dice 'fecha_i'.")
print("Sale de una fila cuya fecha es literalmente 'fecha_invalida':")
print("cortar los primeros 7 caracteres de un texto roto da otro texto roto,")
print("y pivot_table lo acepta como si fuera un mes mas.")
print()
print(panel[["2026-01", "2026-02", "fecha_i"]].round(2).to_string())
print()
lleno = tx.pivot_table(index="sucursal", columns="mes", values="importe",
aggfunc="sum", fill_value=0)
print("con fill_value=0 los huecos se llenan:")
print(lleno[["2026-01", "2026-02", "fecha_i"]].round(2).to_string())
print()
print("Y ahi esta el problema de fill_value: pone 0 en cuatro sucursales")
print("que no tienen ninguna transaccion con fecha rota. Un 0 afirma")
print("'no hubo movimiento'; lo correcto seria que esa columna no exista.")
print("Validar la fecha ANTES de pivotar es lo que evita todo esto.")
forma del panel: (5, 8) = sucursales x meses
columnas: ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', 'fecha_i']
Hay 7 meses de verdad y una columna que dice 'fecha_i'.
Sale de una fila cuya fecha es literalmente 'fecha_invalida':
cortar los primeros 7 caracteres de un texto roto da otro texto roto,
y pivot_table lo acepta como si fuera un mes mas.
mes 2026-01 2026-02 fecha_i
sucursal
Centro 12199.04 6194.21 NaN
Este 11326.56 22356.94 588.64
Norte 13916.99 17228.04 NaN
Oeste 22827.87 10431.05 NaN
Sur 19709.38 32258.93 NaN
con fill_value=0 los huecos se llenan:
mes 2026-01 2026-02 fecha_i
sucursal
Centro 12199.04 6194.21 0.00
Este 11326.56 22356.94 588.64
Norte 13916.99 17228.04 0.00
Oeste 22827.87 10431.05 0.00
Sur 19709.38 32258.93 0.00
Y ahi esta el problema de fill_value: pone 0 en cuatro sucursales
que no tienen ninguna transaccion con fecha rota. Un 0 afirma
'no hubo movimiento'; lo correcto seria que esa columna no exista.
Validar la fecha ANTES de pivotar es lo que evita todo esto.
El panel salió con ocho columnas cuando hay siete meses. La octava
se llama fecha_i y viene de una fila cuya fecha es literalmente
fecha_invalida: cortar los primeros 7 caracteres de un texto roto da otro
texto roto, y pivot_table lo acepta como si fuera un mes más.
Y hay una fila más que directamente no aparece: la que tiene la fecha vacía. Su
«mes» quedó NaN y pivot_table descarta las claves ausentes,
igual que groupby.
Validar antes de pivotar. Un pivote no valida nada: toma los valores que encuentra y arma una columna por cada uno. Si entra basura, sale una columna de basura con un nombre creíble.
pivot no es pivot_table
import pandas as pd
tx = pd.read_csv("transacciones_sucursales.csv")
tx["mes"] = tx["fecha"].str.slice(0, 7)
try:
tx.pivot(index="sucursal", columns="mes", values="importe")
except ValueError as error:
print("pivot ->", type(error).__name__)
print(str(error).splitlines()[0])
print()
print("pivot solo REACOMODA: exige que cada par (sucursal, mes) aparezca")
print("una sola vez. Aca hay varias transacciones por sucursal y mes.")
print()
print("pivot_table AGREGA, y por eso hay que decirle como:")
print(tx.pivot_table(index="sucursal", columns="mes", values="importe",
aggfunc="sum").iloc[:3, :3].round(2).to_string())
print()
print("Su aggfunc por defecto es 'mean', que casi nunca es lo que queres")
print("y no avisa. Escribilo siempre.")
print()
media = tx.pivot_table(index="sucursal", columns="mes", values="importe")
suma = tx.pivot_table(index="sucursal", columns="mes", values="importe",
aggfunc="sum")
print("sin aggfunc (mean):", round(media.iloc[0, 0], 2))
print("con aggfunc='sum' :", round(suma.iloc[0, 0], 2))
pivot -> ValueError
Index contains duplicate entries, cannot reshape
pivot solo REACOMODA: exige que cada par (sucursal, mes) aparezca
una sola vez. Aca hay varias transacciones por sucursal y mes.
pivot_table AGREGA, y por eso hay que decirle como:
mes 2026-01 2026-02 2026-03
sucursal
Centro 12199.04 6194.21 21897.63
Este 11326.56 22356.94 37122.28
Norte 13916.99 17228.04 7845.09
Su aggfunc por defecto es 'mean', que casi nunca es lo que queres
y no avisa. Escribilo siempre.
sin aggfunc (mean): 3049.76
con aggfunc='sum' : 12199.04
pivot | pivot_table | |
|---|---|---|
| Qué hace | Solo reacomoda | Agrega y reacomoda |
| Si la clave se repite | ValueError | Agrega con aggfunc |
aggfunc | No tiene | Por defecto "mean" |
| Cuándo | Cuando cada par aparece una vez y querés que falle si no | El resto del tiempo |
aggfunc por defecto es "mean"
Y casi nunca es lo que querés. Un panel de importes por sucursal y mes con el default da el promedio de las transacciones, no el total: 3049.76 en vez de 12199.04. Los dos son números plausibles y nadie avisa cuál estás mirando.
Escribí aggfunc siempre, aunque sea para poner "mean".
La vuelta completa
import pandas as pd
asis = pd.read_csv("asistencia_sesiones.csv")
sesiones = [c for c in asis.columns if c.startswith("sesion_")]
largo = asis.melt(id_vars=["legajo", "estudiante"], value_vars=sesiones,
var_name="sesion", value_name="estado")
vuelta = (largo.pivot_table(index=["legajo", "estudiante"], columns="sesion",
values="estado", aggfunc="first")
.reset_index())
vuelta.columns.name = None
print("original:", asis.shape)
print("de vuelta:", vuelta.shape)
# Para comparar hay que ponerlos en el mismo orden y con las mismas columnas.
a = asis.sort_values("legajo", kind="stable").reset_index(drop=True)
b = vuelta[asis.columns].sort_values("legajo", kind="stable").reset_index(drop=True)
print()
print("son iguales:", bool(a.equals(b)))
print()
print("Ojo con el aggfunc='first': el pivote NECESITA una funcion aunque no")
print("haya nada que agregar, porque no sabe que cada par (alumno, sesion)")
print("aparece una sola vez. Si apareciera dos veces, 'first' elegiria una")
print("y no diria nada.")
print()
print("Y las celdas ausentes:", int(asis[sesiones].isna().sum().sum()),
"en el original,", int(vuelta[sesiones].isna().sum().sum()), "en la vuelta.")
original: (30, 14)
de vuelta: (30, 14)
son iguales: True
Ojo con el aggfunc='first': el pivote NECESITA una funcion aunque no
haya nada que agregar, porque no sabe que cada par (alumno, sesion)
aparece una sola vez. Si apareciera dos veces, 'first' elegiria una
y no diria nada.
Y las celdas ausentes: 1 en el original, 1 en la vuelta.
Ancho → largo → ancho devuelve exactamente el original. Eso es lo que hace seguro trabajar en largo: no se pierde nada, y la forma de presentación se elige al final.
Dónde se rompe
Causa: pivot y la combinación de índice y columna se repite.
Arreglo: si el duplicado es esperado, pivot_table con un
aggfunc declarado. Si no lo es, encontraste un problema de datos y
el error te lo está diciendo.
Causa: la columna que fue a columns= tiene un valor
inválido, y el pivote arma una columna con él.
Arreglo: validar antes de pivotar. Un pivote no filtra ni normaliza.
Causa: las filas cuya clave de índice o de columna es ausente se
descartan, igual que en groupby.
Arreglo: contarlas antes, y un assert comparando la suma
del panel con la del archivo.
Causa: no se declaró aggfunc y el default es
"mean".
Arreglo: escribirlo siempre.
Causa: no se declaró value_vars, así que todas las
columnas que no son id_vars se derritieron - incluidas las que
querías conservar como identificadores.
Arreglo: declarar las dos listas: id_vars y
value_vars. Es más largo y no sorprende.
Acá hay que elegir
fill_value: cero, o vacío
Un hueco en un panel puede significar dos cosas distintas y el pivote no las distingue:
- No hubo movimiento - entonces
0es correcto e informativo. - No hay datos de ese período - entonces
0es una mentira: afirma que se midió y dio cero.
El ejemplo de arriba muestra el caso feo: fill_value=0 puso un cero en
cuatro sucursales para una columna que ni siquiera debería existir. Antes de rellenar,
preguntate si el hueco es un dato o una ausencia de dato.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
df.melt(id_vars=, value_vars=) | Ancho → largo | Antes de agrupar o cruzar |
var_name= / value_name= | Nombra las dos columnas nuevas | Siempre: los defaults son variable y value |
df.pivot(index=, columns=, values=) | Reacomoda; falla si hay duplicados | Cuando querés que falle |
df.pivot_table(..., aggfunc="sum") | Agrega y reacomoda | El caso normal |
aggfunc="first" | Toma un valor sin agregar | Volver a ancho un largo sin repeticiones |
fill_value=0 | Rellena los huecos | Solo si el hueco significa cero |
margins=True | Agrega fila y columna de totales | Paneles para leer, no para seguir procesando |
.reset_index() | Baja el índice a columnas | Antes de escribir el panel |
df.columns.name = None | Saca el nombre del eje de columnas | Cosmético, pero el CSV sale más limpio |
panel.sum().sum() == df["c"].sum() | bool | El assert que atrapa las claves ausentes |