Lección 5.2.3 · 60 min
Diferenciación fraccional para features
La diferenciación fraccional promete lo mejor de dos mundos: una serie estacionaria que conserva la memoria del nivel del precio. Esta lección la usa como feature de un modelo y mide qué hay detrás de la promesa: qué parte de la memoria son rendimientos pasados y qué parte es nivel, por qué pasar el ADF en el entrenamiento no evita que el test quede fuera de rango, cómo el nivel arrastra sus defectos y cuánto aporta a un LightGBM frente a un conjunto de rendimientos bien elegido.
- 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
Lo que ya sabes y lo que falta
La lección 2.1.2 explicó la diferenciación fraccional: el operador con real, sus pesos , la versión de ventana fija (FFD) que corta los pesos por debajo de un umbral, y el barrido de que busca la más pequeña que pasa el ADF conservando la correlación con el nivel. Allí el objetivo era tener un regresor estacionario. Aquí la pregunta es otra, la que importa para un modelo de machine learning: ¿una FFD es mejor feature que los rendimientos?
López de Prado (2018)1Nota 1 · M. López de Prado, Advances in Financial Machine Learning, Wiley, 2018, capítulo 5 («Fractionally Differentiated Features»); el operador fraccional viene de J. R. M. Hosking, «Fractional differencing», Biometrika 68(1), 1981, pp. 165-176, doi:10.1093/biomet/68.1.165. la propone precisamente para eso: las features estacionarias por diferenciación entera pierden toda la memoria, y la FFD conserva parte. Para medir esa promesa hace falta saber qué es exactamente la memoria que conserva. Se reescriben las funciones de la lección 2.1.2 y se añade una cuenta:
import numpy as np
import pandas as pd
def pesos(d, umbral=1e-4, kmax=100_000):
"""w_0 = 1, w_k = -w_(k-1)·(d - k + 1)/k hasta |w_k| < umbral."""
w = [1.0]
for k in range(1, kmax):
wk = -w[-1] * (d - k + 1) / k
if abs(wk) < umbral:
break
w.append(wk)
return np.array(w)
def ffd(x, d, umbral=1e-4):
"""Diferencia fraccional de ventana fija (sin datos futuros)."""
w = pesos(d, umbral)
v = np.convolve(x.to_numpy(), w, mode="valid")
return pd.Series(v, index=x.index[len(w) - 1:])
for d in [0.1, 0.3, 0.5, 0.7]:
w = pesos(d)
c = np.cumsum(w) # sumas parciales: peso de cada rendimiento
print(f"d={d:.1f}: {len(w):3d} pesos; rendimientos de hace "
f"1, 5, 21 y 63 sesiones {np.round(c[[1, 5, 21, 63]], 3)}; "
f"resto de nivel {c[-1]:.3f}")d=0.1: 503 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.9 0.79 0.689 0.618]; resto de nivel 0.502
d=0.3: 388 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.7 0.466 0.308 0.222]; resto de nivel 0.129
d=0.5: 200 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.5 0.246 0.122 0.071]; resto de nivel 0.040
d=0.7: 97 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.3 0.106 0.039 0.018]; resto de nivel 0.014Agrupando los términos de la suma, una FFD con pesos se escribe como rendimientos pasados más un resto de nivel:
El peso del rendimiento de hace sesiones es la suma parcial , que empieza en 1 (el de hoy) y decae como una potencia, : con , el rendimiento de hace 21 sesiones aún pesa 0,31 y el de hace 63, 0,22; con , 0,04 y 0,02. La última suma parcial, que es la suma de todos los pesos, es el peso que queda al nivel de la serie al principio de la ventana: 0,50 con , 0,13 con 0,3, 0,04 con 0,5. Con la ventana infinita sería cero para cualquier ; la ventana fija lo deja ahí.
Así que la «memoria» de una FFD tiene dos partes de naturaleza distinta. Una es una media de rendimientos con pesos que decaen despacio, algo que se puede aproximar con rendimientos a varios horizontes (lección 5.2.1). La otra es el nivel de hace sesiones, y esa sí es nueva: ningún conjunto de rendimientos la contiene. La figura dibuja la primera parte; las secciones siguientes miden las dos.
Estacionaria en el ADF, fuera de rango en la prueba
El criterio de la lección 2.1.2 elige la más pequeña que pasa el ADF. El ADF contrasta si la serie vuelve a su
media dentro de la muestra. Un modelo necesita otra cosa: que los valores que verá en producción se parezcan a los
que vio al entrenar. Con el índice de vol5 (log del precio desde 2008, construido acumulando rendimientos hacia
delante: point-in-time), entrenamiento 2008-2017 y prueba 2018-2025, se mide para cada el ADF del entrenamiento,
la fracción de valores de prueba fuera del rango de entrenamiento y la memoria (correlación con el log del precio en
la prueba):
from statsmodels.tsa.stattools import adfuller
v5 = pd.read_parquet("vol5_diario.parquet")
r_ind = v5[v5["activo"] == "indice"].set_index("fecha")["rendimiento"]
lp = np.log1p(r_ind).cumsum() # log del precio, 0 el primer día
filas = []
for d in np.round(np.arange(0, 1.01, 0.05), 2):
x = ffd(lp, d) if d > 0 else lp
ent, prueba = x.loc[:"2017-12-31"], x.loc["2018-01-01":]
adf = adfuller(ent, maxlag=1, regression="c", autolag=None,
result_object=True)
fuera = ((prueba < ent.min()) | (prueba > ent.max())).mean()
memoria = np.corrcoef(prueba, lp.loc[prueba.index])[0, 1]
filas.append({"d": d, "adf": adf.statistic, "fuera": fuera,
"memoria": memoria})
t = pd.DataFrame(filas).set_index("d")
critico = adf.critical_values["5%"]
d_adf = t.index[t["adf"] < critico].min()
d_rango = t.index[t["fuera"] < 0.05].min()
print(f"valor crítico al 5 %: {critico:.2f}; d mínima por el "
f"ADF: {d_adf}; por el rango (< 5 % fuera): {d_rango}")
t.loc[[0.0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0]].round(3)valor crítico al 5 %: -2.86; d mínima por el ADF: 0.1; por el rango (< 5 % fuera): 0.5
adf fuera memoria
d
0.00 -2.630 0.879 1.000
0.05 -2.506 0.832 0.999
0.10 -3.328 0.769 0.996
0.20 -5.110 0.656 0.975
0.30 -7.499 0.546 0.928
0.40 -10.968 0.321 0.847
0.50 -15.390 0.033 0.725
0.70 -25.172 0.000 0.409
1.00 -36.912 0.000 0.018La figura de la cabecera dibuja la tabla. Con la FFD ya pasa el ADF (−3,33 frente a −2,86) y conserva una correlación de 0,996 con el precio, pero el 77 % de sus valores de 2018-2025 está fuera del rango de 2008-2017: el índice subió y la FFD, con medio peso en el nivel, subió con él. Para que menos del 5 % de la prueba quede fuera hace falta , y entonces la memoria baja a 0,725. Entre las dos hay un intervalo de en el que la serie es «estacionaria» según el contraste y no lo es para un modelo.
El ADF no está mal: con diez años de datos y un resto de nivel que cambia despacio, la serie revierte lo bastante dentro de la muestra para rechazar la raíz unitaria. El problema es que el nivel de la prueba no está en la muestra. Lo que hace un modelo con eso depende del modelo; un árbol lo hace de forma muy visible:
import lightgbm as lgb
y21 = lp.shift(-21) - lp # log-rendimiento de las 21 siguientes
def arbol(d):
x = (ffd(lp, d) if d > 0 else lp).rename("x")
df = pd.concat([x, y21.rename("y")], axis=1, sort=True).dropna()
ent = df.loc[:"2017-12-31"]
prueba = df.loc["2018-01-01":"2025-10-31"]
m = lgb.LGBMRegressor(n_estimators=100, learning_rate=0.05,
num_leaves=8, min_child_samples=100,
random_state=0, n_jobs=4, deterministic=True,
force_row_wise=True, verbose=-1)
m.fit(ent[["x"]], ent["y"])
pred = m.predict(prueba[["x"]])
fuera = prueba["x"] > ent["x"].max()
return fuera.mean(), np.unique(pred[fuera.to_numpy()]).size
for d in [0.0, 0.1, 0.5]:
f, n = arbol(d)
print(f"d={d:.2f}: {f:.0%} de la prueba por encima del máximo "
f"de entrenamiento; predicciones distintas ahí: {n}")d=0.00: 88% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1
d=0.10: 76% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1
d=0.50: 3% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1Un árbol parte el eje de la feature en umbrales aprendidos con el entrenamiento. Todo lo que cae por encima del último
umbral recibe la predicción de la última hoja: en el log del precio, el 88 % de la prueba recibe una sola predicción;
con , el 76 %. El modelo no extrapola, se queda en el extremo. Con solo el 3 % cae ahí. Una
regresión lineal haría lo contrario, extrapolar sin límite, que no es mejor. El objetivo de esta celda (el rendimiento de
las 21 sesiones siguientes) es impredecible en vol5 por construcción (README de los datos): la celda no mide si el
modelo acierta, sino cómo trata lo que no ha visto.
La memoria del nivel hereda sus defectos
El resto de nivel es la parte de la FFD que no se puede construir con rendimientos. Si el nivel lleva información, la FFD la tiene; si el nivel lleva un defecto, también. El panel de acciones sirve para verlo en dos versiones del mismo precio: la publicada, que acumula rendimientos hacia delante desde un precio inicial y es point-in-time, y la misma serie normalizada por su último precio (), que es lo que hace quien divide cada serie por su último valor para compararlas, y lo que hace, en esencia, un ajuste hacia atrás. Para cada una se calcula la FFD completa y sin el resto de nivel, y su IC con el rendimiento de la semana siguiente:
from scipy.signal import lfilter
px = pd.read_parquet("panel5_precios.parquet")
LP = np.log(px.pivot(index="fecha", columns="ticker", values="cierre"))
fe = pd.read_parquet("panel5_features.parquet")
et = pd.read_parquet("panel5_etiquetas.parquet")
fechas = pd.DatetimeIndex(fe["fecha"].unique())
Y = et.pivot(index="fecha", columns="ticker",
values="ret_sig_semana").loc[fechas]
def ic(X):
"""IC de rangos semanal con la semana siguiente: media y t."""
X = X.reindex(index=fechas, columns=Y.columns)
v = X.rank(axis=1).corrwith(Y.rank(axis=1), axis=1).dropna()
t = v.mean() / v.std() * np.sqrt(len(v))
return round(float(v.mean()), 4), round(float(t), 1)
def ffd_panel(L, d):
"""FFD por columnas: completa y sin el resto de nivel."""
w = pesos(d)
completa = pd.DataFrame(lfilter(w, [1.0], L.to_numpy(), axis=0),
index=L.index, columns=L.columns)
completa.iloc[:len(w) - 1] = np.nan
return completa, completa - w.sum() * L.shift(len(w) - 1)
LN = LP - LP.iloc[-1] # normalizada: 0 el último día de la muestra
for nombre, L in [("publicado", LP), ("normalizado", LN)]:
print(f"{nombre}: log del precio, IC, t = {ic(L)}")
for d in [0.3, 0.5, 0.7, 1.0]:
completa, rendim = ffd_panel(L, d)
print(f" d={d:.1f}: completa {ic(completa)}, "
f"solo rendimientos {ic(rendim)}")publicado: log del precio, IC, t = (-0.0002, -0.1)
d=0.3: completa (-0.0008, -0.3), solo rendimientos (-0.0014, -0.4)
d=0.5: completa (-0.0039, -1.4), solo rendimientos (-0.0058, -1.6)
d=0.7: completa (-0.0061, -1.9), solo rendimientos (-0.0086, -2.3)
d=1.0: completa (-0.004, -1.0), solo rendimientos (-0.004, -1.0)
normalizado: log del precio, IC, t = (-0.0661, -15.5)
d=0.3: completa (-0.0553, -15.0), solo rendimientos (-0.0014, -0.4)
d=0.5: completa (-0.0455, -12.7), solo rendimientos (-0.0058, -1.6)
d=0.7: completa (-0.032, -8.7), solo rendimientos (-0.0086, -2.3)
d=1.0: completa (-0.004, -1.0), solo rendimientos (-0.004, -1.0)Con el precio publicado, el nivel no dice nada de la semana siguiente (IC −0,0002) y la FFD completa da casi lo mismo que su parte de rendimientos: IC pequeños y negativos (−0,009 como mucho, con ), la reversión semanal plantada vista a través de una media de rendimientos. El resto de nivel no añade información, pero tampoco hace daño.
Con el precio normalizado al final, el log del precio pasa a un IC de −0,066 con = −15,5: más del doble del techo del oráculo del README (0,029), que conoce el rendimiento esperado verdadero. Fuera de muestra, ninguna feature puede superar al oráculo, así que esto es una fuga. La FFD completa la hereda (−0,055 con , −0,046 con 0,5, −0,032 con 0,7) aunque su resto de nivel pese 0,13, 0,04 o 0,01, porque el nivel varía mucho más entre acciones que una suma de rendimientos. La parte de rendimientos es idéntica en las dos versiones: normalizar resta una constante por acción, que en los rendimientos se cancela.
¿De dónde sale la fuga? Restar el log del último precio hace que el nivel de hoy sea, exactamente, menos el rendimiento de hoy hasta el final de la muestra:
hasta_el_final = LP.iloc[-1] - LP # log-rendimiento de t a 2025-12-31
for f in ["2013-06-28", "2016-06-30", "2019-06-28", "2022-06-30"]:
pub = np.corrcoef(LP.loc[f], hasta_el_final.loc[f])[0, 1]
nor = np.corrcoef(LN.loc[f], hasta_el_final.loc[f])[0, 1]
print(f"{f}: corr. con el rendimiento hasta el final: "
f"publicado {pub:+.2f}, normalizado {nor:+.2f}")2013-06-28: corr. con el rendimiento hasta el final: publicado +0.03, normalizado -1.00
2016-06-30: corr. con el rendimiento hasta el final: publicado -0.02, normalizado -1.00
2019-06-28: corr. con el rendimiento hasta el final: publicado +0.01, normalizado -1.00
2022-06-30: corr. con el rendimiento hasta el final: publicado -0.02, normalizado -1.00Con el precio publicado, la correlación transversal entre el nivel y el rendimiento hasta 2025 es nula (entre −0,02 y +0,03); con el normalizado es −1 por construcción. La semana siguiente forma parte de ese rendimiento futuro, y de ahí sale el IC imposible. No es un ejemplo de laboratorio: la primera versión de estos datos sintéticos reescalaba cada serie para que su último precio cayera en un rango verosímil, tenía exactamente este defecto y se detectó así, con una feature de nivel que superaba al oráculo.
En datos reales el mismo problema aparece de forma más suave. Un precio ajustado hacia atrás por dividendos y splits
(lección 1.4.3) multiplica el pasado por factores que dependen de dividendos y splits posteriores; una serie continua
de futuros con ajuste proporcional (lección 1.4.6), por los saltos de rolls posteriores; una serie normalizada a 100
en su último día, por todo el rendimiento futuro. Los rendimientos de todas ellas son point-in-time; el nivel, no.
Cualquier feature que conserve memoria del nivel (una FFD, una distancia a un máximo histórico en dólares, el propio
precio como columna de un árbol) hay que construirla sobre un nivel que se conociera en cada fecha: precios sin
ajustar, o rendimientos acumulados hacia delante desde un día fijo, como el precio publicado del panel o el lp del
índice de vol5. Y el IC de cualquier feature nueva se compara con el techo del oráculo, o, en datos reales, con lo
que es verosímil.
¿Aporta en un modelo? FNX con LightGBM
Queda la pregunta principal: con un nivel point-in-time y un modelo no lineal, ¿la FFD aporta algo sobre los rendimientos? Los datos de FNX (lección 5.2.1) tienen una estructura que favorece a una feature con memoria: en el régimen de reversión, el precio vuelve hacia su media exponencial de 30 minutos, es decir, hacia una media ponderada de su propio pasado, que es lo que mide la parte de rendimientos de una FFD. El nivel dentro del año, en cambio, no dice nada del minuto siguiente.
El montaje: features calculadas dentro de cada sesión (la ventana no cruza la noche: umbral , ventanas de 26 a 66 minutos), objetivo el log-rendimiento de los 10 minutos siguientes, entrenamiento en la primera mitad de las sesiones y prueba en la segunda, con una observación cada 10 minutos para que los objetivos no se solapen. Se comparan cuatro conjuntos: cinco rendimientos (1, 5, 10, 30 y 60 minutos), tres FFD completas ( = 0,3, 0,5 y 0,7), las mismas sin el resto de nivel y rendimientos más FFD completas; cada uno, solo y con el desequilibrio de órdenes de 30 minutos de la lección 5.2.1.
b = pd.read_parquet("hf5_barras_1min.parquet")
n_ses = b["ts"].dt.date.nunique()
P = np.log(b["cierre"].to_numpy()).reshape(n_ses, 390) # sesión × min
VC = b["volumen_comprador"].to_numpy().reshape(n_ses, 390)
VT = b["volumen"].to_numpy().reshape(n_ses, 390)
def por_sesion(w):
"""Filtro con los pesos w dentro de cada sesión (sin cruzarlas)."""
X = lfilter(w, [1.0], P, axis=1)
X[:, :len(w) - 1] = np.nan
return X
def retardo(A, k):
out = np.full_like(A, np.nan, dtype=float)
out[:, k:] = A[:, :-k]
return out
H = 10
feats = {}
for k in [1, 5, 10, 30, 60]:
feats[f"r{k}"] = P - retardo(P, k)
for d in [0.3, 0.5, 0.7]:
w = pesos(d, umbral=1e-3) # ventanas de 26 a 66 minutos
completa = por_sesion(w)
feats[f"ffd{d}"] = completa
feats[f"ffdr{d}"] = completa - w.sum() * retardo(P, len(w) - 1)
cv, ct = np.cumsum(VC, axis=1), np.cumsum(VT, axis=1)
feats["di30"] = 2 * (cv - retardo(cv, 30)) / (ct - retardo(ct, 30)) - 1
y = np.full_like(P, np.nan)
y[:, :-H] = P[:, H:] - P[:, :-H] # log-rendimiento de 10 minutos
X = pd.DataFrame({k: v.reshape(-1) for k, v in feats.items()})
y = y.reshape(-1)
sesion = np.repeat(np.arange(n_ses), 390)
minuto = np.tile(np.arange(390), n_ses)
ok = X.notna().all(axis=1).to_numpy() & np.isfinite(y)
ent = ok & (sesion < n_ses // 2)
prueba = ok & (sesion >= n_ses // 2) & (minuto % H == 0)
et_ = 1 / np.sqrt(prueba.sum())
print(f"{ent.sum()} filas de entrenamiento; {prueba.sum()} de prueba "
f"sin solapamiento; error típico ≈ {et_:.3f}")38745 filas de entrenamiento; 3844 de prueba sin solapamiento; error típico ≈ 0.016Con 3.844 observaciones de prueba, el error típico de una correlación es de unos 0,016.
conjuntos = {
"rendimientos": ["r1", "r5", "r10", "r30", "r60"],
"ffd completa": ["ffd0.3", "ffd0.5", "ffd0.7"],
"ffd sin nivel": ["ffdr0.3", "ffdr0.5", "ffdr0.7"],
"rend. + ffd completa": ["r1", "r5", "r10", "r30", "r60",
"ffd0.3", "ffd0.5", "ffd0.7"],
}
res = {}
for nombre, cols in conjuntos.items():
for extra in [[], ["di30"]]:
m = lgb.LGBMRegressor(
n_estimators=200, learning_rate=0.03, num_leaves=15,
min_child_samples=200, subsample=0.5, subsample_freq=1,
random_state=0, n_jobs=4, deterministic=True,
force_row_wise=True, verbose=-1)
m.fit(X.loc[ent, cols + extra], y[ent])
pred = m.predict(X.loc[prueba, cols + extra])
etiqueta = "con di30" if extra else "solo"
res[(nombre, etiqueta)] = np.corrcoef(pred, y[prueba])[0, 1]
pd.Series(res).unstack().round(3) con di30 solo
ffd completa 0.039 -0.012
ffd sin nivel 0.102 0.069
rend. + ffd completa 0.074 0.032
rendimientos 0.094 0.060Tres conclusiones, por orden de importancia:
- El resto de nivel perjudica. Las FFD completas dan −0,012, peor que nada, y añadidas a los rendimientos los empeoran de 0,060 a 0,032: el árbol parte por el nivel de FNX, que no tiene relación con el objetivo, y aprende ruido del entrenamiento.
- La parte útil de la FFD es una media de rendimientos. Sin el resto, las tres FFD dan 0,069, lo mismo que los cinco rendimientos (0,060) dentro del error típico. La FFD es una forma compacta de dar al modelo una media de rendimientos con pesos que decaen como una potencia; un conjunto de rendimientos a varios horizontes contiene casi la misma información.
- Lo que mejora el modelo es información nueva. El desequilibrio de órdenes sube todos los conjuntos unas 3-5 centésimas (0,094 con rendimientos, 0,102 con FFD sin nivel). Transformar los mismos precios de otra forma mueve el resultado mucho menos que añadir un dato que el precio no tiene.
Fuera de estos datos, el resultado dependerá del activo, del horizonte y del modelo: la FFD conviene probarla como una feature más, con la misma validación que cualquier otra, y no como sustituto automático de los rendimientos.
Cuándo no hace falta: una serie que ya es estacionaria
El otro extremo: una serie que ya es estacionaria y tiene memoria larga. La varianza realizada es el caso típico; su
logaritmo es persistente pero vuelve a su media, y el modelo HAR de Corsi2Nota 2 · F. Corsi, «A Simple Approximate Long-Memory Model of Realized Volatility», Journal of Financial Econometrics
7(2), 2009, pp. 174-196, doi:10.1093/jjfinec/nbp001. aproxima esa memoria larga con tres medias
(1, 5 y 22 días). En vol5, el HAR es el modelo verdadero. Diferenciar fraccionalmente el log de la varianza
realizada para predecir la del día siguiente:
from sklearn.linear_model import LinearRegression
def r2_fuera(X, y):
df = pd.concat([X, y.rename("y")], axis=1, sort=True).dropna()
a = df.loc["2009-01-01":"2017-12-31"]
b_ = df.loc["2018-01-01":]
m = LinearRegression().fit(a.drop(columns="y"), a["y"])
e = b_["y"] - m.predict(b_.drop(columns="y"))
return 1 - (e ** 2).sum() / ((b_["y"] - a["y"].mean()) ** 2).sum()
filas = {}
for activo, g in v5.groupby("activo"):
lrv = np.log(g.set_index("fecha")["rv_5min"])
obj = lrv.shift(-1)
fila = {f"d={d}": r2_fuera((ffd(lrv, d, 1e-3) if d else lrv)
.to_frame("x"), obj)
for d in [0, 0.2, 0.5, 1.0]}
har = pd.concat({"d1": lrv, "d5": lrv.rolling(5).mean(),
"d22": lrv.rolling(22).mean()}, axis=1)
fila["HAR"] = r2_fuera(har, obj)
filas[activo] = fila
pd.DataFrame(filas).T.round(3) d=0 d=0.2 d=0.5 d=1.0 HAR
accion 0.275 0.117 0.025 0.003 0.393
bono 0.443 0.217 0.052 0.009 0.543
cripto 0.339 0.125 0.022 0.003 0.488
divisa 0.375 0.186 0.044 0.004 0.466
indice 0.505 0.293 0.082 0.014 0.568
oro 0.480 0.235 0.054 0.007 0.580Cada paso de hacia 1 destruye capacidad de predicción: en el índice, el fuera de muestra pasa de 0,505 con el propio log de la varianza () a 0,293 con , 0,082 con 0,5 y 0,014 con 1. La memoria era la información, y la serie no necesitaba ser «más estacionaria». El HAR, con tres medias de la serie sin diferenciar, llega a 0,568. Es la regla de la lección 2.1.2 (no diferenciar lo que ya es estacionario) vista con un modelo.
Las decisiones de la lección, en un diagrama:
Fuente Mermaid
flowchart TB
S{¿Estacionaria?} -- sí --> N[No diferenciar]
S -- no --> O{¿Importa el nivel?}
O -- no --> R[Rendimientos]
O -- sí --> P{¿Nivel PIT?}
P -- no --> R
P -- sí --> F[FFD con d fija]Errores típicos
- Elegir con toda la muestra. El barrido de es un ajuste más: se hace en el entrenamiento y se mantiene fijo en la prueba.
- Confiar en el ADF como criterio para un modelo. Pasa con pequeñas que dejan la mayor parte de la prueba fuera del rango de entrenamiento. Hay que medir también la estabilidad de la distribución.
- Aplicar la FFD a un nivel que no es point-in-time. Precios ajustados hacia atrás, series continuas con ajuste proporcional o series normalizadas por su último valor llevan el futuro en el nivel, y la FFD lo conserva.
- Ventanas que cruzan sesiones o huecos. Con datos intradía, una ventana de cientos de minutos mezcla días y arrastra el salto de la apertura; se calcula por sesión con un umbral mayor o con una ventana fija.
- Diferenciar lo que ya es estacionario. La log-varianza realizada, un spread cointegrado o un tipo de interés en un rango acotado pierden información al diferenciarse.
- No comparar con una baseline. Una FFD solo merece entrar en el modelo si mejora, fuera de muestra y más allá de su error típico, a un conjunto sencillo de rendimientos.
Ejercicios
- Comprueba con el control de
vol5que una FFD no crea predictibilidad donde no la hay: para cada activo, calcula el fuera de muestra (2018-2025) del rendimiento del día siguiente con la FFD del log del precio () y con el rendimiento de hoy ().
Solución
filas = {}
for activo, g in v5.groupby("activo"):
r = g.set_index("fecha")["rendimiento"]
logp = np.log1p(r).cumsum()
obj = r.shift(-1)
filas[activo] = {f"d={d}": r2_fuera(ffd(logp, d).to_frame("x"), obj)
for d in [0.5, 1.0]}
(pd.DataFrame(filas).T * 100).round(2) # en % d=0.5 d=1.0
accion 0.11 -0.05
bono -0.05 -0.08
cripto -0.06 -0.26
divisa 0.10 -0.13
indice -4.54 -0.26
oro -0.36 0.13En cinco de los seis activos, los dos están entre −0,4 % y +0,1 %: nada, como corresponde a unos rendimientos impredecibles por construcción. El índice da −4,5 % con la FFD: la regresión aprende en 2008-2017 una pendiente con el resto de nivel y la extrapola a un nivel de 2018-2025 que no había visto, con lo que predice peor que la media histórica. Una FFD no fabrica señal; lo que sí puede fabricar es un error de extrapolación.
- Elige para el oro y la cripto de
vol5con un criterio que no mire la prueba: la más pequeña (en pasos de 0,05) que deja menos del 5 % de 2015-2017 fuera del rango de 2008-2014. Después mide la fracción fuera de rango de 2018-2025 respecto a 2008-2017 con esa .
Solución
def fuera(x, a, b):
# fracción de x en el tramo b fuera del rango de x en el tramo a
xa, xb = x.loc[a[0]:a[1]], x.loc[b[0]:b[1]]
return ((xb < xa.min()) | (xb > xa.max())).mean()
for activo in ["oro", "cripto"]:
r = v5[v5["activo"] == activo].set_index("fecha")["rendimiento"]
logp = np.log1p(r).cumsum()
for d in np.round(np.arange(0.05, 1.01, 0.05), 2):
x = ffd(logp, d)
if fuera(x, ("2008", "2014"), ("2015", "2017")) < 0.05:
break
f = fuera(x, ("2008", "2017"), ("2018", "2025"))
print(f"{activo}: d elegida {d}; fuera de rango en 2018-2025: "
f"{f:.1%}")oro: d elegida 0.05; fuera de rango en 2018-2025: 86.1%
cripto: d elegida 0.25; fuera de rango en 2018-2025: 31.6%Para el oro, la validación 2015-2017 cabe entera en el rango de 2008-2014 con , pero en 2018-2025 el 86 % de los valores queda fuera: el oro de los datos sale del rango histórico en la prueba, y un solo bloque de validación no lo podía saber. Para la cripto el criterio elige y aun así queda fuera un 32 %. Un criterio de rango evaluado una vez no protege de un cambio de régimen; en un walk-forward hay que recalcular la fracción fuera de rango en cada bloque, reentrenar (y revisar ) cuando crece, y preferir una algo mayor que la mínima cuando el modelo es un árbol.
- La serie continua de un futuro del nivel 2 (
futuros2_continuos.parquet, columnacierre_ajustado) tiene ajuste proporcional hacia atrás. Un compañero calcula su FFD con para un modelo de tendencia. ¿Qué problema tiene y cómo lo arreglarías?
Solución
El ajuste proporcional hacia atrás multiplica todo el pasado por el cociente de cada roll posterior: el nivel de una
fecha depende de los saltos entre contratos que todavía no habían ocurrido (lección 1.4.6). Con la FFD
conserva un 13 % del nivel del principio de la ventana, y con él esa información del futuro, como el precio normalizado de esta
lección. Los rendimientos de la serie ajustada, en cambio, son los del contrato que se tenía y sí son point-in-time.
El arreglo es construir el nivel hacia delante: acumular los rendimientos de la serie desde una fecha fija (como el
lp de vol5) y calcular la FFD sobre ese nivel, que en cada fecha solo usa el pasado. Después, como en la lección,
elegir con el entrenamiento, comprobar el rango en la prueba y compararla con un conjunto de rendimientos.
Footnotes
-
M. López de Prado, Advances in Financial Machine Learning, Wiley, 2018, capítulo 5 («Fractionally Differentiated Features»); el operador fraccional viene de J. R. M. Hosking, «Fractional differencing», Biometrika 68(1), 1981, pp. 165-176, doi:10.1093/biomet/68.1.165. ↩
-
F. Corsi, «A Simple Approximate Long-Memory Model of Realized Volatility», Journal of Financial Econometrics 7(2), 2009, pp. 174-196, doi:10.1093/jjfinec/nbp001. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Con un umbral de 0,0001, la diferencia fraccional con d = 0,3 usa 388 pesos y la suma de sus pesos es 0,13. ¿Qué significa esa suma?
- AQue la serie no es estacionaria
- BQue la feature es 0,13 veces el nivel del precio de hace 387 sesiones más una suma ponderada de los 387 rendimientos posteriores: queda un resto de nivel
- CQue el 13 % de los pesos son positivos
- DNada: la suma de los pesos siempre es 0,13
Ver respuesta
Respuesta B. Que la feature es 0,13 veces el nivel del precio de hace 387 sesiones más una suma ponderada de los 387 rendimientos posteriores: queda un resto de nivel
Agrupando términos, la suma de w_k·y_(t−k) es igual a la suma de c_j·r_(t−j) más c_(L−1)·y_(t−L+1), con c_j las sumas parciales de los pesos. La última suma parcial es la suma de todos los pesos: el peso que le queda al nivel de la serie al principio de la ventana. Con la ventana infinita sería cero para cualquier d > 0.
En el índice de
vol5, la diferencia fraccional con d = 0,1 ya pasa el ADF al 5 % en 2008-2017, pero el 77 % de sus valores de 2018-2025 queda fuera del rango de entrenamiento. ¿Qué hará un árbol entrenado con ella?- AAsignar a todos esos valores la hoja del extremo del rango de entrenamiento, como si fueran el máximo observado
- BExtrapolar la tendencia aprendida
- CDar un error
- DIgnorar la feature
Ver respuesta
Respuesta A. Asignar a todos esos valores la hoja del extremo del rango de entrenamiento, como si fueran el máximo observado
Un árbol parte el eje en umbrales aprendidos con los datos de entrenamiento; cualquier valor por encima del último umbral cae en la misma hoja. El ADF mide si la serie vuelve a su media dentro de la muestra, no si la muestra futura está dentro del rango de la pasada.
En el panel, el logaritmo del precio normalizado por su último valor tiene un IC de −0,066 con el rendimiento de la semana siguiente, más del doble del techo del oráculo (0,029); el del precio publicado, −0,0002. ¿Qué indica?
- AUn efecto de precio bajo muy fuerte
- BQue el oráculo está mal calculado
- CUna fuga: normalizar por el último precio hace que el nivel de hoy sea menos el rendimiento de hoy hasta el final de la muestra, que incluye la semana siguiente; una diferencia fraccional con d pequeña la hereda
- DQue hace falta más datos
Ver respuesta
Respuesta C. Una fuga: normalizar por el último precio hace que el nivel de hoy sea menos el rendimiento de hoy hasta el final de la muestra, que incluye la semana siguiente; una diferencia fraccional con d pequeña la hereda
Ninguna feature puede superar fuera de muestra al rendimiento esperado verdadero. El nivel de un precio normalizado por su último valor (como una serie ajustada hacia atrás) no es point-in-time; los rendimientos sí. La diferencia fraccional conserva la memoria del nivel, con sus defectos.
En FNX, un LightGBM con tres diferencias fraccionales completas tiene una correlación fuera de muestra de −0,012 y uno con cinco rendimientos, de 0,060. Con solo la parte de rendimientos de las diferencias fraccionales, 0,069. ¿Qué se concluye?
- AQue la diferencia fraccional es mejor que los rendimientos
- BQue LightGBM no sirve para datos intradía
- CQue hay que usar d = 1
- DQue el resto de nivel es ruido para este objetivo y perjudica; la parte útil de la diferencia fraccional es una suma ponderada de rendimientos, equivalente a un buen conjunto de rendimientos (la diferencia entre 0,060 y 0,069 está dentro del error típico de 0,016)
Ver respuesta
Respuesta D. Que el resto de nivel es ruido para este objetivo y perjudica; la parte útil de la diferencia fraccional es una suma ponderada de rendimientos, equivalente a un buen conjunto de rendimientos (la diferencia entre 0,060 y 0,069 está dentro del error típico de 0,016)
El nivel de FNX dentro del año no dice nada del rendimiento de los 10 minutos siguientes, y un árbol que lo usa para partir aprende ruido. La memoria útil aquí es la de los rendimientos recientes, que la diferencia fraccional resume con pesos que decaen como una potencia.
Ideas clave
- Una diferencia fraccional de ventana fija es una suma de rendimientos pasados con pesos que decaen como una potencia más un resto del nivel inicial de la ventana (la suma de los pesos: 0,13 con d = 0,3 y 0,04 con d = 0,5).
- El ADF del entrenamiento no basta: en el índice, d = 0,1 pasa el ADF y deja el 77 % de la prueba fuera del rango de entrenamiento. Para un árbol, el criterio útil es la estabilidad de la distribución entre entrenamiento y prueba.
- La memoria del nivel hereda los defectos del nivel. Con el precio del panel normalizado por su último valor (como un ajuste hacia atrás), el nivel da un IC imposible (−0,066, más del doble del oráculo) y la diferencia fraccional lo hereda (−0,055 con d = 0,3); con el precio publicado, point-in-time, el nivel no aporta nada.
- Como feature de un LightGBM en FNX, la parte de rendimientos de la diferencia fraccional rinde como cinco rendimientos (0,069 frente a 0,060, error típico 0,016); el resto de nivel empeora el modelo.
- Si la serie ya es estacionaria (la log-varianza realizada), no se diferencia: en el índice, con d = 0 se explica el 50 % de la varianza del día siguiente y con d = 0,5 solo el 8 %.