Saltar al contenido

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:

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

EjemploUn indice de fechas habilita operaciones que no existen sin el
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.
Primero convertir, después indexar

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

Ejemploresample crea los periodos vacios; groupby no
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
205 días contra 94

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

Ejemplodiff y shift: comparar una fila con la 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

Paso a pasoDe eventos sueltos a sesiones: diff, umbral y suma acumulada
  1. 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")
  2. 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
  3. 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)
  4. 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
  5. 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

EjemploSesionizar: de eventos sueltos a sesiones, en tres lineas
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:

  1. groupby("usuario")["fecha"].diff() - cuánto pasó desde el evento anterior del mismo usuario.
  2. > HUECO - una máscara booleana: dónde se corta.
  3. .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

EjemploEl error que no da error: diff sin ordenar
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.

TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex

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.

(sin error) los cortes dan el doble de lo esperado

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.

(sin error) el promedio diario es el doble de lo esperado

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.

(sin error) un usuario tiene una sesión de 14 horas

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.

(sin error) los cortes no coinciden con las sesiones

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

Cuánto dura un hueco

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

Series de tiempo
ExpresiónQué haceCuándo
pd.to_datetime(s, format=)Texto a fechaSiempre primero
df.set_index("f").sort_index()Índice temporal ordenadoAntes de resample
df.resample("D").size()Filas por día, incluidos los vacíosSeries diarias
resample("MS") / "W" / "h"Mes, semana, horaOtros períodos
s.diff()Diferencia con la fila anteriorHuecos, variaciones
s.shift(1)El valor de la fila anteriorComparar, no restar
.dt.total_seconds() / 60Un Timedelta en minutosPara leerlo o compararlo
pd.Timedelta(minutes=30)Una duraciónEl umbral, escrito una vez
(hueco).cumsum() + 1Número de sesiónAgrupar lo consecutivo
groupby("u")["f"].diff()Diferencia dentro de cada grupoSesionizar por usuario
.dt.year / .dt.month / .dt.hourPartes de la fechaAgrupar por período sin resample
(s.diff() < pd.Timedelta(0)).any()boolEl assert de «ordené antes»

Ponete a prueba

1. ¿Que necesita resample para funcionar?Nivel 1 · Recordar
2. ¿Que devuelve s.diff() en la primera fila?Nivel 1 · Recordar
3. Un periodo tiene 205 dias. resample("D").size() devuelve 205 filas y groupby por dia devuelve 94. ¿Por que?Nivel 2 · Predecir la salida
4. ¿Que hace hueco.cumsum() + 1, donde hueco es una mascara booleana?Nivel 2 · Predecir la salida
5. Sesionizar sin ordenar da 206 cortes en vez de 88. ¿Por que?Nivel 3 · Elegir entre dos casi correctas
6. Hay 88 cortes y 40 usuarios con eventos. ¿Cuantas sesiones son?Nivel 3 · Elegir entre dos casi correctas
7. Este codigo cuenta pedidos por dia para un grafico. El promedio da 1.28 y el negocio dice que es la mitad. ¿Por que?
p["creado"] = pd.to_datetime(p["creado"])
diario = p.groupby(p["creado"].dt.date).size()
print(diario.mean())
Nivel 4 · Encontrar el defecto

Prácticas de esta lección