---
title: "5.4.3 Walk-forward con reentrenamiento"
description: "Ventana expansiva frente a móvil y frecuencia de reentrenamiento de un ridge en un panel con regímenes: qué gana, con su error típico, y por qué."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento.png"
---

# 5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia

> El walk-forward es la validación que imita la producción: entrenar con lo que se sabía, predecir la semana siguiente y volver a empezar. Quedan dos decisiones que la lección 2.5.2 no tuvo que tomar con un modelo: con cuánta historia se entrena y cada cuánto se reentrena. En un panel con efectos que cambian de signo según el régimen, la intuición dice «ventana corta y reentreno frecuente»; los datos dicen otra cosa.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.4 · Validación para series financieras
- Duración: 65 min
- Requisitos: [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/), [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [5.5.1 · Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística](https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/)

## Al terminar esta lección

- Programar un walk-forward sobre un panel que solo entrena con etiquetas ya terminadas en la fecha de decisión, sacando la regla de las fechas de fin de cada etiqueta.
- Reentrenar un ridge cientos de veces en segundos con estadísticos suficientes (X′X y X′y por semana) y comprobarlo contra scikit-learn.
- Comparar ventana expansiva, móvil y con pesos exponenciales, y frecuencias de reentrenamiento anual, trimestral, mensual y semanal, con el IC, su error típico y diferencias pareadas.
- Leer el resultado por régimen y por la trayectoria de un coeficiente frente al verdadero, y explicar el equilibrio entre sesgo y varianza que decide la ventana.

## Dos decisiones que la validación no toma por ti

La lección 2.5.2 hizo un walk-forward para elegir un parámetro de una estrategia y la 5.4.2, una validación purgada
para comparar modelos. Un walk-forward con un modelo de machine learning añade dos decisiones de diseño:

- **La ventana**: con toda la historia disponible (**expansiva**), con los últimos N años (**móvil**) o con toda pero
  dando más peso a lo reciente (**pesos exponenciales**, con una semivida).
- **La frecuencia**: reentrenar cada año, cada trimestre, cada mes o cada semana.

```mermaid
flowchart TB
  D[Fecha de decisión] --> E[Etiquetas cerradas]
  E --> V[Elegir ventana]
  V --> M[Reentrenar si toca]
  M --> P[Predecir la semana]
  P --> A[Avanzar una semana]
  A --> D
```

La figura compara qué ve el modelo antes de cada año de prueba con una ventana expansiva y con una móvil de tres años,
las dos reentrenadas cada enero:

*Fig. — Ventana expansiva frente a móvil de tres años en un walk-forward con reentreno anual: qué datos ve el modelo antes de cada año de prueba (esquema).* Seis filas. Con ventana expansiva, el entrenamiento empieza siempre en 2012 y crece: hasta 2015, hasta 2019, hasta 2023. Con ventana móvil de tres años, el entrenamiento son siempre los tres años anteriores: 2012-2014, 2016-2018, 2020-2022. En cada fila, la barra destacada es el año de prueba.

La intuición empuja hacia ventanas cortas y reentrenos frecuentes: los mercados cambian. El panel del nivel 5 es un
buen sitio para ponerla a prueba, porque **los efectos cambian de verdad** y se sabe cuánto. Según el README, cada
coeficiente verdadero es `base × régimen × m`, con m un AR(1) semanal de persistencia 0,9 (los efectos se debilitan y
a veces se invierten) y el momentum multiplicado por −2,5 en los rebotes tras una crisis (el crash del momentum).
`panel5_coef_verdad.csv` trae los coeficientes de cada semana y su régimen.

<aside class="callout" data-kind="aviso">

Datos sintéticos con la respuesta conocida. El modelo es el del README: un ridge (α = 10) sobre las 21 features en
rango normal transversal y el rendimiento de la semana siguiente menos su media, evaluado de 2015 a 2025. Los
resultados describen este panel, no los mercados.

</aside>

## El panel en arrays

Para reentrenar cientos de veces conviene tener el panel como arrays de semanas × acciones × features: 730 × 400 × 21.
La transformación es la del README (y la de la lección 5.2.4): el rango normal transversal
$g(x) = \Phi^{-1}\big((\text{rango} - 0{,}5)/n\big)$ con las acciones que tienen dato, y 0 donde falta.

```python
import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata
from sklearn.linear_model import Ridge

feat = pd.read_parquet("panel5_features.parquet")
etiq = pd.read_parquet("panel5_etiquetas.parquet")
feat = feat.sort_values(["fecha", "ticker"])
etiq = etiq.sort_values(["fecha", "ticker"])
FEATS = [c for c in feat.columns if c not in
         ("fecha", "ticker", "sector", "revision_analistas")]
fechas = pd.DatetimeIndex(feat["fecha"].unique())
nW, nA, nF = len(fechas), feat["ticker"].nunique(), len(FEATS)

F = feat[FEATS].to_numpy(dtype=float).reshape(nW, nA, nF)
with np.errstate(invalid="ignore", divide="ignore"):
    rango = rankdata(F, axis=1, nan_policy="omit")
    n = np.isfinite(F).sum(axis=1, keepdims=True)
    G = np.nan_to_num(norm.ppf((rango - 0.5) / n))

R = etiq["ret_sig_semana"].to_numpy().reshape(nW, nA)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, nA)[:, 0]
con_feat = np.isfinite(F[:, :, 0]).all(axis=1)
con_etiq = np.isfinite(R).all(axis=1)
valida = con_feat & con_etiq           # semanas para entrenar
Yc = np.zeros_like(R)
Yc[valida] = R[valida] - R[valida].mean(axis=1, keepdims=True)

coef = pd.read_csv("panel5_coef_verdad.csv", parse_dates=["fecha"])
coef = coef.set_index("fecha").reindex(fechas)
regimen = coef["regimen"].to_numpy()
print(f"{nW} semanas x {nA} acciones x {nF} features; "
      f"semanas válidas {valida.sum()}")
print(pd.Series(regimen[valida]).value_counts().to_string())
```
```output
730 semanas x 400 acciones x 21 features; semanas válidas 729
calma     575
estres     89
rebote     65
```

## La regla del tiempo: entrenar con etiquetas cerradas

En la fecha de decisión w solo se puede entrenar con filas cuya etiqueta ya ha terminado. La regla sale de los datos,
no de una suposición: para cada fecha, la última semana utilizable es la última cuyo `fin_sig_semana` es
**anterior** a ella.

```python
siguiente = (fin[:-1] == fechas[1:].to_numpy()).mean()
print(f"la etiqueta de la semana w termina en la fecha w+1: "
      f"{siguiente:.1%} de las semanas")

def ultima_conocida(w):
    """Índice de la última semana cuya etiqueta terminó antes de
    la fecha w (fin < fecha: un margen de una semana)."""
    return int(np.searchsorted(fin, fechas[w], side="left")) - 1

w = fechas.get_loc(pd.Timestamp("2020-03-20"))
u = ultima_conocida(w)
print(f"decisión el {fechas[w].date()}: entrenar hasta la semana del "
      f"{fechas[u].date()} (su etiqueta terminó el "
      f"{pd.Timestamp(fin[u]).date()})")
```
```output
la etiqueta de la semana w termina en la fecha w+1: 100.0% de las semanas
decisión el 2020-03-20: entrenar hasta la semana del 2020-03-06 (su etiqueta terminó el 2020-03-13)
```

La etiqueta de la semana w termina exactamente en la fecha w+1. Así que, al cierre de la fecha w, la etiqueta de w−1
acaba de cerrarse y la de w está abierta. La regla `fin < fecha` deja fuera también la de w−1 (que termina justo ese
día): es el margen que usa el README, prudente si las órdenes se envían antes del cierre. Incluir la semana w sería
una fuga: el ejercicio 1 mide cuánta.

## Reentrenar mil veces en segundos

Un ridge con intercepto sobre 21 features tiene solución cerrada a partir de cinco cantidades: el número de filas,
las sumas de x e y, y las matrices $X'X$ y $X'y$. Todas se **suman** entre semanas. Si se guardan por semana, el
ridge de cualquier ventana (o con pesos por semana) es una suma y un sistema de 21 × 21:

$$
\hat\beta = \left(X_c' X_c + \alpha I\right)^{-1} X_c' y_c, \qquad
X_c'X_c = X'X - n\,\overline{x}\,\overline{x}', \quad X_c'y_c = X'y - n\,\overline{x}\,\overline{y}
$$

donde el subíndice c indica datos centrados (scikit-learn centra para estimar el intercepto, que no se penaliza).

```python
X1 = np.concatenate([np.ones((nW, nA, 1)), G], axis=2)
peso_valida = valida.astype(float)
S_xx = np.einsum("wni,wnj->wij", X1, X1) * peso_valida[:, None, None]
S_xy = np.einsum("wni,wn->wi", X1, Yc) * peso_valida[:, None]

def ridge(pesos, alfa=10.0):
    """Ridge con intercepto de las semanas con `pesos` > 0."""
    A = np.tensordot(pesos, S_xx, 1)
    b = np.tensordot(pesos, S_xy, 1)
    n_, mx, my = A[0, 0], A[0, 1:] / A[0, 0], b[0] / A[0, 0]
    C = A[1:, 1:] - n_ * np.outer(mx, mx)
    c = b[1:] - n_ * mx * my
    return np.linalg.solve(C + alfa * np.eye(nF), c)

prueba = np.flatnonzero(valida & (fechas.year < 2015))
sk = Ridge(alpha=10.0).fit(G[prueba].reshape(-1, nF),
                           Yc[prueba].reshape(-1))
pesos = np.zeros(nW)
pesos[prueba] = 1.0
print(f"máx. |diferencia| con scikit-learn: "
      f"{np.abs(ridge(pesos) - sk.coef_).max():.1e}")
```
```output
máx. |diferencia| con scikit-learn: 8.1e-18
```

La diferencia está en el último decimal del `float64`. A partir de aquí, cada reentrenamiento es una suma ponderada de
730 matrices pequeñas: el walk-forward semanal entero tarda menos de un segundo.

## El walk-forward y su métrica

La función recorre las semanas de prueba (2015-2025), reentrena cuando toca y predice la puntuación de cada acción. La
métrica es el **IC**: la correlación de rangos, cada semana, entre la puntuación y el rendimiento de la semana
siguiente, y su media. El error típico sale de la serie semanal de ICs (las etiquetas de una semana no se solapan, así
que esa serie es casi independiente).

```python
anyo = fechas.year.to_numpy()
semanas_prueba = np.flatnonzero(con_feat & (anyo >= 2015))

def walk_forward(ventana=None, cada=None, anual=False,
                 semivida=None):
    """Puntuaciones (semanas x acciones) y coeficientes por semana.
    ventana: semanas de historia (None = expansiva); cada: reentrenar
    cada n semanas; anual: cada enero; semivida: pesos exponenciales."""
    pred = np.full((nW, nA), np.nan)
    betas = np.full((nW, nF), np.nan)
    beta = None
    for i, w in enumerate(semanas_prueba):
        toca = (beta is None
                or (anual and anyo[w] != anyo[semanas_prueba[i - 1]])
                or (cada is not None and i % cada == 0))
        if toca:
            u = ultima_conocida(w)
            p = peso_valida.copy()
            p[u + 1:] = 0.0
            if ventana is not None:
                p[:max(0, u + 1 - ventana)] = 0.0
            if semivida is not None:
                p *= 0.5 ** ((u - np.arange(nW)) / semivida)
            beta = ridge(p)
        pred[w] = G[w] @ beta
        betas[w] = beta
    return pred, betas

evaluables = semanas_prueba[con_etiq[semanas_prueba]]

def ic_semanal(pred):
    out = []
    for w in evaluables:
        out.append(np.corrcoef(rankdata(pred[w]),
                               rankdata(R[w]))[0, 1])
    return np.array(out)

def resumen(ic):
    et = ic.std(ddof=1) / np.sqrt(len(ic))
    return {"IC": ic.mean(), "error típico": et, "t": ic.mean() / et}

base, betas_base = walk_forward(anual=True)
ic_base = ic_semanal(base)
print({k: round(float(v), 4) for k, v in resumen(ic_base).items()},
      f"semanas: {len(ic_base)}")
```
```output
{'IC': 0.0185, 'error típico': 0.0038, 't': 4.8789} semanas: 574
```

Expansiva, reentrenada cada enero: IC de 0,0185 con t = 4,9 en 574 semanas, el mismo resultado que publica el README
para este modelo. Es la referencia del resto de la lección.

## Frecuencia de reentrenamiento

Con la ventana expansiva, cuatro frecuencias: cada enero, cada 13 semanas (trimestral), cada 4 (mensual) y cada
semana.

```python
esquemas = {"expansiva, anual": ic_base}
for nombre, kw in [("expansiva, cada 13 semanas", {"cada": 13}),
                   ("expansiva, cada 4 semanas", {"cada": 4}),
                   ("expansiva, semanal", {"cada": 1})]:
    esquemas[nombre] = ic_semanal(walk_forward(**kw)[0])
pd.DataFrame({k: resumen(v) for k, v in esquemas.items()}).T.round(4)
```
```output
                                IC  error típico       t
expansiva, anual            0.0185        0.0038  4.8789
expansiva, cada 13 semanas  0.0176        0.0038  4.6397
expansiva, cada 4 semanas   0.0172        0.0038  4.5324
expansiva, semanal          0.0176        0.0038  4.6463
```

Reentrenar más a menudo no mejora nada: de 0,0185 a 0,0172-0,0176, diferencias de unas milésimas con un error típico
de casi cuatro. Tiene sentido: con ventana expansiva, un año nuevo sobre diez de historia apenas mueve los
coeficientes, y una semana nueva, menos.

## Ventana expansiva, móvil y con pesos

Ahora la ventana. Móviles de cinco, tres, dos y un año reentrenadas cada enero, y dos versiones semanales que deberían
ser las más adaptativas: una ventana móvil de un año y pesos exponenciales con semivida de dos años y de uno.

```python
for nombre, kw in [
        ("móvil 5 años, anual", {"ventana": 260, "anual": True}),
        ("móvil 3 años, anual", {"ventana": 156, "anual": True}),
        ("móvil 2 años, anual", {"ventana": 104, "anual": True}),
        ("móvil 1 año, anual", {"ventana": 52, "anual": True}),
        ("móvil 1 año, semanal", {"ventana": 52, "cada": 1}),
        ("semivida 2 años, semanal", {"semivida": 104, "cada": 1}),
        ("semivida 1 año, semanal", {"semivida": 52, "cada": 1})]:
    p, b = walk_forward(**kw)
    esquemas[nombre] = ic_semanal(p)
    if nombre == "móvil 1 año, semanal":
        betas_movil = b
tabla = pd.DataFrame({k: resumen(v) for k, v in esquemas.items()}).T
print(tabla.round(4).to_string())
```
```output
                                IC  error típico       t
expansiva, anual            0.0185        0.0038  4.8789
expansiva, cada 13 semanas  0.0176        0.0038  4.6397
expansiva, cada 4 semanas   0.0172        0.0038  4.5324
expansiva, semanal          0.0176        0.0038  4.6463
móvil 5 años, anual         0.0143        0.0040  3.6273
móvil 3 años, anual         0.0102        0.0037  2.7680
móvil 2 años, anual         0.0085        0.0038  2.2282
móvil 1 año, anual          0.0084        0.0039  2.1652
móvil 1 año, semanal        0.0039        0.0038  1.0086
semivida 2 años, semanal    0.0120        0.0038  3.1412
semivida 1 año, semanal     0.0081        0.0038  2.1550
```

La figura de la cabecera ordena los once esquemas con su intervalo de ±2 errores típicos. El patrón es nítido:
**cuanto más corta la ventana, peor**. Cinco años pierden un cuarto del IC (0,0143); uno, más de la mitad (0,0084), y
con reentrenamiento semanal, casi todo (0,0039, t = 1,0, indistinguible de cero). Los pesos exponenciales quedan entre
medias, peor cuanto más corta la semivida.

Las diferencias entre esquemas no se leen con los intervalos de cada uno, que se solapan: se leen con la **diferencia
pareada** semana a semana, que elimina el ruido común (las semanas buenas y malas son las mismas para todos los
modelos).

```python
def pareada(a, b):
    dif = esquemas[a] - esquemas[b]
    et = dif.std(ddof=1) / np.sqrt(len(dif))
    return {"diferencia": dif.mean(), "t": dif.mean() / et}

pd.DataFrame({
    "expansiva semanal - móvil 1 año semanal":
        pareada("expansiva, semanal", "móvil 1 año, semanal"),
    "expansiva anual - expansiva semanal":
        pareada("expansiva, anual", "expansiva, semanal"),
    "expansiva anual - móvil 5 años anual":
        pareada("expansiva, anual", "móvil 5 años, anual"),
}).T.round(4)
```
```output
                                         diferencia       t
expansiva semanal - móvil 1 año semanal      0.0138  3.8797
expansiva anual - expansiva semanal          0.0008  1.2332
expansiva anual - móvil 5 años anual         0.0041  2.6339
```

Expansiva frente a móvil de un año, las dos con reentreno semanal: 0,014 de diferencia con t = 3,9. Es la única
conclusión firme de la tabla. Frecuencia anual frente a semanal: t = 1,2, nada. Expansiva frente a móvil de cinco años, las dos
anuales: 0,004 con t = 2,6, en la misma dirección.

## Por régimen: el crash del momentum

La esperanza de la ventana móvil era adaptarse a los cambios. El panel tiene cambios de régimen conocidos: en `estres`
la reversión se refuerza (×1,5) y en `rebote` el momentum se invierte (×−2,5). El IC por régimen dice si alguna ventana
lo aprovecha:

```python
reg_eval = regimen[evaluables]
por_regimen = {}
for nombre in ("expansiva, anual", "móvil 3 años, anual",
               "móvil 1 año, semanal", "semivida 1 año, semanal"):
    ic = esquemas[nombre]
    por_regimen[nombre] = {r: ic[reg_eval == r].mean()
                           for r in ("calma", "estres", "rebote")}
t = pd.DataFrame(por_regimen).T.round(4)
t.columns = [f"{r} ({(reg_eval == r).sum()} sem.)" for r in t.columns]
t
```
```output
                         calma (420 sem.)  estres (89 sem.)  rebote (65 sem.)
expansiva, anual                   0.0230            0.0026            0.0110
móvil 3 años, anual                0.0156           -0.0132            0.0075
móvil 1 año, semanal               0.0073           -0.0094           -0.0003
semivida 1 año, semanal            0.0128           -0.0077           -0.0003
```

Ninguna ventana corta gana en los rebotes, que es donde se esperaba la ventaja de adaptarse: allí la expansiva sigue
siendo la mejor (0,011, la mitad que en calma) y las adaptativas se quedan en cero. Los rebotes del panel duran de
tres a seis meses (marzo-junio de 2020, abril-julio de 2025…): cuando una ventana de un año empieza a reflejar el
cambio de signo del momentum, el rebote ha terminado y el modelo se queda con un coeficiente distorsionado para la
calma siguiente. En las semanas de estrés, además, las ventanas cortas tienen un IC negativo. La adaptación que
funcionaría no es de ventana sino de modelo: una feature de régimen observable a tiempo (y bien fechada, lección 5.2.2)
con la que el modelo aprenda la interacción.

<aside class="callout" data-kind="nota">

El IC por régimen tiene mucho ruido: 65 semanas de rebote dan un error típico de unas 0,01 para cada media. Las cifras
de la tabla se leen como dirección, no como medida. La conclusión firme es la de la diferencia pareada de toda la
muestra.

</aside>

## Sesgo frente a varianza: la trayectoria de un coeficiente

La explicación está en los coeficientes. `panel5_coef_verdad.csv` trae el coeficiente verdadero del momentum cada
semana (`c_mom`, en rendimiento semanal por unidad de g). El ridge estima el mismo objeto con la feature `mom_12_1`
(con las otras 20 al lado, entre ellas `ruido_4`, que hereda una parte del momentum, así que el nivel no tiene por qué
coincidir). Lo que importa es cómo se mueve cada estimación frente a la verdad:

```python
j = FEATS.index("mom_12_1")
verdad_mom = coef["c_mom"].to_numpy()[semanas_prueba] * 1e4
_, betas_exp = walk_forward(cada=1)
filas = {}
for nombre, b in (("expansiva semanal", betas_exp),
                  ("móvil 1 año semanal", betas_movil)):
    est = b[semanas_prueba, j] * 1e4
    filas[nombre] = {
        "media (pb)": est.mean(), "desv. (pb)": est.std(),
        "corr. con la verdad": np.corrcoef(est, verdad_mom)[0, 1],
        "error cuadrático medio (pb)":
            np.sqrt(np.mean((est - verdad_mom) ** 2))}
filas["verdad"] = {"media (pb)": verdad_mom.mean(),
                   "desv. (pb)": verdad_mom.std()}
pd.DataFrame(filas).T.round(2)
```
```output
                     media (pb)  desv. (pb)  corr. con la verdad  error cuadrático medio (pb)
expansiva semanal          4.88        0.93                -0.01                         6.04
móvil 1 año semanal        4.37        6.37                -0.10                         8.99
verdad                     2.40        5.42                  NaN                          NaN
```

*Fig. — Coeficiente verdadero del momentum en el panel del nivel 5 frente a su estimación por un ridge con ventana expansiva y con ventana móvil de un año (reentreno semanal, cada 4 semanas en la figura).* Tres líneas de 2015 a 2025. La verdad (línea continua) oscila entre unos -10 y +9 puntos básicos por semana, con caídas a -25 en el rebote de 2020 y a -9 en los de 2016 y 2019. La estimación con ventana móvil de un año (discontinua) oscila entre -13 y +16 sin seguir a la verdad (correlación -0,10). La expansiva (destacada) es casi plana, entre 4 y 7.

El coeficiente verdadero oscila mucho de una semana a otra (desviación de 5,4 pb sobre una media de 2,4) y cambia de
signo en los rebotes. La estimación de un año también oscila, con una desviación de 6,4 pb, pero no sigue a la
verdad: su correlación con ella es de −0,10. Persigue el ruido de la muestra de los últimos doce meses, no el efecto.
La expansiva es casi una línea (desviación de 0,9 pb, correlación nula): está sesgada, porque no ve los cambios, pero
su error cuadrático frente a la verdad es de 6,0 pb y el de la móvil, de 9,0. Seguir un efecto que cambia cada pocas
semanas con un año de datos ruidosos añade más error del que quita. Es el resultado clásico de Pesaran y
Timmermann (2007): con rupturas, la ventana óptima depende del tamaño de la ruptura frente al ruido, y cuando la señal
es pequeña, usar todos los datos suele ganar.

| Situación | Ventana que suele convenir |
|---|---|
| Señal débil (IC de centésimas), efectos que oscilan sin tendencia | expansiva, o pesos con semivida larga |
| Ruptura grande y permanente (un efecto que desaparece) | móvil o con pesos, tras detectarla (lección 5.10.2) |
| Señal fuerte y cambiante (alta frecuencia, datos abundantes) | móvil corta y reentreno frecuente |
| Cualquiera | la que diga un walk-forward fijado de antemano, con su diferencia pareada |

## Ejercicios

1. Repite el walk-forward expansivo semanal y el móvil de 26 semanas olvidando el margen: entrena hasta la propia
   semana w, cuya etiqueta es la que se predice. ¿Cuánto sube el IC en cada uno y por qué más en la ventana corta?

**Solución.**

```python
def ultima_con_fuga(w):
    return w  # ¡incluye la etiqueta de la propia semana!

original = ultima_conocida
for nombre, kw in (("expansiva semanal", {"cada": 1}),
                   ("móvil 26 semanas", {"ventana": 26, "cada": 1})):
    fila = []
    for fn in (original, ultima_con_fuga):
        ultima_conocida = fn
        fila.append(resumen(ic_semanal(walk_forward(**kw)[0]))["IC"])
    print(f"{nombre:<18} sin fuga {fila[0]:.4f}  con fuga "
          f"{fila[1]:.4f}")
ultima_conocida = original
```
```output
expansiva semanal  sin fuga 0.0176  con fuga 0.0272
móvil 26 semanas   sin fuga 0.0051  con fuga 0.0603
```

Con la ventana expansiva, la fila con fuga es una semana de las más de 500 que hay en el entrenamiento, y aun así el IC
sube. Con 26 semanas, es un 4 % del entrenamiento: el modelo aprende sobre todo de la semana que tiene que predecir y
el IC se multiplica. Las ventanas cortas son más sensibles a cualquier fuga en el borde, y por eso un walk-forward con
ventana móvil que da un resultado excelente merece una revisión especial de la regla del tiempo.

2. Prueba los pesos exponenciales con semividas de 13, 26, 52, 104, 208 y 416 semanas (reentreno semanal). ¿Cuál gana?
   ¿Puedes presentar su IC como resultado fuera de muestra?

**Solución.**

```python
barrido = {h: resumen(ic_semanal(walk_forward(semivida=h,
                                              cada=1)[0]))["IC"]
           for h in (13, 26, 52, 104, 208, 416)}
pd.Series(barrido, name="IC").rename_axis("semivida").round(4)
```
```output
semivida
13     0.0048
26     0.0057
52     0.0081
104    0.0120
208    0.0150
416    0.0165
Name: IC, dtype: float64
```

Gana la semivida más larga, que es la que más se parece a la ventana expansiva: el barrido confirma lo de la lección.
Pero el IC de la mejor no es un resultado fuera de muestra: se eligió mirando los seis walk-forward. Hay que
registrarlo como el mejor de seis intentos y descontar la selección (lección 5.4.4), o elegir la semivida dentro del
walk-forward con datos pasados (un walk-forward anidado). Aquí el barrido dice algo útil, que más historia es mejor;
la cifra concreta de la ganadora no.

3. Los datos `deriva5_*` del nivel (lección 5.10.2) tienen un cambio de signo **permanente** del efecto de una feature
   en julio de 2019. Sin hacer cálculos, ¿esperarías que allí ganase la ventana expansiva o la móvil? ¿Qué necesitarías
   para elegir sin mirar el resultado?

**Solución.**

Allí debería ganar una ventana móvil o con pesos: el cambio es grande (el efecto pasa de +8 a −8 puntos básicos por
unidad) y permanente, así que después de unos meses la historia anterior a 2019 enseña al modelo exactamente lo
contrario de lo que ocurre. La expansiva tardaría años en diluirlo. Para elegir sin mirar el resultado hace falta una
regla fijada de antemano: un detector de deriva (la media móvil del IC del modelo, como en el README del nivel, o
pruebas de cambio estructural de la lección 2.1.7) que dispare el paso a una ventana corta, evaluado todo junto en un
walk-forward. La lección general: la ventana se elige por la naturaleza de los cambios (oscilaciones frente a rupturas)
y por la cantidad de señal, y se valida como un procedimiento completo.

## Autoevaluación

1. En el panel, la etiqueta `ret_sig_semana` de la fecha w termina en la fecha w+1. ¿Con qué semanas puede entrenarse un modelo que decide al cierre de la fecha w?

   - A. Con todas hasta la w incluida
   - B. Solo con las de años anteriores
   - C. Con las semanas cuya etiqueta ya terminó: hasta la w−1 (que termina justo en w) o, dejando un margen como el README, hasta la w−2
   - D. Con las de la w+1 en adelante

   *Respuesta C: Con las semanas cuya etiqueta ya terminó: hasta la w−1 (que termina justo en w) o, dejando un margen como el README, hasta la w−2.* La etiqueta de la propia semana w es lo que se quiere predecir. Incluirla es una fuga: en el ejercicio 1 sube el IC de 0,018 a 0,027 con ventana expansiva y de 0,005 a 0,060 con una ventana de 26 semanas.

2. Ventana expansiva reentrenada cada año: IC 0,0185 (t = 4,9). Ventana móvil de un año reentrenada cada semana: IC 0,0039 (t = 1,0). ¿Por qué pierde la móvil, si los coeficientes verdaderos cambian semana a semana?

   - A. Porque la ventana móvil tiene un error de programación
   - B. Porque con un IC verdadero de unas centésimas, un año de datos estima los coeficientes con tanto ruido que el error de estimación supera con mucho lo que se gana siguiendo los cambios
   - C. Porque reentrenar cada semana provoca fugas
   - D. Porque las ventanas móviles solo sirven en datos diarios

   *Respuesta B: Porque con un IC verdadero de unas centésimas, un año de datos estima los coeficientes con tanto ruido que el error de estimación supera con mucho lo que se gana siguiendo los cambios.* Es el equilibrio sesgo-varianza. La trayectoria del coeficiente de momentum con ventana de un año oscila tanto como la verdadera, pero no se correlaciona con ella (−0,10); la expansiva es casi constante, sesgada pero con menos error (6,0 pb frente a 9,0).

3. Reentrenar cada semana en lugar de cada enero (ventana expansiva) cambia el IC de 0,0185 a 0,0176, con una diferencia pareada de t = 1,2. ¿Qué se concluye?

   - A. Que reentrenar cada semana empeora el modelo
   - B. Que hay que reentrenar cada día
   - C. Que la ventana expansiva no aprende
   - D. Que con ventana expansiva la frecuencia apenas importa: un año más de datos sobre diez mueve poco los coeficientes, y la diferencia no se distingue del ruido

   *Respuesta D: Que con ventana expansiva la frecuencia apenas importa: un año más de datos sobre diez mueve poco los coeficientes, y la diferencia no se distingue del ruido.* Una diferencia de t = 1,2 no es evidencia de nada. Con ventana expansiva, cada reentrenamiento añade una fracción pequeña de datos; reentrenar a menudo cuesta cómputo y no compra precisión.

4. En las 65 semanas de régimen `rebote` (el momentum verdadero se invierte, ×−2,5), ¿qué esquema protege del crash del momentum?

   - A. Ninguno: los rebotes duran unos meses y ningún reentrenamiento detecta a tiempo el cambio de signo; la protección tendría que venir del modelo (el régimen como feature) o del riesgo, no de la ventana
   - B. La ventana móvil de un año, porque se adapta
   - C. La expansiva con reentrenamiento semanal
   - D. Los pesos exponenciales con semivida de 13 semanas

   *Respuesta A: Ninguno: los rebotes duran unos meses y ningún reentrenamiento detecta a tiempo el cambio de signo; la protección tendría que venir del modelo (el régimen como feature) o del riesgo, no de la ventana.* Para cuando un año de datos refleja un rebote, el rebote ha terminado. La adaptación por ventana llega tarde y paga el ruido en todas las demás semanas.

5. Se prueban seis semividas para los pesos exponenciales y se informa del IC de la mejor. ¿Qué hay que hacer con ese número?

   - A. Tratarlo como el mejor de seis intentos: registrarlos todos y descontar la selección (Deflated Sharpe, lección 5.4.4), o elegir la semivida dentro del propio walk-forward con datos pasados
   - B. Nada: es fuera de muestra porque viene de un walk-forward
   - C. Multiplicarlo por seis
   - D. Usar la peor de las seis

   *Respuesta A: Tratarlo como el mejor de seis intentos: registrarlos todos y descontar la selección (Deflated Sharpe, lección 5.4.4), o elegir la semivida dentro del propio walk-forward con datos pasados.* Un walk-forward es fuera de muestra para un procedimiento fijado de antemano. Si el procedimiento se elige mirando el resultado del walk-forward, el resultado ya no lo es.

## Ideas clave

- Regla del walk-forward en un panel: en la fecha de decisión solo se entrena con filas cuya etiqueta ya terminó (`fin_sig_semana` anterior a la fecha). Olvidarla con una ventana móvil de 26 semanas multiplica el IC por doce.
- Con estadísticos suficientes por semana (X′X, X′y), un ridge se reentrena miles de veces en segundos; coincide con scikit-learn.
- Ventana expansiva reentrenada cada enero: IC 0,0185 (t = 4,9), el dato del README. Reentrenar más a menudo no mejora (0,0172-0,0176).
- Las ventanas móviles pierden, y más cuanto más cortas: 0,0143 con cinco años, 0,0084 con uno. Expansiva frente a móvil de un año, ambas semanales: diferencia pareada de 0,014 con t = 3,9.
- Ninguna ventana corta se adapta a los rebotes (crash del momentum): el cambio dura menos de lo que tarda en verse en los datos, y la expansiva sigue siendo la mejor también allí.
- Con poca señal manda la varianza: la estimación de un año sigue mal a un coeficiente que cambia; la expansiva, sesgada y estable, predice mejor.

## Recursos

### Research

- [Introduccion al Quant Research](https://pythonparatrading.com/quant-research/) · Research-0023 · 8 min

### Documentación

- [scikit-learn: Ridge](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.Ridge.html) · scikit-learn.org
- [SciPy: scipy.stats.rankdata (nan_policy y axis)](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rankdata.html) · docs.scipy.org

### Libros

- [Pesaran, M. H. y Timmermann, A. (2007): Selection of estimation window in the presence of breaks. Journal of Econometrics 137(1), 134-161](https://doi.org/10.1016/j.jeconom.2006.03.010) · doi.org
- [López de Prado, M. (2018): Advances in Financial Machine Learning. Wiley (capítulo 11: los peligros del backtest)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Datos

- [Datos del nivel 5: coeficientes verdaderos y régimen de cada semana (CSV)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_coef_verdad.csv) · Descarga · panel5_coef_verdad.csv
- [Datos del nivel 5: features semanales del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML](https://pythonparatrading.com/curso-python-trading/nivel-5/purged-k-fold-embargo-y-cpcv/)

Lección siguiente: [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/)
