Lección 5.2.4 · 60 min
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.
- Módulo
- 5.2 · Ingeniería de características
- Duración
- 60 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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 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 es la media
dividida por la desviación y multiplicada por la raíz del número de semanas (lección 5.1.3).
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)(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.0El 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:
La suma llega hasta , el final de la muestra. Si depende del precio, como la capitalización o el cociente patrimonio/capitalización, 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: negativa anuncia subidas. La min-max ( 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.
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) 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.6477La 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 : 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.
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.
Fuente 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 calculado con los datos hasta es el mismo que calculado con todos los datos.
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()}{'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:
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) ic t
escalador en cada entrenamiento 0.0159 4.5775
escalador con todos los años 0.0159 4.5724El IC es el mismo hasta el cuarto decimal (0,0159); solo cambia el segundo decimal de . 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 la feature de la acción en la semana y el número de acciones con dato:
- z-score transversal: , con la media y la desviación de esa semana.
- z-score winsorizada: la anterior recortada a , para que un dato extremo no domine.
- Rango: , uniforme entre y sea cual sea la distribución.
- Rango normal (rank-gauss): , el rango llevado a una normal estándar. Es la forma 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:
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) 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.001En 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:
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) 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.06La 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:
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) 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.8721El 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 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.
Una feature demasiado buena
La tabla de features del README tiene una fila que no se parece a las demás:
pd.DataFrame({c: ic(d, c) for c in
["revision_analistas", "alfa_verdadero", "ret_1s"]}
).T.round(4) 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.0Un IC de 0,174, seis veces el del oráculo, con . 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.
Fuente 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.
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}) 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.1El 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.
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) 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.0088Retrasada 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.
Errores típicos
- Escalar la serie entera antes de construir ventanas. El patrón de muchos tutoriales de LSTM:
MinMaxScalersobre 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)oshift(-k)en una feature. Las dos miran adelante. La prueba de recortar el futuro las caza.- Rellenar huecos hacia atrás.
bfillcopia 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
- 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 porusa_el_futuroy mide su IC con la capitalización.
Solución
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)usa el futuro: True
ic -0.2148
t -68.4267
dtype: float64La 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 , 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.
- El value del panel se reparte entre sectores. Calcula el rango normal de
valor_bpdentro de cada sector y semana (neutralizado por sector) y compara su IC con el del rango normal de todo el universo.
Solución
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) ic t
vbp_gauss 0.0095 3.9281
vbp_sector 0.0077 4.5619El value neutralizado da un IC algo menor (0,0077 frente a 0,0095) pero más estable semana a semana ( = 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.
- 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).
Respuesta plegada en cada pregunta
Autoevaluación
Se tipifica
log_capitalizacionde 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?- ALa normalización ha sacado a la luz un efecto tamaño que estaba escondido
- BEs ruido: con 400 acciones un IC de −0,049 no es significativo
- CLa 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
- DLa tipificación cambia el signo de las correlaciones de rangos
Ver respuesta
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.
¿Qué prueba detecta de forma automática que una normalización usa datos futuros?
- AQue el IC sea mayor que el de la feature sin normalizar
- BRecortar los datos en una fecha y comprobar que los valores anteriores al corte no cambian
- CQue la feature normalizada tenga media 0 y desviación 1
- DUna validación cruzada con k-fold barajado
Ver respuesta
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.
En el walk-forward con ridge, ajustar el
StandardScalercon todos los años antes de partir da exactamente el mismo IC fuera de muestra que ajustarlo solo con el entrenamiento. ¿Por qué?- APorque el escalador global no usa datos de prueba
- BPorque ridge ignora la escala de las features
- CPorque las features ya estaban en rangos
- DPorque 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
Ver respuesta
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.
valor_bptiene 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?- APorque 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
- BPorque la ventana expansiva tiene un error
- CPorque se pierden las primeras 52 semanas
- DPorque el value no tiene efecto en el panel
Ver respuesta
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.
revision_analistastiene 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?- AQue es una señal muy rápida que hay que operar al abrir
- BQue la normalización transversal la estropea
- CQue 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
- DQue el purged k-fold la habría eliminado
Ver respuesta
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,rollingdentro degroupby). 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_analistaspasa de 0,174 a −0,002.