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.
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 necesitas | Busca |
|---|---|
| Separar tendencia, ciclo y ruido para verlos | Descomposición estacional |
| Un pronóstico clásico bien hecho | ARIMA y suavizado exponencial, que sobre pocas series le ganan a cualquier red |
| Predecir varios días de golpe | Pronóstico a varios pasos, que es otro problema y no este |
| Un intervalo alrededor del pronóstico | Predicción conforme, que ya está en el capítulo 24 |
| Saber si el quiebre de 2024 fue real | Detecció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.
- 🔁
TimeSeriesSplites 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! 🌸