---
title: "5.2.4 Normalización sin leakage y rank transversal"
description: "Tipificar cada acción con toda su historia da a una feature sin efecto un IC mayor que el del oráculo. Ventanas sin futuro, rangos y fugas de fechas."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal.png"
---

# 5.2.4 · Normalización sin leakage y rank transversal

> Escalar las features parece un paso técnico sin consecuencias, y en series financieras es una de las fuentes de fuga más eficaces: una media calculada con toda la muestra lleva dentro el futuro de cada acción. Esta lección mide cuánto rinde esa fuga en el panel del nivel, enseña las alternativas sin futuro (ventanas expansivas y móviles, rangos de cada semana) y audita la feature que el proveedor fecha antes de tiempo.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.2 · Ingeniería de características
- Duración: 60 min
- Requisitos: [1.7.4 · Multi-timeframe sin anticipación](https://pythonparatrading.com/curso-python-trading/nivel-1/multi-timeframe-sin-look-ahead/), [2.5.1 · Sesgos de backtest: anticipación, supervivencia, selección y narrativa](https://pythonparatrading.com/curso-python-trading/nivel-2/sesgos-de-backtest/), [5.1.2 · Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn](https://pythonparatrading.com/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn/), [5.2.1 · Features de precio, volumen y microestructura](https://pythonparatrading.com/curso-python-trading/nivel-5/features-de-precio-volumen-y-microestructura/)

## Al terminar esta lección

- Medir la fuga de normalizar cada acción con estadísticos de toda la muestra (z-score y min-max) y compararla con el techo del oráculo.
- Programar normalizaciones expansivas y móviles con `groupby` y comprobarlas con una prueba automática: el valor de una fecha no puede cambiar al recortar el futuro.
- Distinguir la normalización temporal (respecto a la historia de la acción) de la transversal (respecto a las demás acciones de la semana) y elegir entre z-score, winsorización, rango y rango normal.
- Auditar una feature con una fuga de fechas: IC sesión a sesión de la semana siguiente y prueba del retraso.

## Escalar no es un paso neutro

Casi todos los modelos de las lecciones siguientes necesitan las features en una escala comparable: una regresión
penalizada castiga igual a todos los coeficientes, una red neuronal aprende mal con entradas de órdenes de magnitud
distintos y un k-vecinos mide distancias. Por eso, entre calcular una feature (lecciones 5.2.1 a 5.2.3) y entrenar,
casi siempre hay una normalización. En un tutorial de ML genérico es una línea: `StandardScaler().fit_transform(X)`.
En un panel financiero hay que decidir **respecto a qué** se normaliza cada valor y **con qué datos** se calculan los
estadísticos:

| Normalización | Respecto a qué | Datos de los estadísticos | ¿Usa el futuro? |
|---|---|---|---|
| z-score o min-max con toda la muestra de la acción | Su propia historia | Todas las fechas, también las posteriores | **Sí** |
| z-score expansiva | Su propia historia | Desde el inicio hasta la fecha | No |
| z-score móvil | Su historia reciente | Las últimas $n$ fechas hasta la fecha | No |
| z-score, rango o rango normal de cada semana | Las demás acciones esa semana | Solo esa fecha | No |
| Escalador global del modelo | Todo el conjunto de entrenamiento | El entrenamiento (o, mal hecho, todo) | Según dónde se ajuste |

Esta lección mide cada fila de la tabla en el panel del nivel 5: 400 acciones ficticias, 730 semanas de 2012 a 2025,
22 features semanales y una verdad publicada (`public/curso/datos/nivel-5/README.md`). Los datos son sintéticos y los
efectos están plantados: sirven para saber cuánto rinde una fuga, no para estimar nada del mercado real. La última
sección cambia de problema: una feature que ninguna normalización puede arreglar, porque el proveedor la fecha antes
de que se conozca.

## El panel y la vara de medir

La medida de todo el módulo es el **IC**: la correlación de rangos, semana a semana, entre una feature y el
rendimiento de la semana siguiente (`ret_sig_semana`), promediada en las semanas; su estadístico $t$ es la media
dividida por la desviación y multiplicada por la raíz del número de semanas (lección 5.1.3).

```python
import numpy as np
import pandas as pd

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
v = pd.read_parquet("panel5_verdad.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
             on=["fecha", "ticker"])
      .merge(v[["fecha", "ticker", "alfa_verdadero"]],
             on=["fecha", "ticker"])
      .dropna(subset=["ret_sig_semana"])
      .sort_values(["ticker", "fecha"])
      .reset_index(drop=True))

def ic(df, col, y="ret_sig_semana"):
    x = df[["fecha", col, y]].dropna()
    r = x.groupby("fecha")[[col, y]].rank()
    s = r[col].groupby(x["fecha"]).corr(r[y])
    return pd.Series({"ic": s.mean(),
                      "t": s.mean() / s.std() * np.sqrt(s.count()),
                      "semanas": s.count()})

print(d.shape)
pd.DataFrame({c: ic(d, c) for c in
              ["alfa_verdadero", "valor_bp", "log_capitalizacion"]}
             ).T.round(4)
```
```output
(291600, 27)
                        ic       t  semanas
alfa_verdadero      0.0292  8.4714    729.0
valor_bp            0.0095  3.9281    729.0
log_capitalizacion -0.0005 -0.2103    729.0
```

El techo es el IC del **oráculo**, `alfa_verdadero`: la parte del rendimiento de la semana siguiente que el modelo
generador planta, conocida solo porque los datos son sintéticos. Ningún modelo puede pasar de ahí fuera de muestra.
`valor_bp` (el logaritmo del cociente entre patrimonio y capitalización) tiene un efecto plantado y un IC pequeño y
positivo; `log_capitalizacion` no tiene efecto y su IC es cero. Esas dos son las cobayas de la sección siguiente.

## La fuga de la muestra completa

La forma más común de tipificar una feature de una serie temporal es restarle su media y dividir por su desviación,
calculadas con toda la serie:

$$
z_{i,t} = \frac{x_{i,t} - \overline{x}_i}{s_i}, \qquad \overline{x}_i = \frac{1}{T}\sum_{u=1}^{T} x_{i,u}.
$$

La suma llega hasta $T$, el final de la muestra. Si $x$ depende del precio, como la capitalización o el cociente
patrimonio/capitalización, $\overline{x}_i$ depende de todos los precios futuros de la acción. Una acción que va a subir
mucho tiene una capitalización media futura alta, así que hoy su capitalización queda **por debajo** de su media:
$z_{i,t}$ negativa anuncia subidas. La min-max ($\frac{x - \min}{\max - \min}$ con el mínimo y el máximo de toda la
serie, el escalado por defecto de muchos tutoriales de redes neuronales) tiene el mismo defecto: dice en qué punto del
recorrido futuro está el precio.

```python
def z_completa(df, col):
    s = df.groupby("ticker")[col]
    return (df[col] - s.transform("mean")) / s.transform("std")

def minmax_completa(df, col):
    s = df.groupby("ticker")[col]
    mn, mx = s.transform("min"), s.transform("max")
    return (df[col] - mn) / (mx - mn)

def z_expansiva(df, col, minimo=52):
    s = df.groupby("ticker")[col]
    m = s.transform(lambda x: x.expanding(minimo).mean())
    sd = s.transform(lambda x: x.expanding(minimo).std())
    return (df[col] - m) / sd

def z_movil(df, col, ventana=52):
    s = df.groupby("ticker")[col]
    m = s.transform(lambda x: x.rolling(ventana).mean())
    sd = s.transform(lambda x: x.rolling(ventana).std())
    return (df[col] - m) / sd

metodos = {"bruta": lambda df, c: df[c],
           "z con toda la muestra": z_completa,
           "min-max con toda la muestra": minmax_completa,
           "z expansiva (52 s mín.)": z_expansiva,
           "z móvil (52 s)": z_movil}
filas = {}
for col in ["log_capitalizacion", "valor_bp"]:
    t = d[["fecha", "ticker", "ret_sig_semana"]].copy()
    for nombre, fn in metodos.items():
        t[nombre] = fn(d, col)
    t = t.dropna()   # mismas semanas para todas: tras la 52.ª
    for nombre in metodos:
        filas[(col, nombre)] = ic(t, nombre)[["ic", "t"]]
pd.DataFrame(filas).T.round(4)
```
```output
                                                    ic        t
log_capitalizacion bruta                        0.0000   0.0080
                   z con toda la muestra       -0.0490 -15.2101
                   min-max con toda la muestra -0.0451 -14.3760
                   z expansiva (52 s mín.)      0.0007   0.2304
                   z móvil (52 s)              -0.0009  -0.3030
valor_bp           bruta                        0.0095   3.7176
                   z con toda la muestra        0.0501  15.7421
                   min-max con toda la muestra  0.0438  13.5968
                   z expansiva (52 s mín.)      0.0017   0.5449
                   z móvil (52 s)               0.0020   0.6477
```

La figura de la cabecera dibuja la tabla. Con la z-score de toda la muestra, la capitalización, que no tiene ningún
efecto, pasa a tener un IC de −0,049 con $t = -15$: casi el doble del techo del oráculo, en valor absoluto. El value
pasa de 0,010 a 0,050, cinco veces su efecto real. La min-max da lo mismo. En un backtest, cualquiera de las dos
parecería el descubrimiento del año, y las dos son información de los precios de los años siguientes.

Con estadísticos expansivos o móviles, que en cada fecha solo usan el pasado, la capitalización vuelve a cero
(0,001 y −0,001). Es la respuesta conocida: no tiene efecto.

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

La regla práctica de este nivel: **un IC por encima del oráculo es una fuga**. Con datos reales no hay oráculo, pero sí
órdenes de magnitud: en acciones, un IC semanal sostenido de 0,05 con una feature de precios públicos no es una
anomalía de mercado, es un error de código hasta que se demuestre lo contrario.

</aside>

## Lo que cambia al normalizar contra el pasado propio

La misma tabla tiene una segunda lectura, más sutil. Con la z-score expansiva, `valor_bp` cae de 0,0095 a 0,0017 y
deja de ser significativo, y no por una fuga. El efecto plantado es **transversal**: las acciones baratas respecto a
las demás rinden un poco más la semana siguiente. Tipificar cada acción contra su propia historia responde a otra
pregunta, si la acción está barata respecto a sí misma, y esa pregunta no tiene efecto en el panel.

```mermaid
flowchart TB
  X[x de la acción i] --> P{¿Comparar con?}
  P -- su pasado --> T[Temporal]
  P -- otras en t --> C[Transversal]
  T --> T1[¿Rara para ella?]
  C --> C1[¿Alta hoy?]
```

No hay una normalización correcta en abstracto: la correcta es la que conserva la información que el efecto usa. Una
feature de volatilidad para un modelo de serie temporal de un solo activo (lección 5.8.5) se compara con el pasado del
activo; una feature de value o de momentum para elegir acciones, con las demás acciones de la misma fecha. Y las dos se
pueden combinar: la z-score temporal de una acción, ordenada después en rangos entre acciones.

## Una prueba automática contra el futuro

Revisar el código a ojo no basta: en un pipeline de varios cientos de líneas, una media con toda la muestra se esconde
bien. La propiedad que define una transformación sin futuro se puede comprobar de forma mecánica: el valor de la fecha
$t$ calculado con los datos hasta $t$ es el mismo que calculado con todos los datos.

```python
def usa_el_futuro(fn, df, col, corte="2018-12-28"):
    antes = df["fecha"] <= corte
    con_todo = fn(df, col)[antes]
    recortado = fn(df[antes], col)
    return not np.allclose(con_todo, recortado, equal_nan=True)

{nombre: usa_el_futuro(fn, d, "valor_bp")
 for nombre, fn in metodos.items()}
```
```output
{'bruta': False, 'z con toda la muestra': True, 'min-max con toda la muestra': True, 'z expansiva (52 s mín.)': False, 'z móvil (52 s)': False}
```

Es una prueba unitaria barata que conviene poner a cada función de features y de normalización de un proyecto: se
ejecuta en segundos, no depende de ningún modelo y detecta la fuga antes de que llegue a un backtest. Detecta también
las variantes menos obvias, como `rolling(..., center=True)` (el primer ejercicio), un `bfill` que rellena huecos con el
valor siguiente o un `resample` con la etiqueta en el extremo equivocado (lección 1.7.4).

## El escalador global del modelo

El error que más se cita en la documentación de scikit-learn es ajustar el escalador con todos los datos antes de
separar entrenamiento y prueba. En este panel se puede medir lo que cuesta. El experimento es el modelo de referencia
del README: un ridge (α = 10) sobre las 21 features sin la fuga, reentrenado cada enero de 2015 a 2025 con todas las
semanas anteriores y con la etiqueta menos su media de la semana. Una versión ajusta el `StandardScaler` dentro de
cada ventana de entrenamiento; la otra, una sola vez con todos los años:

```python
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler

d["y"] = (d["ret_sig_semana"]
          - d.groupby("fecha")["ret_sig_semana"].transform("mean"))
FEATS = [c for c in f.columns
         if c not in ("fecha", "ticker", "sector",
                      "revision_analistas")]
anio = d["fecha"].dt.year.to_numpy()

def walk_forward(X, escalar_con_todo=False):
    X = X.copy()
    if escalar_con_todo:
        X[:] = StandardScaler().fit_transform(X.fillna(X.median()))
    pred = pd.Series(np.nan, index=X.index)
    for a in range(2015, 2026):
        tr, te = anio < a, anio == a
        med = X[tr].median()
        Xtr, Xte = X[tr].fillna(med), X[te].fillna(med)
        if not escalar_con_todo:
            esc = StandardScaler().fit(Xtr)
            Xtr, Xte = esc.transform(Xtr), esc.transform(Xte)
        m = Ridge(alpha=10).fit(Xtr, d.loc[tr, "y"])
        pred.loc[te] = m.predict(Xte)
    return ic(d.assign(pred=pred), "pred", "y")[["ic", "t"]]

pd.DataFrame({
    "escalador en cada entrenamiento": walk_forward(d[FEATS]),
    "escalador con todos los años": walk_forward(d[FEATS], True),
}).T.round(4)
```
```output
                                     ic       t
escalador en cada entrenamiento  0.0159  4.5775
escalador con todos los años     0.0159  4.5724
```

El IC es el mismo hasta el cuarto decimal (0,0159); solo cambia el segundo decimal de $t$. No es que la práctica sea correcta: es que esta fuga **no se puede
aprovechar**. Un escalador global aplica a todas las filas la misma transformación afín, y un modelo lineal la deshace
con sus coeficientes; los árboles, que solo miran el orden de cada feature, ni la notan. La fuga del escalador sí pesa
cuando sus estadísticos varían mucho entre el entrenamiento y la prueba y el modelo no es invariante a ellos (redes
con activaciones saturadas, k-vecinos, umbrales fijos sobre la feature escalada), y por eso la regla sigue siendo la
de la lección 5.1.2: todo lo que se ajusta, dentro del `Pipeline` y dentro de cada ventana.

La diferencia con la sección anterior es la que importa recordar. La media global es **la misma** para todas las filas;
la media de toda la historia de una acción es **distinta para cada acción** y depende de su futuro. La segunda reordena
las acciones dentro de cada semana según lo que les va a pasar, y eso un modelo sí lo explota.

## Transformaciones transversales

Para elegir acciones, la normalización natural es la de cada semana entre acciones. Sea $x_{i,t}$ la feature de la
acción $i$ en la semana $t$ y $n_t$ el número de acciones con dato:

- **z-score transversal**: $(x_{i,t} - \overline{x}_t)/s_t$, con la media y la desviación de esa semana.
- **z-score winsorizada**: la anterior recortada a $\pm 3$, para que un dato extremo no domine.
- **Rango**: $\text{rango}_{i,t}/n_t - 0{,}5$, uniforme entre $-0{,}5$ y $0{,}5$ sea cual sea la distribución.
- **Rango normal** (*rank-gauss*): $\Phi^{-1}\big((\text{rango}_{i,t} - 0{,}5)/n_t\big)$, el rango llevado a una normal
  estándar. Es la forma $g(\cdot)$ con la que el generador del panel planta los efectos.

Todas se calculan con los datos de una sola fecha, así que ninguna puede usar el futuro. Lo que quitan es otra cosa:
los cambios de nivel del mercado entero. La volatilidad de 63 sesiones lo enseña bien:

```python
from scipy import stats

sem = d.groupby("fecha")

def cs_z(col):
    media = sem[col].transform("mean")
    return (d[col] - media) / sem[col].transform("std")

def cs_rango(col):
    return sem[col].rank(pct=True) - 0.5

def cs_gauss(col):
    n = sem[col].transform("count")
    return pd.Series(stats.norm.ppf((sem[col].rank() - 0.5) / n),
                     index=d.index)

anual = pd.DataFrame({
    "mediana bruta": d.groupby(d["fecha"].dt.year)["vol_63d"].median(),
    "mediana del rango": cs_rango("vol_63d")
        .groupby(d["fecha"].dt.year).median(),
})
anual.loc[[2017, 2019, 2020, 2022, 2024]].round(3)
```
```output
       mediana bruta  mediana del rango
fecha
2017           0.246              0.001
2019           0.278              0.001
2020           0.354              0.001
2022           0.356              0.001
2024           0.274              0.001
```

*Fig. — Volatilidad de 63 sesiones en bruto de las 400 acciones del panel: mediana y percentiles 10 y 90 de cada mes, 2012-2025. En rangos transversales, la mediana sería la misma todas las semanas.* Tres líneas mensuales de 2012 a 2025. La mediana, destacada, se mueve entre 0,23 (julio de 2019) y 0,50 (mayo de 2020), con subidas en 2015-2016, 2018, 2020, 2022 y 2025. Los percentiles 10 y 90 acompañan los mismos cambios de nivel: el percentil 90 llega a 0,65 en 2020.

En bruto, la volatilidad mediana del panel cambia con los regímenes del modelo de mercado: de 0,246 en 2017 a 0,354
en 2020 (la crisis de febrero y marzo) y 0,356 en 2022. Un modelo entrenado con la volatilidad en bruto en años tranquilos ve
en 2020 valores que casi no había visto, y su predicción para todas las acciones se desplaza a la vez. Con el rango,
la mediana es siempre la misma (0,001, y no exactamente 0 porque cada semana hay un número par de acciones) y solo
queda el orden entre acciones, que es lo que una cartera larga-corta usa. Si el
nivel del mercado importa (la volatilidad agregada es una variable de régimen útil), se añade aparte, como una feature
común a todas las acciones de la semana, no mezclada con la transversal.

La segunda cosa que quitan los rangos son los extremos. `ret_1s` tiene colas gruesas y `ruido_5` está construido con
colas muy gruesas; la curtosis en exceso lo mide:

```python
pd.DataFrame({c: {"bruta": stats.kurtosis(d[c], nan_policy="omit"),
                  "z transversal": stats.kurtosis(cs_z(c),
                                                   nan_policy="omit"),
                  "rango normal": stats.kurtosis(cs_gauss(c),
                                                 nan_policy="omit")}
              for c in ["ret_1s", "ruido_5", "vol_63d"]}).round(2)
```
```output
               ret_1s  ruido_5  vol_63d
bruta            2.93     3.62     4.87
z transversal    3.49     3.42     6.13
rango normal    -0.06    -0.06    -0.06
```

La z-score transversal quita el nivel de cada semana, pero no las colas: unas pocas acciones con movimientos enormes
siguen pesando en la regresión mucho más que el resto. El rango normal deja todas las features con la misma
distribución, una normal estándar truncada en sus extremos.

## ¿Qué transformación predice mejor?

Con el mismo walk-forward de antes, cambiando solo cómo entran las features:

```python
transformaciones = {
    "bruta": d[FEATS],
    "z transversal": pd.DataFrame({c: cs_z(c) for c in FEATS}),
    "z winsorizada ±3": pd.DataFrame({c: cs_z(c).clip(-3, 3)
                                      for c in FEATS}),
    "rango": pd.DataFrame({c: cs_rango(c) for c in FEATS}),
    "rango normal": pd.DataFrame({c: cs_gauss(c) for c in FEATS}),
}
pd.DataFrame({k: walk_forward(X) for k, X in transformaciones.items()}
             ).T.round(4)
```
```output
                      ic       t
bruta             0.0159  4.5775
z transversal     0.0164  4.5211
z winsorizada ±3  0.0177  4.8441
rango             0.0177  4.5728
rango normal      0.0184  4.8721
```

El orden es el esperado, de la feature en bruto (0,016) al rango normal (0,018), y el rango normal es la forma
verdadera de los efectos del panel. Pero conviene leer la tabla con su error: con $t$ entre 4,5 y 4,9, el error típico
de cada IC es de unas 0,0035, mayor que cualquiera de las diferencias. En este panel, la transformación transversal
no es lo que separa un modelo bueno de uno malo; lo que hace es que el modelo sea **estable**: el mismo coeficiente
significa lo mismo en 2017 y en 2020, ningún dato extremo mueve la regresión y una feature nueva entra en la misma
escala que las demás. Con modelos que se reentrenan poco o con más features de colas gruesas, la diferencia crece.

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

En scikit-learn, `QuantileTransformer(output_distribution="normal")` hace un rango normal, pero con la distribución de
**todo** el conjunto de entrenamiento, no la de cada fecha: es una normalización global, no transversal. Para un panel,
la transversal se calcula con `groupby("fecha")` antes del modelo, como aquí, y no tiene estadísticos que ajustar.

</aside>

## Una feature demasiado buena

La tabla de features del README tiene una fila que no se parece a las demás:

```python
pd.DataFrame({c: ic(d, c) for c in
              ["revision_analistas", "alfa_verdadero", "ret_1s"]}
             ).T.round(4)
```
```output
                        ic        t  semanas
revision_analistas  0.1743  92.1366    729.0
alfa_verdadero      0.0292   8.4714    729.0
ret_1s             -0.0159  -4.2881    729.0
```

Un IC de 0,174, seis veces el del oráculo, con $t = 92$. Ninguna normalización lo cambia: en rangos o en bruto, sigue
ahí. La explicación está en el README del nivel: el proveedor (ficticio) publica las revisiones de los analistas y
**las fecha al inicio de la semana que cubren**, pero el dato incorpora lo que pasó en las dos primeras sesiones de esa
semana. En la fila del viernes, el modelo recibe un número que en la realidad no se conoce hasta el martes siguiente.

Es el mismo problema de fechas de la lección 5.2.2 (fundamentales fechados al cierre del trimestre en lugar de a la
publicación), con una diferencia: aquí no hay una segunda columna con la fecha buena. Hay que deducirla de los datos.

```mermaid
flowchart TB
  V[Viernes: fila] --> L[Lunes y martes]
  L --> M[Dato publicado]
  M --> E[Viernes siguiente]
  V -. fecha del dato .-> M
```

## Auditar cuándo se conoce un dato

La auditoría tiene dos pasos. El primero: descomponer la etiqueta. Si la feature anticipa de verdad la semana
siguiente, su IC se reparte entre las cinco sesiones; si lleva dentro sesiones concretas, se concentra en ellas.

```python
p = pd.read_parquet("panel5_precios.parquet",
                    columns=["fecha", "ticker", "cierre"])
P = p.pivot(index="fecha", columns="ticker", values="cierre")
R = P.pct_change()

def matriz(col):
    return d.pivot(index="fecha", columns="ticker", values=col)

F = matriz("revision_analistas")
pos = P.index.get_indexer(F.index)

def ic_matriz(X, Y):
    X, Y = X.where(Y.notna()), Y.where(X.notna())
    c = X.rank(axis=1).corrwith(Y.rank(axis=1), axis=1)
    return pd.Series({"ic": c.mean(),
                      "t": c.mean() / c.std() * np.sqrt(c.count())})

def a_matriz(valores):
    return pd.DataFrame(valores, index=F.index, columns=P.columns)

tramos = {"semana que acaba en la fecha":
          a_matriz(P.to_numpy()[pos] / P.to_numpy()[pos - 5] - 1)}
for k in range(1, 6):
    idx = np.minimum(pos + k, len(R) - 1)
    vals = R.to_numpy()[idx]
    vals[pos + k >= len(R)] = np.nan
    tramos[f"sesión {k} siguiente"] = a_matriz(vals)
tramos["etiqueta (semana siguiente)"] = matriz("ret_sig_semana")
pd.DataFrame({k: ic_matriz(F, Y) for k, Y in tramos.items()}
             ).T.round({"ic": 3, "t": 1})
```
```output
                                 ic     t
semana que acaba en la fecha -0.004  -2.2
sesión 1 siguiente            0.189  97.1
sesión 2 siguiente            0.178  93.1
sesión 3 siguiente           -0.003  -1.6
sesión 4 siguiente            0.002   0.8
sesión 5 siguiente           -0.002  -1.0
etiqueta (semana siguiente)   0.174  92.1
```

*Fig. — IC de revision_analistas con el rendimiento de cada tramo: está entero en las dos primeras sesiones de la semana siguiente y desaparece al retrasar la feature una semana.* Barras horizontales. Semana pasada −0,004; sesión 1 de la semana siguiente 0,189 y sesión 2 0,178, destacadas; sesiones 3, 4 y 5 −0,003, 0,002 y −0,002; semana siguiente completa 0,174; la feature retrasada una semana, −0,002.

El IC está entero en las sesiones 1 y 2 (0,189 y 0,178) y es cero en las sesiones 3, 4 y 5 y en la semana pasada. Un
efecto económico no sabe en qué sesión empieza la semana: una revisión de analistas que de verdad anticipara el
rendimiento lo haría repartido, y decayendo. Que la correlación empiece y acabe en sesiones exactas es la huella de una
fecha mal puesta.

El segundo paso: **retrasar** la feature hasta el momento en que con seguridad se conoce y ver qué queda. Un efecto
real sobrevive al retraso; una fuga, no.

```python
L = matriz("ret_sig_semana")
V = matriz("valor_bp")
pd.DataFrame({
    f"retraso {k} sem.": {
        "revision_analistas": ic_matriz(F.shift(k), L)["ic"],
        "valor_bp": ic_matriz(V.shift(k), L)["ic"]}
    for k in [0, 1, 2, 4]}).round(4)
```
```output
                    retraso 0 sem.  retraso 1 sem.  retraso 2 sem.  retraso 4 sem.
revision_analistas          0.1743         -0.0016          0.0002         -0.0012
valor_bp                    0.0095          0.0089          0.0087          0.0088
```

Retrasada una semana, `revision_analistas` tiene un IC de −0,002: nada. El value retrasado una, dos o cuatro semanas
mantiene su IC de 0,009: es un efecto lento y de verdad. Sin ella, el ridge de la sección anterior se queda en
0,016-0,018; con ella tal cual, el README mide un IC de 0,175 para el mismo ridge y una cartera larga-corta con un
Sharpe neto de 17,7, la cifra que nadie debería creerse.

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

Ninguna validación detecta esta fuga. El purged k-fold y el walk-forward la heredan, porque cada fila lleva la fuga
dentro (lección 5.4.1). Las únicas defensas son las de esta sección: sospechar de lo demasiado bueno, descomponer la
etiqueta en el tiempo, retrasar y, con datos reales, pedir al proveedor la fecha de primera publicación (*first
published* o *as-of*) de cada valor y no la fecha a la que se refiere.

</aside>

## Errores típicos

- **Escalar la serie entera antes de construir ventanas.** El patrón de muchos tutoriales de LSTM: `MinMaxScaler` sobre
  todo el precio y después las secuencias. Cada ventana sabe dónde está el máximo futuro (lección 5.6.3).
- **`rolling(center=True)` o `shift(-k)` en una feature.** Las dos miran adelante. La prueba de recortar el futuro las
  caza.
- **Rellenar huecos hacia atrás.** `bfill` copia el siguiente valor conocido en fechas en que aún no se conocía.
- **Normalizar con el universo de hoy.** Calcular los rangos con las acciones que existen al final de la muestra
  introduce el sesgo de supervivencia en la feature (lección 1.4.4); el rango de cada fecha se calcula con las acciones
  que cotizaban esa fecha.
- **Confiar en la fecha del proveedor.** La fecha de una fila es la del periodo al que se refiere el dato, no
  necesariamente la de su publicación. Sin fecha de publicación, se retrasa con margen y se audita.

## Ejercicios

1. Una media móvil centrada (`rolling(52, center=True)`) suaviza mejor que una que solo mira atrás. Escribe la
   z-score móvil centrada, pásala por `usa_el_futuro` y mide su IC con la capitalización.

**Solución.**

```python
def z_centrada(df, col, ventana=52):
    s = df.groupby("ticker")[col]
    m = s.transform(lambda x: x.rolling(ventana, center=True).mean())
    sd = s.transform(lambda x: x.rolling(ventana, center=True).std())
    return (df[col] - m) / sd

t = d[["fecha", "ret_sig_semana"]].assign(
    zc=z_centrada(d, "log_capitalizacion")).dropna()
print("usa el futuro:", usa_el_futuro(z_centrada, d,
                                      "log_capitalizacion"))
ic(t, "zc")[["ic", "t"]].round(4)
```
```output
usa el futuro: True
ic    -0.2148
t    -68.4267
dtype: float64
```

La ventana centrada usa 26 semanas futuras en cada fecha, y la prueba lo detecta. Su IC con la capitalización, que no
tiene efecto, es −0,215 con $t = -68$, cuatro veces el de la z-score de toda la muestra: la media de las 26 semanas
siguientes depende mucho más del rendimiento de la semana próxima que la de 14 años, en la que esa semana pesa poco.

2. El value del panel se reparte entre sectores. Calcula el rango normal de `valor_bp` dentro de cada sector y semana
   (neutralizado por sector) y compara su IC con el del rango normal de todo el universo.

**Solución.**

```python
gs = d.groupby(["fecha", "sector"])["valor_bp"]
n_s = gs.transform("count")
d["vbp_sector"] = stats.norm.ppf((gs.rank() - 0.5) / n_s)
d["vbp_gauss"] = cs_gauss("valor_bp")
pd.DataFrame({c: ic(d, c) for c in ["vbp_gauss", "vbp_sector"]}
             ).T[["ic", "t"]].round(4)
```
```output
                ic       t
vbp_gauss   0.0095  3.9281
vbp_sector  0.0077  4.5619
```

El value neutralizado da un IC algo menor (0,0077 frente a 0,0095) pero más estable semana a semana ($t$ = 4,6 frente
a 3,9). En el panel el efecto está plantado sobre el rango de todo el universo, así que la parte del value que
compara sectores entre sí también rinde, y al neutralizar se pierde; a cambio, la feature deja de cargar el
rendimiento común de cada sector, que es ruido para este efecto. Con datos reales la neutralización suele importar
más, porque hay sectores enteros estructuralmente «baratos» (bancos, energía) cuyo rendimiento depende de su propio
ciclo: neutralizar separa la apuesta de value de la apuesta sectorial.

3. Un proveedor de sentimiento entrega cada mañana a las 7:00 (hora de Nueva York) un fichero con una fila por acción y
   la fecha del día anterior, calculada con las noticias hasta las 23:59. Tus features son del cierre de cada viernes.
   ¿Qué fecha de conocimiento asignas a cada fila y qué comprobarías antes de usarla?

**Solución.**

La fila con fecha del jueves se conoce el viernes a las 7:00, antes del cierre del viernes: se puede usar en la
feature del viernes. La fila del viernes, en cambio, se conoce el sábado: entra en la feature del viernes siguiente, no
en la de ese viernes. La regla general es guardar la fecha de llegada del fichero (`fecha_conocimiento`) y unir con
`merge_asof` sobre ella, nunca sobre la fecha del dato (lección 5.2.2). Antes de usarla: descomponer el IC por sesión
como en esta lección (si se concentra en el lunes siguiente, algo llega tarde), retrasarla un día y una semana y ver
cuánto IC sobrevive, y comprobar en el histórico del proveedor si las filas antiguas se reescribieron después
(un histórico «limpiado» a posteriori es otra fuga).

## Autoevaluación

1. Se tipifica `log_capitalizacion` de cada acción con la media y la desviación de sus 14 años de datos, y su IC con el rendimiento de la semana siguiente pasa de 0,000 a −0,049. ¿Qué ha ocurrido?

   - A. La normalización ha sacado a la luz un efecto tamaño que estaba escondido
   - B. Es ruido: con 400 acciones un IC de −0,049 no es significativo
   - C. La media de toda la muestra incluye los precios futuros de la acción: una acción que va a subir tiene hoy una capitalización por debajo de su media futura, y la feature lo revela
   - D. La tipificación cambia el signo de las correlaciones de rangos

   *Respuesta C: La media de toda la muestra incluye los precios futuros de la acción: una acción que va a subir tiene hoy una capitalización por debajo de su media futura, y la feature lo revela.* El IC del oráculo, la mejor previsión posible, es 0,029. Un IC de −0,049 con t = −15 en una feature sin efecto solo puede venir de información posterior a la fecha. Con media y desviación expansivas, el IC vuelve a 0,001.

2. ¿Qué prueba detecta de forma automática que una normalización usa datos futuros?

   - A. Que el IC sea mayor que el de la feature sin normalizar
   - B. Recortar los datos en una fecha y comprobar que los valores anteriores al corte no cambian
   - C. Que la feature normalizada tenga media 0 y desviación 1
   - D. Una validación cruzada con k-fold barajado

   *Respuesta B: Recortar los datos en una fecha y comprobar que los valores anteriores al corte no cambian.* Una transformación sin futuro da en la fecha t el mismo valor con los datos hasta t que con todos. La z-score con toda la muestra falla esa prueba; la expansiva y la móvil la pasan.

3. En el walk-forward con ridge, ajustar el `StandardScaler` con todos los años antes de partir da exactamente el mismo IC fuera de muestra que ajustarlo solo con el entrenamiento. ¿Por qué?

   - A. Porque el escalador global no usa datos de prueba
   - B. Porque ridge ignora la escala de las features
   - C. Porque las features ya estaban en rangos
   - D. Porque una transformación afín común a todas las filas no cambia el orden de las predicciones de un modelo lineal: la fuga existe pero no se puede aprovechar. La que hace daño es la que varía con el futuro de cada fila

   *Respuesta D: Porque una transformación afín común a todas las filas no cambia el orden de las predicciones de un modelo lineal: la fuga existe pero no se puede aprovechar. La que hace daño es la que varía con el futuro de cada fila.* El modelo lineal compensa cualquier cambio de escala y de origen común con sus coeficientes. Una media por acción con toda su historia, en cambio, mete en cada fila información distinta y posterior a su fecha.

4. `valor_bp` tiene un IC de 0,0095 en bruto y de 0,0017 tipificado con la historia expansiva de cada acción. ¿Por qué baja si esa normalización no tiene fuga?

   - A. Porque el efecto value es transversal (acciones baratas frente a caras) y tipificar cada acción contra su propio pasado lo quita: la feature pasa a medir si la acción está barata respecto a sí misma
   - B. Porque la ventana expansiva tiene un error
   - C. Porque se pierden las primeras 52 semanas
   - D. Porque el value no tiene efecto en el panel

   *Respuesta A: Porque el efecto value es transversal (acciones baratas frente a caras) y tipificar cada acción contra su propio pasado lo quita: la feature pasa a medir si la acción está barata respecto a sí misma.* La normalización temporal cambia lo que significa la feature. Para un efecto de corte transversal, la normalización adecuada es la de cada semana entre acciones.

5. `revision_analistas` tiene un IC de 0,189 con la primera sesión de la semana siguiente, 0,178 con la segunda y casi 0 con la tercera, la cuarta y la quinta. Retrasada una semana, su IC es −0,002. ¿Qué se concluye?

   - A. Que es una señal muy rápida que hay que operar al abrir
   - B. Que la normalización transversal la estropea
   - C. Que el dato se conoce después de su fecha: incorpora los dos primeros días de la semana que se quiere predecir, y retrasado a cuando de verdad se conoce no tiene efecto
   - D. Que el purged k-fold la habría eliminado

   *Respuesta C: Que el dato se conoce después de su fecha: incorpora los dos primeros días de la semana que se quiere predecir, y retrasado a cuando de verdad se conoce no tiene efecto.* Un efecto real decae despacio al retrasarlo (el value retrasado una semana mantiene su IC). Uno que salta de 0,17 a 0 con una semana de retraso y se concentra en sesiones concretas futuras es una fuga de fechas, que ninguna validación detecta.

## Ideas clave

- Una media, una desviación, un mínimo o un máximo calculados con toda la muestra de una acción llevan dentro su futuro: en el panel, la z-score con toda la historia da a una feature sin efecto un IC de −0,049, por encima del techo del oráculo (0,029).
- Las alternativas sin futuro son las ventanas expansivas y móviles (`expanding`, `rolling` dentro de `groupby`). Cualquier transformación se prueba recortando el futuro: los valores anteriores al corte no pueden cambiar.
- Normalizar contra la historia de la propia acción cambia el significado de la feature: el value deja de ser «barata frente a las demás» y su efecto desaparece.
- Las transformaciones transversales de cada semana (z-score, winsorización, rango, rango normal) quitan los cambios de nivel del mercado y los extremos; en el panel el rango normal da el mejor IC fuera de muestra (0,018 frente a 0,016 en bruto), una mejora dentro del error.
- Un escalador global ajustado antes de partir es una fuga formal que un modelo lineal no puede aprovechar; la que hace daño varía con el futuro de cada fila.
- Ninguna normalización arregla una feature fechada antes de conocerse. Se detecta midiendo su IC sesión a sesión y retrasándola: `revision_analistas` pasa de 0,174 a −0,002.

## Recursos

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [scikit-learn: errores comunes y buenas prácticas (fugas en el preprocesado)](https://scikit-learn.org/stable/common_pitfalls.html) · scikit-learn.org
- [scikit-learn: preprocesado de datos (StandardScaler, QuantileTransformer)](https://scikit-learn.org/stable/modules/preprocessing.html) · scikit-learn.org
- [pandas: DataFrame.rolling](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.rolling.html) · pandas.pydata.org
- [pandas: DataFrame.expanding](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.expanding.html) · pandas.pydata.org

### Datos

- [Datos del nivel 5: features del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet
- [Datos del nivel 5: precios diarios del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_precios.parquet) · Descarga · panel5_precios.parquet

Lección anterior: [5.2.3 · Diferenciación fraccional para features](https://pythonparatrading.com/curso-python-trading/nivel-5/diferenciacion-fraccional-para-features/)

Lección siguiente: [5.2.5 · Importancia de features: MDI, MDA, SFI y SHAP](https://pythonparatrading.com/curso-python-trading/nivel-5/importancia-de-features-mdi-mda-sfi-y-shap/)
