Indice / pandas: combinar y resumir / 6.6
Series de tiempo
Cuando el índice es una fecha, pandas puede hacer cosas que no puede hacer de otra forma: completar los días que faltan, comparar una fila con la anterior, y cortar una secuencia de eventos en sesiones.
Para qué sirve
Tres preguntas que aparecen todo el tiempo y que groupby no contesta
bien:
- «Pedidos por día» - incluyendo los días sin ningún
pedido, que
groupbyno puede inventar. - «Cuánto pasó desde el evento anterior» - que compara una fila con otra, algo que ninguna agregación hace.
- «Cuántas visitas distintas hizo cada usuario» - donde «visita» no existe en los datos: hay que inventarla a partir de los huecos.
ELI5Explicado facil
Tenés el registro de cada vez que alguien entró a una tienda. Nadie anotó «acá empieza una visita»: solo hay horarios sueltos.
Pero si ves cuatro entradas seguidas a las 8:07, 8:24, 8:35 y 8:45, y la siguiente recién a las 11:44, es evidente que fueron dos visitas y no cinco. Lo que separa una visita de otra es el hueco - y cuán grande tiene que ser el hueco lo decidís vos.
El ejemplo mínimo
import pandas as pd
pedidos = pd.read_csv("tienda_pedidos.csv")
pedidos["creado"] = pd.to_datetime(pedidos["creado"],
format="%Y-%m-%d %H:%M:%S", errors="coerce")
print("dtype de creado:", pedidos["creado"].dtype)
print("fechas invalidas:", int(pedidos["creado"].isna().sum()))
# Con la fecha en el indice, pandas puede agrupar por periodo.
serie = pedidos.set_index("creado").sort_index()
print()
print("primer pedido:", serie.index.min())
print("ultimo pedido :", serie.index.max())
print()
print("pedidos por mes:")
print(serie.resample("MS").size().to_string())
print()
print('resample("MS") agrupa por mes calendario y, a diferencia de groupby,')
print("crea los periodos que no tienen ninguna fila. Eso es lo que lo hace")
print("util: un mes sin ventas aparece con 0, no desaparece.")
dtype de creado: datetime64[us]
fechas invalidas: 0
primer pedido: 2026-01-01 13:14:00
ultimo pedido : 2026-07-24 07:46:00
pedidos por mes:
creado
2026-01-01 17
2026-02-01 15
2026-03-01 12
2026-04-01 17
2026-05-01 23
2026-06-01 24
2026-07-01 12
Freq: MS
resample("MS") agrupa por mes calendario y, a diferencia de groupby,
crea los periodos que no tienen ninguna fila. Eso es lo que lo hace
util: un mes sin ventas aparece con 0, no desaparece.
resample necesita un índice de tipo fecha. Si la columna todavía es
texto, set_index funciona igual y resample falla con un
mensaje que no ayuda. El orden es siempre
to_datetime → set_index →
sort_index.
resample completa los huecos
import pandas as pd
pedidos = pd.read_csv("tienda_pedidos.csv")
pedidos["creado"] = pd.to_datetime(pedidos["creado"],
format="%Y-%m-%d %H:%M:%S", errors="coerce")
serie = pedidos.set_index("creado").sort_index()
diario_resample = serie.resample("D").size()
diario_groupby = serie.groupby(serie.index.date).size()
print("dias que devuelve resample:", len(diario_resample))
print("dias que devuelve groupby :", len(diario_groupby))
print("dias con 0 pedidos :", int((diario_resample == 0).sum()))
print()
print("La diferencia son los dias SIN pedidos. groupby solo puede agrupar")
print("lo que existe; resample conoce el calendario y completa los huecos.")
print()
print("primeros 6 dias:")
print(diario_resample.head(6).to_string())
print()
print("Para un grafico o para calcular un promedio diario, los ceros")
print("importan: sin ellos el promedio se calcula sobre menos dias y sale")
print("mas alto de lo que corresponde.")
print("promedio con ceros :", round(diario_resample.mean(), 2))
print("promedio sin ceros :", round(diario_groupby.mean(), 2))
dias que devuelve resample: 205
dias que devuelve groupby : 94
dias con 0 pedidos : 111
La diferencia son los dias SIN pedidos. groupby solo puede agrupar
lo que existe; resample conoce el calendario y completa los huecos.
primeros 6 dias:
creado
2026-01-01 1
2026-01-02 1
2026-01-03 0
2026-01-04 1
2026-01-05 0
2026-01-06 1
Freq: D
Para un grafico o para calcular un promedio diario, los ceros
importan: sin ellos el promedio se calcula sobre menos dias y sale
mas alto de lo que corresponde.
promedio con ceros : 0.59
promedio sin ceros : 1.28
resample("D") devuelve todos los días del período,
incluidos los 111 que no tuvieron ningún pedido. groupby devuelve solo
los 94 que sí.
El promedio diario con los ceros da 0,59 pedidos; sin ellos, 1,28. Más del doble. Los dos números son correctos y contestan preguntas distintas: «cuántos pedidos por día tuvo el negocio» y «cuántos pedidos por día tuvo, los días que hubo pedidos». Casi siempre se quiere el primero y casi siempre se calcula el segundo.
Comparar con la fila anterior
import json
import pandas as pd
with open("eventos_usuarios.json", encoding="utf-8") as f:
crudo = json.load(f)
eventos = pd.json_normalize(crudo["eventos"])
eventos["fecha"] = pd.to_datetime(eventos["fecha"],
format="%Y-%m-%dT%H:%M:%S", errors="coerce")
uno = (eventos[eventos["usuario"] == "u10"]
.dropna(subset=["fecha"])
.sort_values("fecha", kind="stable")
.reset_index(drop=True))
uno["anterior"] = uno["fecha"].shift(1)
uno["minutos"] = uno["fecha"].diff().dt.total_seconds() / 60
print(uno[["fecha", "tipo", "anterior", "minutos"]].head(8).to_string(index=False))
print()
print("shift(1) trae el valor de la fila anterior; diff() da la resta.")
print("La primera fila no tiene anterior, asi que su minutos es NaN:")
print("no es un dato faltante, es que no existe un evento previo.")
print()
print("eventos de u10:", len(uno))
print("hueco mas largo:", round(uno['minutos'].max(), 1), "minutos")
fecha tipo anterior minutos
2026-08-24 08:07:00 page_view NaT NaN
2026-08-24 08:24:00 page_view 2026-08-24 08:07:00 17.0
2026-08-24 08:35:00 page_view 2026-08-24 08:24:00 11.0
2026-08-24 08:45:00 page_view 2026-08-24 08:35:00 10.0
2026-08-24 11:44:00 page_view 2026-08-24 08:45:00 179.0
2026-08-24 11:53:00 page_view 2026-08-24 11:44:00 9.0
2026-08-24 12:00:00 page_view 2026-08-24 11:53:00 7.0
2026-08-24 15:06:00 page_view 2026-08-24 12:00:00 186.0
shift(1) trae el valor de la fila anterior; diff() da la resta.
La primera fila no tiene anterior, asi que su minutos es NaN:
no es un dato faltante, es que no existe un evento previo.
eventos de u10: 14
hueco mas largo: 186.0 minutos
shift(1) trae el valor de la fila anterior y diff() da la
resta. La primera fila queda en NaN, y eso no es un dato faltante: es que
no existe un evento previo.
Lo mismo, paso a paso
- Los eventosOcho eventos de un mismo usuario, ordenados por hora. A simple vista son ocho lineas sueltas: nada dice donde empieza y termina una visita.
uno = eventos[eventos["usuario"] == "u10"].sort_values("fecha") - diffLa diferencia con el evento anterior, en minutos. La primera fila queda vacia y esta bien: no hay ningun evento antes que ella. Ahi aparecen dos huecos enormes entre ratos de actividad seguida.
uno["fecha"].diff().dt.total_seconds() / 60 - El umbralUn hueco de mas de 30 minutos corta la sesion. El 30 es una decision, no un descubrimiento: con 15 saldrian mas sesiones y con 60 menos. Tiene que estar escrito en algun lado.
hueco = uno["fecha"].diff() > pd.Timedelta(minutes=30) - cumsumY el truco que resuelve todo: la suma acumulada de los cortes. Cada vez que hay un corte el numero sube, y entre corte y corte se queda quieto. Eso es exactamente el numero de sesion.
sesion = hueco.cumsum() + 1 - Sobre el archivo enteroLo mismo sobre los 596 eventos validos de los 40 usuarios: 88 cortes. Como cada usuario arranca con la sesion 1, las sesiones son 88 + 40 = 128.
orden.groupby("usuario")["fecha"].diff() > HUECO
Sesionizar
import json
import pandas as pd
HUECO = pd.Timedelta(minutes=30)
with open("eventos_usuarios.json", encoding="utf-8") as f:
crudo = json.load(f)
eventos = pd.json_normalize(crudo["eventos"])
eventos["usuario"] = eventos["usuario"].str.strip()
eventos["fecha"] = pd.to_datetime(eventos["fecha"],
format="%Y-%m-%dT%H:%M:%S", errors="coerce")
validos = eventos[(eventos["usuario"] != "") & eventos["fecha"].notna()]
print("eventos leidos :", len(eventos))
print("eventos validos:", len(validos))
print("usuarios :", validos["usuario"].nunique())
# Las tres lineas. Ordenar por usuario y fecha es obligatorio: diff compara
# con la fila anterior de la TABLA, no con el evento anterior del usuario.
orden = validos.sort_values(["usuario", "fecha"], kind="stable")
hueco = orden.groupby("usuario")["fecha"].diff() > HUECO
sesion = hueco.groupby(orden["usuario"]).cumsum() + 1
print()
print("cortes (huecos de mas de 30 minutos):", int(hueco.sum()))
print("sesiones:", int(hueco.sum()) + validos["usuario"].nunique())
print("Cada usuario arranca con la sesion 1, y cada corte suma una.")
resumen = (orden.assign(sesion=sesion)
.groupby(["usuario", "sesion"], as_index=False)
.agg(eventos=("fecha", "size"),
inicio=("fecha", "min"),
fin=("fecha", "max")))
resumen["minutos"] = ((resumen["fin"] - resumen["inicio"])
.dt.total_seconds() / 60).round(1)
print()
print(resumen.head(5).to_string(index=False))
print()
print("sesiones en total:", len(resumen))
eventos leidos : 600
eventos validos: 596
usuarios : 40
cortes (huecos de mas de 30 minutos): 88
sesiones: 128
Cada usuario arranca con la sesion 1, y cada corte suma una.
usuario sesion eventos inicio fin minutos
u01 1 2 2026-08-24 10:22:00 2026-08-24 10:38:00 16.0
u01 2 7 2026-08-24 11:48:00 2026-08-24 12:12:00 24.0
u01 3 2 2026-08-24 13:39:00 2026-08-24 13:54:00 15.0
u01 4 6 2026-08-24 15:36:00 2026-08-24 16:09:00 33.0
u02 1 6 2026-08-24 09:03:00 2026-08-24 09:36:00 33.0
sesiones en total: 128
Tres líneas para una idea que suena complicada:
groupby("usuario")["fecha"].diff()- cuánto pasó desde el evento anterior del mismo usuario.> HUECO- una máscara booleana: dónde se corta..cumsum() + 1- la suma acumulada de los cortes es el número de sesión.
El cumsum sobre booleanos es el truco que hace todo: sube en cada
corte y se queda quieto entre corte y corte. Vale la pena mirarlo una vez con
atención, porque reaparece en cualquier problema de «agrupar lo consecutivo».
Dónde se rompe
import json
import pandas as pd
HUECO = pd.Timedelta(minutes=30)
with open("eventos_usuarios.json", encoding="utf-8") as f:
crudo = json.load(f)
eventos = pd.json_normalize(crudo["eventos"])
eventos["usuario"] = eventos["usuario"].str.strip()
eventos["fecha"] = pd.to_datetime(eventos["fecha"],
format="%Y-%m-%dT%H:%M:%S", errors="coerce")
validos = eventos[(eventos["usuario"] != "") & eventos["fecha"].notna()]
bien = validos.sort_values(["usuario", "fecha"], kind="stable")
cortes_bien = int((bien.groupby("usuario")["fecha"].diff() > HUECO).sum())
# Mismo codigo, sin ordenar. El archivo viene en el orden en que se
# registraron los eventos, que no es el orden de cada usuario.
mal = validos
cortes_mal = int((mal.groupby("usuario")["fecha"].diff() > HUECO).sum())
print("cortes ordenando :", cortes_bien)
print("cortes sin ordenar:", cortes_mal)
print("diferencia :", cortes_mal - cortes_bien)
print()
print("Los dos programas corren. Los dos devuelven un numero plausible.")
print("Uno esta bien y el otro no, y no hay forma de saber cual mirando")
print("el resultado.")
print()
# Y ademas, sin ordenar aparecen diferencias negativas: el evento
# 'anterior' resulto ser posterior.
delta = mal.groupby("usuario")["fecha"].diff()
print("diferencias negativas sin ordenar:",
int((delta < pd.Timedelta(0)).sum()))
print("ordenando:",
int((bien.groupby("usuario")["fecha"].diff() < pd.Timedelta(0)).sum()))
print()
print("Un assert de que ninguna diferencia es negativa atrapa esto solo.")
cortes ordenando : 88
cortes sin ordenar: 206
diferencia : 118
Los dos programas corren. Los dos devuelven un numero plausible.
Uno esta bien y el otro no, y no hay forma de saber cual mirando
el resultado.
diferencias negativas sin ordenar: 292
ordenando: 0
Un assert de que ninguna diferencia es negativa atrapa esto solo.
Sin ordenar: 206 cortes en vez de 88, y 292 diferencias negativas - el evento «anterior» resultó ser posterior. Los dos programas corren y los dos devuelven un número plausible.
Causa: resample sobre un DataFrame cuyo índice no es de
fechas, casi siempre porque la columna sigue siendo texto.
Arreglo: to_datetime antes de set_index. Mirá
df.index.dtype: tiene que decir datetime64.
Causa: no se ordenó por (clave, fecha) antes del
diff. La diferencia se calcula contra la fila anterior de la
tabla, no contra el evento anterior del usuario.
Arreglo: sort_values(["usuario", "fecha"],
kind="stable"), y un assert de que ninguna diferencia es
negativa.
Causa: se agrupó por día con groupby, que no crea los días
sin actividad, y se promedió sobre menos días.
Arreglo: resample("D"), o dividir por la cantidad real de
días del período.
Causa: el umbral es demasiado grande, o hay un evento con la fecha mal parseada que quedó en otro momento del día.
Arreglo: validar las fechas antes de sesionizar. En este archivo hay 3 inválidas y 1 evento sin usuario: 4 de 600 que hay que apartar primero.
Causa: se olvidó que cada usuario arranca con una sesión. Las sesiones son cortes + usuarios, no cortes.
Arreglo: 88 + 40 = 128. Escribirlo como assert
lo deja comprobado en cada corrida.
Acá hay que elegir
Los 30 minutos no salen de los datos: es una convención de la industria para analítica web, y como toda convención hay que declararla.
- Umbral corto (5-15 min) - más sesiones, más cortas. Sirve para medir «intentos» o interacciones puntuales.
- 30 minutos - el default de la mayoría de las herramientas. Es lo que se espera si nadie dice otra cosa.
- Umbral largo (1-4 h) - menos sesiones, más largas. Sirve cuando la actividad es lenta por naturaleza.
Y hay una decisión más que se olvida: ¿la sesión se corta a la medianoche? Si la respuesta es sí, un usuario activo a las 23:55 y a las 00:05 tiene dos sesiones aunque no haya ningún hueco. Las dos respuestas son defendibles; lo que no vale es no haberlo decidido.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
pd.to_datetime(s, format=) | Texto a fecha | Siempre primero |
df.set_index("f").sort_index() | Índice temporal ordenado | Antes de resample |
df.resample("D").size() | Filas por día, incluidos los vacíos | Series diarias |
resample("MS") / "W" / "h" | Mes, semana, hora | Otros períodos |
s.diff() | Diferencia con la fila anterior | Huecos, variaciones |
s.shift(1) | El valor de la fila anterior | Comparar, no restar |
.dt.total_seconds() / 60 | Un Timedelta en minutos | Para leerlo o compararlo |
pd.Timedelta(minutes=30) | Una duración | El umbral, escrito una vez |
(hueco).cumsum() + 1 | Número de sesión | Agrupar lo consecutivo |
groupby("u")["f"].diff() | Diferencia dentro de cada grupo | Sesionizar por usuario |
.dt.year / .dt.month / .dt.hour | Partes de la fecha | Agrupar por período sin resample |
(s.diff() < pd.Timedelta(0)).any() | bool | El assert de «ordené antes» |