Validación y Análisis · Tema 06

Validación histórica

Si tu modelo no puede contar el pasado que ya ocurrió, no le creas el futuro: el backtesting honesto es el primer juicio sin piedad.

01 · Punto de partida

El pasado ya trae la respuesta escrita

La validación histórica (backtesting) es simple: congelás el modelo, lo hacés «predecir» un período pasado que ya mediste y comparás. Si con el diario del lunes no acierta, el problema no es la incertidumbre del futuro: es el modelo.

  • ¿Qué período pasado es un juez justo?
  • ¿Cómo evitar que el modelo «espíe» el futuro?
  • ¿Cuándo un buen pasado garantiza poco futuro?
  • ¿Cómo distinguir ajuste genuino de sobreajuste?

02 · Definición

Backtesting honesto en una frase

🗂

Ventana reservada

Una parte del pasado se esconde durante la calibración y solo se revela para el veredicto. Sin reserva no hay prueba.

🚫

Sin fuga de futuro

El modelo no puede usar datos de t+1 para «predecir» t. Cada pronóstico usa solo pasado disponible en ese instante.

🔁

Ventanas múltiples

Un solo período puede ser suerte. Repetir en varias ventanas (walk-forward) mide estabilidad, no casualidad.

Tres diseños históricos de menor a mayor exigencia.
DiseñoIdeaRiesgo que controla
Partición únicaCalibrar en 0…c, probar en c…TOptimimso circular
Ventanas deslizantesRepetir el corte en varios puntosSuerte de un período
Walk-forwardAvanzar calibrando solo con pasadoFuga de futuro

03 · Método

Protocolo sin autoengaño

  1. 1
    Reunir la serie con su contexto.

    Fechas, instrumentos, huecos y atípicos documentados. Un pasado sin contexto es una novela.

  2. 2
    Congelar modelo y tolerancia.

    Balance, tope y MAE admisible se firman antes de tocar la ventana de prueba.

  3. 3
    Cortar en entrenamiento / prueba.

    Ej.: calibrar con t=0…6, probar con t=7…12. El corte se elige por uso, no para que «pase».

  4. 4
    Reproducir sin mirar adelante.

    Cada punto de prueba se genera solo con información previa al corte más la dinámica, nunca con el dato real futuro.

  5. 5
    Comparar MAE train vs. test.

    Si el train es excelente y el test se derrumba, hay sobreajuste: el modelo memorizó, no comprendió.

serie + corte→backtest ciego→train vs. test→dictamen

04 · Riesgos

Cómo mentirte con el pasado

Sobreajuste

Memorizar en vez de modelar

Ajustar 5 parámetros para 6 datos da MAE train ≈ 0 y test desastroso. Regla: menos parámetros que puntos, y penalizar complejidad.

Fuga de futuro

Espiar sin querer

Normalizar con toda la serie, elegir el corte mirando el test o calibrar con el pasado completo. Todo eso filtra futuro al «pasado».

No estacionariedad
El pasado cambió de régimen (nueva tarifa, pandemia, capacidad). Reproducir 2019 no valida 2024 si el sistema mutó.
Atípicos
Un corte de luz o un feriado distorsionan el MAE. Se investigan y documentan, no se borran en silencio.
Ventana cómoda
Elegir el único período donde el modelo brilla. Se evita con ventanas múltiples y corte pre-registrado.

05 · Representación en Python

Backtesting en 15 líneas

El patrón es fijo: partir la serie, simular solo con el pasado de entrenamiento y medir por separado.

Python en tu navegador. Mové el corte y observá cómo el veredicto cambia sin tocar el modelo.

real = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
CORTE = 7  # entrenar con 0..6, probar con 7..12 (fijado antes)
TOL = 1.0

def simular(inicio, balance, pasos):
    return [inicio + balance * (t + 1) for t in range(pasos)]

train_real, test_real = real[:CORTE], real[CORTE:]
# El modelo solo "vio" el train para elegir balance=1 e inicio=10
sim_full = simular(10, 1, len(real))
sim_test = sim_full[CORTE:]

def mae(a, b):
    return sum(abs(x - y) for x, y in zip(a, b)) / len(a)

print("train:", train_real)
print("test: ", test_real)
print(f"MAE train: {mae(sim_full[:CORTE], train_real):.2f}")
print(f"MAE test:  {mae(sim_test, test_real):.2f} (tolerancia {TOL})")

Con serie perfecta el test da 0. Agregá ruido o un atípico al final y mirá cómo el test sufre lo que el train no vio.

Walk-forward: nada de espiar

real2 = [10, 11, 12, 20, 14, 15, 16, 17, 18, 19, 20, 21, 22]
errores = []
for corte in [5, 7, 9]:
    s = simular(10, 1, len(real2))
    e = mae(s[corte:], real2[corte:])
    errores.append(e)
    print(f"corte {corte}: MAE test = {e:.2f}")
print(f"MAE promedio walk-forward: {sum(errores)/len(errores):.2f}")

06 · Exploración

Laboratorio: train frente a test

La realidad es 10 + t + ruido con un atípico en t=10. Tu modelo propone 10 + (1+sesgo)·t. Mové el corte (dónde termina el entrenamiento), el sesgo y el ruido. La zona sombreada izquierda es entrenamiento; la derecha, prueba ciega.

EXPERIMENTO 06

Backtesting con corte visible

MAE train vs. MAE test · tolerancia 1,0

Los resultados numéricos aparecen debajo.
MAE train0,00
MAE test0,00
Brecha0,00
Veredicto testVálido

Con sesgo 0 y corte 7, el test reproduce el pasado dentro de la tolerancia.

Puntos verdes: historia medida (con atípico en t=10). Línea lima: backtest del modelo. La línea vertical marca el corte: a la izquierda se calibró, a la derecha se juzga.

Preguntas para explorar

  1. Con sesgo 0 y ruido 0,6: compará MAE train y test con corte 7. ¿Por qué el test es mayor aunque el modelo sea correcto? ¿Qué rol juega el atípico?
  2. Poné sesgo +1. ¿Train y test se degradan parejo o el test castiga más? ¿Eso es sobreajuste o sesgo estructural?
  3. Mové el corte a 10 (dejando el atípico en el train) y luego a 9 (dejándolo en el test). ¿Cómo cambia el veredicto sin tocar el modelo?
Ver respuestas sugeridas
  1. El test incluye el atípico de t=10 y más ruido acumulado: MAE test ≈ 1,0 frente a train ≈ 0,5. No es falla del modelo, es la dureza del juez. Por eso se usan ventanas múltiples.
  2. Se degradan parejo (brecha chica, ambos altos): sesgo estructural, no sobreajuste. El sobreajuste sería train bajo + test alto.
  3. Con corte 10 el atípico contamina el train (parece «aprendido») y el test se ve limpio; con corte 9 el test lo sufre. Misma serie, distinto veredicto: el corte debe fijarse antes, no para favorecer.

07 · Comprensión

Confusiones frecuentes

«Reproduce el pasado, entonces predice el futuro»

Reproducir el pasado es necesario pero no suficiente: si el régimen cambió, el pasado es otro sistema. Falta la validación predictiva (Tema 7).

«Cuanto mejor el train, mejor el modelo»

El train mide memoria, el test mide comprensión. Un train perfecto con test malo es la firma del sobreajuste.

«Puedo elegir el corte después de mirar»

Elegir el corte que hace pasar el test es fuga de futuro por la puerta de atrás. El corte se pre-registra.

«Borro el atípico y listo»

Borrar sin documentar es fraguar el pasado. Se investiga, se documenta y se reporta con y sin atípico.

08 · Práctica guiada

Ejercicios con Python

Ejercicio 1: partir sin espiar

Partí esta serie en train (0…5) y test (6…11) y medí ambos MAE. ¿Hay brecha sospechosa?

real = [10, 11, 13, 13, 14, 15, 16, 17, 19, 19, 20, 21]
sim = [10 + 1*(t+1) for t in range(len(real))]
def mae(a, b):
    return sum(abs(x-y) for x, y in zip(a, b)) / len(a)
print("train:", round(mae(sim[:6], real[:6]), 2))
print("test: ", round(mae(sim[6:], real[6:]), 2))
Ver solución razonada

Train ≈ 0,33, test ≈ 0,50: brecha chica y ambos bajo 1,0. No hay sobreajuste; el modelo generaliza dentro del mismo régimen.

Ejercicio 2: detectar la fuga de futuro

Este código «normaliza» con toda la serie antes de partir. ¿Dónde está la fuga?

serie = [10, 11, 12, 13, 14, 15, 16, 17]
media_total = sum(serie) / len(serie)  # usa el futuro del test
train = [x - media_total for x in serie[:4]]
print("Fuga: la media incluye t=4..7 para centrar t=0..3")
Ver solución

La media usa el test (t=4…7) para transformar el train. Lo correcto es calcular la media solo con el train y aplicarla al test sin recalcular.

Ejercicio 3: walk-forward mínimo

Promediá el MAE test en tres cortes y dictaminá con tolerancia 1,0.

Ver una posible respuesta
real = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
def simular(inicio, balance, pasos):
    return [inicio + balance*(t+1) for t in range(pasos)]
s = simular(10, 1, len(real))
ms = []
for c in [5, 7, 9]:
    m = sum(abs(x-y) for x, y in zip(s[c:], real[c:])) / (len(real)-c)
    ms.append(m)
    print(c, round(m, 2))
print("promedio:", round(sum(ms)/len(ms), 2), "-> Válido" if max(ms) <= 1.0 else "-> revisar")

Los tres cortes dan 0,00: walk-forward perfecto porque la serie es ideal. Con ruido real, el promedio y el peor corte cuentan la estabilidad.

09 · Síntesis

Ideas para recordar

  • Validación histórica = backtest ciego en ventana reservada, sin fuga de futuro.
  • Compará train vs. test: brecha grande = sobreajuste; ambos altos = sesgo estructural.
  • Corte, tolerancia y ventanas se pre-registran; el atípico se investiga, no se esconde.
  • Un pasado no estacionario no valida el futuro: falta la prueba predictiva.
  • Walk-forward convierte una anécdota en evidencia.

En el próximo tema daremos el paso más exigente: la validación predictiva con datos que nadie vio.