Capítulo 17 de 27 10 secciones 14 min

Cuando las filas van en orden

Tres años de venta diaria peruana, y el corte al azar que se ve un 23% mejor de lo que es.

Todo lo que llevamos suponía que las filas son intercambiables, y con una fecha de por medio dejan de serlo. Yo lo mido aquí sobre tres años de venta diaria: partir al azar da un error de S/ 1.652,67 y partir por fecha da S/ 2.147,47, o sea que el corte al azar se ve un 23% mejor de lo que es porque entrena con el futuro 📆

El capítulo 16 enseñó a partir los datos y a repetir el corte varias veces. Todo correcto, y con una suposición escondida: que las filas son intercambiables 🔀

Con una fecha de por medio dejan de serlo, y ahí todo lo anterior se rompe de una forma que no da ningún error.

Una pregunta antes de empezar: ¿qué tiene de malo predecir el martes pasado usando datos del viernes siguiente? Suena absurdo dicho así, y es exactamente lo que hace train_test_split sobre una serie 📆

Tres años de venta diaria

Este archivo es distinto del que usa el resto del libro. Aquí no hay una fila por pedido con su ciudad, su canal y su segmento: hay una fila por día, con lo que se facturó ese día en toda la distribuidora. Y esa diferencia lo cambia todo, porque las filas dejan de ser intercambiables 📆

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import TimeSeriesSplit, train_test_split

URL = 'https://missyera.com/static/datasets/ventas-diarias-peru.csv'
d = pd.read_csv(URL, parse_dates=['fecha'])
print('filas:', len(d), '| de', d['fecha'].min().date(), 'a', d['fecha'].max().date())
print('fechas duplicadas:', int(d['fecha'].duplicated().sum()))
filas: 1087 | de 2023-01-01 a 2025-12-31
fechas duplicadas: 3

Tres fechas repetidas, de un reproceso de la carga. En una tabla normal un duplicado infla una suma; en una serie además rompe el orden, y todo lo que viene después asume que hay una fila por día.

El error que sale si no las quitas

Y esas tres fechas repetidas no son un detalle estético. Mira qué pasa si intentas ordenar la serie por día sin quitarlas:

d.set_index('fecha').reindex(
    pd.date_range(d['fecha'].min(), d['fecha'].max(), freq='D'))
ValueError: cannot reindex on an axis with duplicate labels

duplicate labels. pandas no sabe cuál de las dos ventas del mismo día poner, y hace lo correcto: negarse 🛑

Este error es de los buenos, porque te obliga a decidir. Y decidir es de negocio, no técnico: si son dos cargas del mismo día hay que quedarse con una, y si de verdad hubo dos cierres de caja hay que sumarlas. Aquí son un reproceso, así que se quitan.

El hueco que no es un cero

d = d.drop_duplicates(subset='fecha').sort_values('fecha').reset_index(drop=True)
esperados = (d['fecha'].max() - d['fecha'].min()).days + 1
print('dias que deberia haber:', esperados, '| hay:', len(d), '| faltan:', esperados - len(d))

completo = d.set_index('fecha').reindex(
    pd.date_range(d['fecha'].min(), d['fecha'].max(), freq='D'))
print('nulos tras reindexar:', int(completo['venta'].isna().sum()))
print('nulos en transacciones:', int(completo['transacciones'].isna().sum()))
dias que deberia haber: 1096 | hay: 1084 | faltan: 12
nulos tras reindexar: 12
nulos en transacciones: 59

Faltan doce días, y esto es de lo más importante del capítulo: un día sin fila no es un día de venta cero 🕳️

Son días en que no se cerró caja. Si los tratas como ceros hundes el promedio y te inventas doce caídas que no existieron. Y si simplemente los ignoras, cualquier cuenta que dependa del orden (un retardo de siete días, una media móvil) se desplaza sin avisar.

Por eso el reindex: obliga a que exista una fila por día y convierte el hueco invisible en un NaN visible, que es lo que sabes tratar desde el capítulo 6 👀

Lo que hace distinta a una serie

s = completo['venta']
print('venta media por dia de la semana:')
print(s.groupby(s.index.dayofweek).mean().round(0).to_string())
print()
print('autocorrelacion:')
for k in [1, 7, 14, 28, 365]:
    print(f'  retardo {k:3}: {s.autocorr(k):.4f}')
venta media por dia de la semana:
0    11346.0
1    11855.0
2    11633.0
3    11740.0
4    13055.0
5    14642.0
6     4728.0

autocorrelacion:
  retardo   1: 0.2341
  retardo   7: 0.7686
  retardo  14: 0.7584
  retardo  28: 0.7331
  retardo 365: 0.1209

Mira las dos tablas juntas, que cuentan lo mismo 🔍

El sábado vende 14.642 y el domingo 4.728, o sea tres veces menos. Y la autocorrelación lo dice en números: ayer te informa poco (0,2341) y el mismo día de la semana pasada te informa muchísimo (0,7686).

La autocorrelación es la correlación de la serie consigo misma corrida N días. Es la herramienta para descubrir el ritmo sin saber de antemano cuál es, y aquí grita "siete" 📢

Y si te preguntas por el 0,1209 del retardo 365: con tres años de datos hay muy pocos pares separados un año exacto, así que ese número dice más de la falta de historia que de la estacionalidad anual.

Sobre 1.096 días de venta en orden, el corte al azar entrena hasta el 31 de diciembre de 2025 y prueba en enero de 2023, y da un error de 1.653 soles que se ve un 23% mejor de lo que es. El corte por fecha entrena hasta abril de 2025 y prueba desde el día siguiente, con un error de 2.147 soles.
El corte al azar no da ningún error y devuelve un número más bonito, que es la peor combinación posible.

Y ahora el error caro

Armamos un modelo normal, con las columnas que uno armaría:

  • Qué día es
  • Qué mes
  • Cuánto se vendió hace 1
  • 7 y 14 días
  • La media de la última semana
  • Si es feriado
t = completo.copy()
t['venta'] = t['venta'].interpolate()
t['dia_semana'] = t.index.dayofweek
t['mes'] = t.index.month
for k in [1, 7, 14]:
    t[f'lag{k}'] = t['venta'].shift(k)
t['media7'] = t['venta'].shift(1).rolling(7).mean()
t = t.dropna()

COLS = ['dia_semana', 'mes', 'lag1', 'lag7', 'lag14', 'media7', 'feriado']
X, y = t[COLS], t['venta']
print('filas para modelar:', len(t))
print('columnas:', COLS)
filas para modelar: 1024
columnas: ['dia_semana', 'mes', 'lag1', 'lag7', 'lag14', 'media7', 'feriado']

De 1.096 días quedan 1.024, y conviene saber de dónde salen los 72 que se fueron. Unos pocos son el arranque, porque los primeros catorce días no tienen retardo de catorce. Y el resto se los llevó dropna() por una columna que ni siquiera estamos usando: transacciones, que tiene 59 nulos de los días que el contador estuvo caído 😅

Eso es un descuido clásico y silencioso. dropna() a secas mira todas las columnas, así que una que te da igual puede borrarte filas buenas. Si te importan esas 59, se escribe t.dropna(subset=COLS + ['venta']) y listo.

Fíjate en el .shift(1) de la media móvil, que no sobra. Sin él la media de siete días incluiría el día que estamos prediciendo, y eso es fuga de información del capítulo 12 con ropa de serie 🚩

Ahora los dos cortes, el de siempre y el correcto:

Xa, Xb, ya, yb = train_test_split(X, y, test_size=0.25, random_state=42)
m1 = LinearRegression().fit(Xa, ya)
err_azar = mean_absolute_error(yb, m1.predict(Xb))

corte = int(len(t) * 0.75)
m2 = LinearRegression().fit(X.iloc[:corte], y.iloc[:corte])
err_fecha = mean_absolute_error(y.iloc[corte:], m2.predict(X.iloc[corte:]))

print(f'error medio con corte al azar  : S/ {err_azar:,.2f}')
print(f'error medio con corte por fecha: S/ {err_fecha:,.2f}')
print(f'el corte al azar se ve {100*(err_fecha-err_azar)/err_fecha:.1f}% mejor de lo que es')
error medio con corte al azar  : S/ 1,652.67
error medio con corte por fecha: S/ 2,147.47
el corte al azar se ve 23.0% mejor de lo que es

Un 23% mejor de lo que es 😳

Y no hubo ningún error, ninguna advertencia y ningún aviso. El código corre igual y devuelve un número más bonito, que es la peor combinación posible.

La prueba de qué pasó cabe en dos líneas:

print('con el corte al azar:')
print('  ultima fecha de entrenamiento:', Xa.index.max().date())
print('  primera fecha de prueba      :', Xb.index.min().date())
print('con el corte por fecha:')
print('  ultima fecha de entrenamiento:', X.index[:corte].max().date())
print('  primera fecha de prueba      :', X.index[corte:].min().date())
con el corte al azar:
  ultima fecha de entrenamiento: 2025-12-31
  primera fecha de prueba      : 2023-01-17
con el corte por fecha:
  ultima fecha de entrenamiento: 2025-04-03
  primera fecha de prueba      : 2025-04-04

Ahí está, negro sobre blanco: el modelo del corte al azar se entrenó hasta el 31 de diciembre de 2025 y se probó en enero de 2023 🤯

Predijo el pasado sabiendo el futuro. Por eso le fue tan bien, y por eso ese número no vale nada.

Si tus filas tienen fecha, la prueba va después del entrenamiento. Siempre, y sin excepciones.

Repetir el corte, pero hacia adelante

La validación cruzada del capítulo 16 también hay que rehacerla, y scikit-learn ya trae la versión correcta:

tscv = TimeSeriesSplit(n_splits=5)
errores = []
for i, (a, b) in enumerate(tscv.split(X), 1):
    mm = LinearRegression().fit(X.iloc[a], y.iloc[a])
    e = mean_absolute_error(y.iloc[b], mm.predict(X.iloc[b]))
    errores.append(e)
    print(f'  ventana {i}: entrena {len(a):4} dias, prueba {len(b):3} -> S/ {e:,.0f}')
print(f'  media: S/ {np.mean(errores):,.0f}')
  ventana 1: entrena  174 dias, prueba 170 -> S/ 1,732
  ventana 2: entrena  344 dias, prueba 170 -> S/ 1,963
  ventana 3: entrena  514 dias, prueba 170 -> S/ 1,908
  ventana 4: entrena  684 dias, prueba 170 -> S/ 1,784
  ventana 5: entrena  854 dias, prueba 170 -> S/ 2,323
  media: S/ 1,942

Fíjate en la columna de la izquierda: el entrenamiento crece y la prueba siempre va después. Es como se usaría de verdad, reentrenando cada cierto tiempo con todo lo que ya pasó ⏩

Y mira la ventana 5, que es la peor de las cinco con S/ 2.323. Eso no es ruido: es el modelo enfrentándose al tramo más reciente, que es el que más se parece a lo que va a tener que predecir mañana. Si hay una ventana a la que hacerle caso, es la última.

El modelo tonto, que aquí es obligatorio

El capítulo 1 insiste en tener contra qué comparar. En series el punto de comparación es tan simple que da vergüenza y hay que hacerlo igual: repetir lo que pasó el mismo día de la semana pasada.

tonto = mean_absolute_error(y.iloc[corte:], X.iloc[corte:]['lag7'])
print(f'repetir el mismo dia de la semana pasada: S/ {tonto:,.2f}')
print(f'nuestro modelo                          : S/ {err_fecha:,.2f}')
print(f'mejora: {100*(tonto-err_fecha)/tonto:.1f}%')
repetir el mismo dia de la semana pasada: S/ 2,613.19
nuestro modelo                          : S/ 2,147.47
mejora: 17.8%

Un 17,8% mejor que copiar la semana pasada 🎉

Y eso sí es un resultado que se puede contar, porque tiene contra qué. Si el modelo hubiera salido peor que esta línea, lo honesto sería decirlo y usar la línea, que además no hay que mantener.

Lo que este capítulo no cubre

Series de tiempo es una especialidad entera y aquí cabe lo imprescindible. Para que sepas qué buscar cuando te haga falta:

Si necesitasBusca
Separar tendencia, ciclo y ruido para verlosDescomposición estacional
Un pronóstico clásico bien hechoARIMA y suavizado exponencial, que sobre pocas series le ganan a cualquier red
Predecir varios días de golpePronóstico a varios pasos, que es otro problema y no este
Un intervalo alrededor del pronósticoPredicción conforme, que ya está en el capítulo 24
Saber si el quiebre de 2024 fue realDetección de puntos de cambio

Y una que no está en la tabla porque es de criterio: con una sola serie y tres años, casi siempre gana lo simple. Las redes del libro de deep learning desde cero piden muchísimas más series o muchísimo más histórico para justificarse 📉

La trampa

Un equipo pronostica la venta del mes que viene. Escalan las columnas antes de partir, como se hace siempre, y el error de prueba les sale buenísimo.

from sklearn.preprocessing import StandardScaler

X_escalado = StandardScaler().fit_transform(X)      # sobre TODO el historico

corte = int(len(X) * 0.75)
modelo = LinearRegression().fit(X_escalado[:corte], y[:corte])
error = mean_absolute_error(y[corte:], modelo.predict(X_escalado[corte:]))
Qué está mal

El corte por fecha está bien hecho y aun así hay fuga, porque el fit_transform se hizo antes y sobre todo el histórico. La media y la desviación con las que se escala salen de datos que incluyen el tramo de prueba, o sea del futuro. Es el mismo error del capítulo de fuga, y sobre una serie es más difícil de ver porque el corte sí parece correcto. Se arregla igual que allá: el escalado va dentro de un pipeline que se ajusta solo con el tramo de entrenamiento. Y hay una versión todavía más escondida de lo mismo: rellenar los huecos con interpolate sobre la serie entera, que también mira hacia adelante. En este capítulo se hace a propósito y se puede porque los doce huecos están repartidos, pero en un proyecto de verdad hay que rellenar solo con lo que ya pasó 🚩

Ejercicios

Seis, y el 2 es el que más veces vas a repetir en el trabajo 💛

1. Encuentra el ritmo sin que te lo digan

Recorre la autocorrelación del retardo 1 al 40 y saca los cinco más altos.

autos = {k: s.autocorr(k) for k in range(1, 41)}
top = sorted(autos.items(), key=lambda x: -x[1])[:5]
for k, v in top:
    print(f'  retardo {k:3}: {v:.4f}')

Los cinco tienen que ser múltiplos de 7 o estar pegados. Así se descubre el ritmo de una serie que no conoces, y funciona igual con datos por hora, donde el número mágico es 24.

2. Mueve el corte y mira si aguanta

Prueba el corte por fecha en el 60%, 70%, 80% y 90% y compara los errores.

for pct in [0.6, 0.7, 0.8, 0.9]:
    k = int(len(t) * pct)
    mm = LinearRegression().fit(X.iloc[:k], y.iloc[:k])
    e = mean_absolute_error(y.iloc[k:], mm.predict(X.iloc[k:]))
    print(f'  corte en {pct:.0%}: S/ {e:,.0f}  ({len(t)-k} dias de prueba)')

Si el error cambia muchísimo según dónde cortes, tu estimación depende del tramo que te tocó y hay que reportar el rango, no un número. Es el mismo argumento del capítulo 16.

3. Los huecos, tratados de tres formas

Compara rellenar los doce huecos con cero, con la media y con interpolación.

for nombre, serie in [('con cero', s.fillna(0)),
                      ('con la media', s.fillna(s.mean())),
                      ('interpolando', s.interpolate())]:
    print(f'{nombre:14} media S/ {serie.mean():,.0f}   minimo S/ {serie.min():,.0f}')

Con cero la media se hunde y aparecen doce días catastróficos que nunca pasaron. Es el error del capítulo hecho a propósito para que lo veas en números.

4. El quiebre del segundo almacén

Compara la venta media antes y después del 1 de marzo de 2024, y después entrena solo con datos posteriores a esa fecha.

antes = s[s.index < '2024-03-01'].mean()
despues = s[s.index >= '2024-03-01'].mean()
print(f'antes del quiebre : S/ {antes:,.0f}')
print(f'despues           : S/ {despues:,.0f}  ({despues/antes:.2f}x)')

Hay un salto grande. La pregunta que abre esto es de negocio y no técnica: ¿los datos de antes del quiebre ayudan a predecir el después, o ensucian? Prueba las dos y decide con el número, no con la intuición.

5. Los feriados, que valen mucho

Quita la columna feriado del modelo y mira cuánto empeora.

sin_feriado = [c for c in COLS if c != 'feriado']
mm = LinearRegression().fit(X.iloc[:corte][sin_feriado], y.iloc[:corte])
e = mean_absolute_error(y.iloc[corte:], mm.predict(X.iloc[corte:][sin_feriado]))
print(f'con feriado : S/ {err_fecha:,.0f}')
print(f'sin feriado : S/ {e:,.0f}')

Una columna que sabes de antemano y que vale dinero. En Perú, además, hay que acordarse de que Semana Santa se mueve cada año, así que una lista fija de fechas no sirve.

6. Tu propia serie

Sin dataset. Exporta la venta diaria o semanal de tu negocio (o de tu propio gasto) y aplícale las tres primeras cosas del capítulo: reindexar por fecha, mirar la autocorrelación y partir por fecha.

Vale cualquier cosa que tenga fecha: la venta de tu bodega, las facturas emitidas por semana, o los pedidos que entran por WhatsApp. Con doce meses ya se ve la estacionalidad semanal.

Y si la autocorrelación no grita ningún número, eso también es un hallazgo: quiere decir que lo que mueve tu serie no es el calendario, y entonces hay que ir a buscar qué es.

Comprueba que lo tienes

Tienes dos años de venta diaria y te piden un pronóstico para el mes que viene. ¿Cuál es tu primer paso?

  • Reindexar por fecha, mirar la autocorrelación y medir el modelo tonto
  • Armar las columnas de retardo y entrenar un boosting
  • Partir con train_test_split y validación cruzada normal
  • Una red neuronal, que es lo que se usa para series

Lo que te llevas

  • 📆 Con fecha de por medio, la prueba va siempre después del entrenamiento.
  • 💸 Aquí el corte al azar se veía un 23% mejor, y entrenaba hasta 2025 para predecir 2023.
  • 🕳️ Un día sin fila no es un día de venta cero. Reindexa y trata el hueco.
  • 📢 La autocorrelación te dice el ritmo sin que nadie te lo cuente: 0,7686 en el retardo 7.
  • 🔁 TimeSeriesSplit es la validación cruzada de las series, y la última ventana es la que más importa.
  • 🪞 Y compara siempre contra repetir la semana pasada, que aquí daba S/ 2.613,19.

Y si lo que quieres es entender la serie en vez de predecirla, con sus intervalos y sus pruebas, eso es el libro de estadística desde cero 📐

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?