Lección 5.10.2 · 70 min

Deriva de datos y de concepto: detectarla y qué hacer

Un modelo de trading se estima con el pasado y se usa en un futuro que cambia. A veces cambian las features y el modelo sigue sirviendo; a veces cambia la relación con el objetivo y las features no avisan. Con los datos de deriva del nivel, que tienen cada cambio en una fecha conocida, se mide qué detecta cada herramienta, cuánto tarda, cuántas falsas alarmas da y qué conviene hacer después.

Fig. — Datos deriva5: media móvil de 63 sesiones del IC diario del modelo fijo (arriba) y PSI trimestral de f1 y f4 frente a 2014-2016 (abajo). La deriva de datos de 2017 y 2021 dispara el PSI sin tocar el IC; la de concepto de 2019 hunde el IC sin mover ningún PSI
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Dos derivas y solo una duele

Un modelo supervisado aprende la relación entre unas features XX y un objetivo yy a partir de una muestra. Lo que aprende es una aproximación a P(y∣X)P(y \mid X), y lo que usa para estimarla son ejemplos sacados de P(X)P(X). Cualquiera de las dos puede cambiar cuando el modelo ya está en producción, y la literatura las separa (Gama et al., 2014; Quiñonero-Candela et al., 2009):

TipoQué cambiaEjemplo en mercados¿Degrada el modelo?
Deriva de datos (covariate shift)P(X)P(X)la volatilidad sube y todas las features de riesgo se desplazanno necesariamente
Deriva de concepto (concept drift)P(y∣X)P(y \mid X)un factor deja de pagar o se invierte (crash del momentum, lección 5.1.1)sí
Deriva de la etiqueta (prior shift)P(y)P(y)un mercado alcista largo: más etiquetas positivasdepende del modelo y la métrica

La deriva de concepto, a su vez, puede ser brusca (un cambio de normativa, un competidor que arbitra la señal), gradual (una anomalía que se publica y se va diluyendo) o recurrente (los regímenes de calma y estrés que vuelven).

DerivaDatos: P(X)Concepto: P(y|X)BruscaGradualRecurrente
Fuente Mermaid
flowchart TB
  D[Deriva] --> X["Datos: P(X)"]
  D --> C["Concepto: P(y|X)"]
  C --> B[Brusca]
  C --> G[Gradual]
  C --> R[Recurrente]

La deriva de datos es la que se ve: basta comparar la distribución de las features de hoy con la del entrenamiento, y eso se puede hacer cada día, sin esperar a ninguna etiqueta. La deriva de concepto es la que duele, y no se ve mirando las features: solo aparece en los resultados, cuando ya han llegado las etiquetas que el modelo predijo mal. En datos reales nunca se sabe cuál de las dos ha ocurrido ni cuándo. Los datos deriva5_* del nivel sí lo dicen.

El laboratorio: seis segmentos con fecha conocida

deriva5_muestras.parquet tiene 30 activos ficticios en 3.018 sesiones de la NYSE (2014-2025), seis features tipificadas y el rendimiento del día siguiente ya alineado. El objetivo es lineal en las features con una relación señal/ruido de rendimientos diarios: un IC verdadero de 0,10, un R2R^2 del 1 %. El README del nivel publica los segmentos:

Seg.DesdeTipoQué cambia
02014-01-02basef1, f2 y f3 con efecto (+8, +5 y −4 pb por unidad); f4, f5 y f6 sin efecto
12017-01-03datosla media de f1 sube una desviación y la dispersión de f3 se duplica; la relación no cambia
22019-07-01conceptoel efecto de f1 cambia de signo (+8 a −8 pb); las features vuelven a la base
32021-01-04datos irrelevantef4, sin efecto, sube 1,5 desviaciones y su dispersión ×1,5
42022-07-01concepto gradualen 12 meses, el efecto de f2 baja de 5 a 0 y el de f5 sube de 0 a 6
52023-07-03establela relación nueva, sin más cambios

El protagonista es un modelo fijo: un OLS estimado con 2014-2016 y que nunca se reentrena. Su calidad se mide como en todo el nivel, con el IC diario: la correlación transversal entre su predicción y el rendimiento del día siguiente de los 30 activos (lección 5.1.3).

IN [01]python
import numpy as np
import pandas as pd

d = pd.read_parquet("deriva5_muestras.parquet")
seg = pd.read_csv("deriva5_verdad.csv", parse_dates=["desde"])
F = [f"f{k}" for k in range(1, 7)]
fechas = pd.DatetimeIndex(np.sort(d["fecha"].unique()))
n, NA = len(fechas), d["activo"].nunique()
X = d[F].to_numpy(float).reshape(n, NA, 6)  # sesión × activo × f
Y = d["objetivo"].to_numpy(float).reshape(n, NA)
s = np.searchsorted(seg["desde"], fechas, side="right") - 1

base = s == 0                                # 2014-2016
X1 = np.column_stack([np.ones(base.sum() * NA),
                      X[base].reshape(-1, 6)])
beta = np.linalg.lstsq(X1, Y[base].ravel(), rcond=None)[0]
print("coeficientes del modelo fijo (pb):",
      np.round(beta[1:] * 1e4, 1))

def ic_diario(P, Y):
    """Correlación transversal de cada sesión entre P e Y."""
    P = P - P.mean(1, keepdims=True)
    Yc = Y - Y.mean(1, keepdims=True)
    return ((P * Yc).sum(1)
            / np.sqrt((P**2).sum(1) * (Yc**2).sum(1)))

ic = pd.Series(ic_diario(X @ beta[1:], Y), index=fechas)
tabla = seg[["segmento", "desde", "tipo"]].copy()
tabla["desde"] = tabla["desde"].dt.date
tabla["ic_fijo"] = ic.groupby(s).mean().round(3).to_numpy()
tabla
OUT [01]Datos con fechas conocidas y el modelo fijo
coeficientes del modelo fijo (pb): [ 8.9  6.1 -4.5 -0.  -0.   1. ]
   segmento       desde               tipo  ic_fijo
0         0  2014-01-02               base    0.120
1         1  2017-01-03              datos    0.117
2         2  2019-07-01           concepto   -0.019
3         3  2021-01-04  datos_irrelevante   -0.026
4         4  2022-07-01   concepto_gradual   -0.035
5         5  2023-07-03            estable   -0.051

El OLS recupera la relación base con su error de muestreo (8,9, 6,1 y −4,5 pb frente a 8, 5 y −4) y ceros para las features sin efecto. Su IC medio no se mueve con la deriva de datos de 2017 (0,117 frente a 0,120) y se hunde con la de concepto de 2019 (−0,019). Desde entonces no se recupera: en 2021 la deriva de f4 es irrelevante para él, y la gradual de 2022 le quita además la parte de f2 (−0,051 al final).

Deriva de datos: PSI y KS

El índice de estabilidad de la población (PSI, Population Stability Index) viene del riesgo de crédito. Se cortan los datos de referencia en BB tramos (aquí, deciles), se mide qué fracción pip_i de la referencia y qiq_i del periodo actual cae en cada uno y se suma:

PSI=∑i=1B(qi−pi) ln⁡qipi\text{PSI} = \sum_{i=1}^{B} (q_i - p_i)\,\ln\frac{q_i}{p_i}

Es una divergencia de Kullback-Leibler simetrizada sobre el histograma: cero si los dos histogramas coinciden, y crece con cualquier diferencia, de media, de dispersión o de forma. La regla práctica habitual lee un PSI por debajo de 0,1 como estable y por encima de 0,25 como deriva fuerte.

IN [02]python
def psi(ref, cur, nb=10):
    """Population Stability Index con deciles de la referencia."""
    cortes = np.quantile(ref, np.linspace(0, 1, nb + 1)[1:-1])
    p = np.bincount(np.searchsorted(cortes, ref), minlength=nb)
    q = np.bincount(np.searchsorted(cortes, cur), minlength=nb)
    p = np.maximum(p / len(ref), 1e-6)
    q = np.maximum(q / len(cur), 1e-6)
    return float(np.sum((q - p) * np.log(q / p)))

ref = X[base]
filas = {}
for k in range(1, 6):
    cur = X[s == k]
    filas[f"{k} {seg['tipo'][k]}"] = [
        psi(ref[..., j].ravel(), cur[..., j].ravel())
        for j in range(6)]
pd.DataFrame(filas, index=F).T.round(2)
OUT [02]PSI de cada feature frente al segmento base
                       f1   f2   f3    f4   f5   f6
1 datos              0.97  0.0  0.5  0.00  0.0  0.0
2 concepto           0.00  0.0  0.0  0.00  0.0  0.0
3 datos_irrelevante  0.00  0.0  0.0  1.23  0.0  0.0
4 concepto_gradual   0.00  0.0  0.0  1.27  0.0  0.0
5 estable            0.00  0.0  0.0  1.28  0.0  0.0

El PSI ve exactamente las derivas de datos plantadas: 0,97 en f1 y 0,50 en f3 en el segmento 1 (la dispersión duplicada de f3 también cuenta), 1,23 en f4 desde 2021, y ceros en todo lo demás. Sobre todo, ceros en el segmento 2, el de la deriva de concepto, y en el gradual. La figura de la cabecera pone las dos cosas juntas: el PSI salta en 2017 y en 2021 sin que el IC se entere, y el IC se hunde en julio de 2019 sin que ningún PSI se mueva.

La alternativa clásica es la prueba de Kolmogorov-Smirnov de dos muestras (scipy.stats.ks_2samp), que compara las funciones de distribución empíricas y da un p-valor. Antes de usar cualquiera de las dos como alarma hay que saber qué dan cuando no pasa nada: se compara cada mes de 2016 con 2014-2015, sin ningún cambio plantado.

IN [03]python
from scipy import stats

ref = X[fechas.year <= 2015].reshape(-1, 6)   # 2014-2015
meses = pd.period_range("2016-01", "2016-12", freq="M")
psis, pvals = [], []
for m in meses:
    cur = X[fechas.to_period("M") == m].reshape(-1, 6)
    for j in range(6):
        psis.append(psi(ref[:, j], cur[:, j]))
        pvals.append(stats.ks_2samp(ref[:, j], cur[:, j]).pvalue)
psis, pvals = np.array(psis), np.array(pvals)
print(f"72 pruebas (12 meses × 6 features) sin deriva")
print(f"PSI: mediana {np.median(psis):.3f}, máximo {psis.max():.3f}")
print(f"KS con p < 0,05: {np.mean(pvals < 0.05):.0%} "
      f"(lo esperado: 5 %)")
ks_ano = [stats.ks_2samp(ref[:, j],
                         X[fechas.year == 2016][..., j].ravel())
          for j in range(6)]
print("2016 entero, p del KS:",
      [f"{r.pvalue:.3f}" for r in ks_ano])
print("estadístico D:", [round(float(r.statistic), 3)
                         for r in ks_ano])
OUT [03]PSI y KS cuando no hay deriva
72 pruebas (12 meses × 6 features) sin deriva
PSI: mediana 0.018, máximo 0.055
KS con p < 0,05: 33% (lo esperado: 5 %)
2016 entero, p del KS: ['0.000', '0.146', '0.009', '0.000', '0.315', '0.018']
estadístico D: [0.029, 0.016, 0.023, 0.04, 0.013, 0.022]

Sin deriva, el PSI mensual se queda en valores de ruido (mediana 0,018, máximo 0,055), muy por debajo del 0,25. El KS no: el 33 % de las 72 pruebas sale con p<0,05p < 0{,}05, seis veces lo esperado, y con 2016 entero frente a 2014-2015 cuatro de las seis features «cambian» con p<0,05p < 0{,}05 (tres con p≤0,009p \le 0{,}009). Hay dos razones. Las features son AR(1) con ρ=0,5\rho = 0{,}5 dentro de cada activo: las filas no son independientes, el tamaño efectivo de la muestra es varias veces menor que el número de filas y el p-valor sale demasiado pequeño. Y con miles de filas, diferencias minúsculas son significativas: los estadísticos D están entre 0,013 y 0,040: diferencias de unas centésimas en la función de distribución.

Deriva multivariante: validación adversaria

El PSI y el KS miran cada feature por separado. Una deriva en la relación entre features (dos que dejan de estar correlacionadas) no se ve así. La validación adversaria mira todas a la vez: se entrena un clasificador que distinga los datos de referencia de los actuales. Si no puede (AUC ≈ 0,5), las dos muestras son indistinguibles; si puede, la importancia del clasificador dice qué features han cambiado. Rabanser et al. (2019) comparan varios detectores de deriva en varios conjuntos de datos: el mejor en su estudio es un contraste de dos muestras sobre una representación reducida, y los clasificadores que separan dominios resultan útiles para describir la deriva y ver si es dañina.

Dos detalles deciden si el AUC significa algo. Los pliegues deben ser de meses enteros (filas de días vecinos son casi iguales, como en la lección 5.4.1) y el AUC se calcula en cada pliegue y se promedia. Si se juntan las predicciones de todos los pliegues y se calcula un solo AUC, sale por debajo de 0,5 sin deriva: cada pliegue tiene una proporción de clases distinta y el clasificador, sin nada que aprender, predice esa proporción, al revés en el pliegue de prueba.

IN [04]python
import lightgbm as lgb
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedGroupKFold

mref = fechas.year == 2016                 # el último año visto
mes = np.repeat((fechas.year * 100 + fechas.month).to_numpy(), NA)
mes = mes.reshape(n, NA)

def adversaria(m):
    """AUC de un clasificador que separa 2016 de las sesiones m."""
    A = np.vstack([X[mref].reshape(-1, 6), X[m].reshape(-1, 6)])
    c = np.r_[np.zeros(mref.sum() * NA), np.ones(m.sum() * NA)]
    g = np.r_[mes[mref].ravel(), mes[m].ravel()]
    aucs, imp = [], np.zeros(6)
    cv = StratifiedGroupKFold(4, shuffle=True, random_state=0)
    for tr, te in cv.split(A, c, g):          # meses enteros
        clf = lgb.LGBMClassifier(
            n_estimators=60, num_leaves=8, min_child_samples=100,
            random_state=0, n_jobs=4, deterministic=True,
            force_row_wise=True, verbose=-1).fit(A[tr], c[tr])
        p = clf.predict_proba(A[te])[:, 1]
        aucs.append(roc_auc_score(c[te], p))  # AUC de cada pliegue
        imp += clf.booster_.feature_importance("gain")
    return np.mean(aucs), F[int(imp.argmax())], imp.max() / imp.sum()

semestre = fechas.year.astype(str) + np.where(
    fechas.month <= 6, "-S1", "-S2")
adv = {}
for sm in semestre.unique():
    if sm.startswith("2016"):
        continue
    auc, top, peso = adversaria(semestre == sm)
    adv[sm] = {"auc": auc, "feature": top, "peso": peso,
               "ic_fijo": ic[semestre == sm].mean()}
adv = pd.DataFrame(adv).T.astype({"auc": float, "peso": float,
                                  "ic_fijo": float})
adv.loc[["2014-S1", "2015-S2", "2017-S1", "2019-S1", "2019-S2",
         "2020-S2", "2021-S1", "2023-S1", "2025-S2"]].round(3)
OUT [04]Validación adversaria por semestre
           auc feature   peso  ic_fijo
2014-S1  0.493      f3  0.203    0.138
2015-S2  0.518      f4  0.198    0.103
2017-S1  0.826      f1  0.535    0.142
2019-S1  0.824      f1  0.533    0.123
2019-S2  0.519      f2  0.198   -0.031
2020-S2  0.496      f1  0.185   -0.025
2021-S1  0.782      f4  0.905   -0.029
2023-S1  0.780      f4  0.916   -0.026
2025-S2  0.790      f4  0.919   -0.027

En los semestres sin deriva el AUC queda entre 0,49 y 0,52, lo que dan dos muestras iguales. En 2017-2019 sube a 0,83, con f1 como feature más importante (un 53 % de la ganancia). Desde 2021 queda en 0,78-0,79, con f4 (92 %). Y en 2019-S2 y 2020-S2, tras el cambio de concepto, vuelve a 0,52 y 0,50 mientras el IC del modelo fijo es negativo (−0,031 y −0,025). La validación adversaria responde muy bien a su pregunta, si los datos de hoy se parecen a los del entrenamiento, y esa pregunta no es la que importa para la cuenta de resultados.

Para qué sirve, entonces, vigilar las features: para diagnosticar (cuando el rendimiento cae, saber si las features cambiaron ayuda a entender por qué), para avisar de errores de datos (un proveedor que cambia una unidad, una columna que llega vacía) y para vigilar mientras las etiquetas no llegan. Con un objetivo a 21 días, el rendimiento de hoy se conoce dentro de un mes; las features, hoy.

Vigilar el rendimiento: media móvil, CUSUM y Page-Hinkley

La deriva de concepto solo se ve en el rendimiento. El IC diario es una serie ruidosa: con 30 activos y un IC verdadero de 0,12, su desviación típica es 0,18 (casi 1/291/\sqrt{29}), así que un día con IC negativo no dice nada. El detector más sencillo, el del README del nivel, es la media móvil de 63 sesiones del IC con una alarma cuando cae por debajo de cero.

IN [05]python
cambio = pd.Timestamp("2019-07-01")
mm = ic.rolling(63).mean()
fecha_alarma = mm[(mm.index >= cambio) & (mm < 0)].index[0]
retraso = ((fechas >= cambio) & (fechas <= fecha_alarma)).sum()
print("primera media móvil < 0:", fecha_alarma.date(),
      f"({retraso} sesiones después del cambio)")
antes = mm["2017-01-01":"2019-06-28"]
print(f"mínimo antes del cambio: {antes.min():.3f}")
OUT [05]Monitor del IC: media móvil de 63 sesiones
primera media móvil < 0: 2019-08-29 (43 sesiones después del cambio)
mínimo antes del cambio: 0.071

Avisa el 2019-08-29, 43 sesiones después del cambio, sin falsas alarmas antes (la media móvil no bajó de 0,071 en 2017-2019). Es fácil de explicar y lenta: una ventana de 63 días necesita que la mitad de su contenido sea posterior al cambio para que la media cruce el cero.

El CUSUM de Page (1954) es el detector secuencial clásico. Para una caída de la media desde μ0\mu_0:

St=max⁡(0,  St−1+(μ0−k)−xt),alarma si St>hS_t = \max\left(0,\; S_{t-1} + (\mu_0 - k) - x_t\right), \qquad \text{alarma si } S_t > h

StS_t acumula cuánto queda el IC por debajo de μ0−k\mu_0 - k y se reinicia a cero cuando el modelo va bien. La holgura kk se suele fijar en la mitad del salto que se quiere detectar (aquí, de 0,12 a 0: k=0,06k = 0{,}06) y el umbral hh decide el equilibrio entre falsas alarmas y retraso. Es la misma recursión que el filtro CUSUM de la lección 5.3.5, que allí muestreaba precios y aquí vigila un modelo. Page-Hinkley es su variante sin μ0\mu_0: compara cada dato con la media desde el último reinicio.

mt=∑i≤t(x‾i−xi−δ),alarma si mt−min⁡j≤tmj>λm_t = \sum_{i \le t} \left(\overline{x}_i - x_i - \delta\right), \qquad \text{alarma si } m_t - \min_{j \le t} m_j > \lambda
IN [06]python
x = ic.to_numpy()
t0 = np.searchsorted(fechas, pd.Timestamp("2017-01-03"))
mu0 = ic[base].mean()

def cusum(x, mu0, k, h):
    """CUSUM unilateral de Page para una caída de la media."""
    S, alarmas = 0.0, []
    for t, v in enumerate(x):
        S = max(0.0, S + (mu0 - k) - v)
        if S > h:
            alarmas.append(t)
            S = 0.0                       # reinicio tras la alarma
    return alarmas

def page_hinkley(x, delta, lam):
    """Page-Hinkley: caída frente a la media desde el reinicio."""
    m = M = suma = 0.0
    cnt, alarmas = 0, []
    for t, v in enumerate(x):
        cnt += 1
        suma += v
        m += suma / cnt - v - delta
        M = min(M, m)
        if m - M > lam:
            alarmas.append(t)
            m = M = suma = 0.0
            cnt = 0
    return alarmas

def resumen(alarmas):
    f = fechas[t0:][alarmas]
    falsas = int((f < cambio).sum())
    tras = f[f >= cambio]
    r = int(((fechas >= cambio) & (fechas <= tras[0])).sum())
    return falsas, tras[0].date(), r

print(f"IC medio de referencia (2014-2016): {mu0:.3f}")
for h in [0.5, 1.0, 2.0, 3.0]:
    fa, f, r = resumen(cusum(x[t0:], mu0, 0.06, h))
    print(f"CUSUM h={h}: {fa:2d} falsas alarmas 2017-2019, "
          f"primera tras el cambio {f} ({r} sesiones)")
for lam in [1.0, 2.0, 3.0]:
    fa, f, r = resumen(page_hinkley(x[t0:], 0.03, lam))
    print(f"Page-Hinkley lambda={lam}: {fa:2d} falsas alarmas, "
          f"{f} ({r} sesiones)")
OUT [06]CUSUM y Page-Hinkley sobre el IC
IC medio de referencia (2014-2016): 0.120
CUSUM h=0.5: 19 falsas alarmas 2017-2019, primera tras el cambio 2019-07-08 (5 sesiones)
CUSUM h=1.0:  3 falsas alarmas 2017-2019, primera tras el cambio 2019-07-16 (11 sesiones)
CUSUM h=2.0:  0 falsas alarmas 2017-2019, primera tras el cambio 2019-07-19 (14 sesiones)
CUSUM h=3.0:  0 falsas alarmas 2017-2019, primera tras el cambio 2019-08-01 (23 sesiones)
Page-Hinkley lambda=1.0:  8 falsas alarmas, 2019-07-11 (8 sesiones)
Page-Hinkley lambda=2.0:  1 falsas alarmas, 2019-07-18 (13 sesiones)
Page-Hinkley lambda=3.0:  0 falsas alarmas, 2019-07-29 (20 sesiones)

El CUSUM con h=2h = 2 avisa el 2019-07-19, a las 14 sesiones, sin ninguna falsa alarma en los dos años y medio anteriores, que tienen además la deriva de datos de 2017. Con h=0,5h = 0{,}5 avisa a las 5 sesiones, a cambio de 19 falsas alarmas en ese periodo, unas siete al año. Page-Hinkley se comporta parecido: con λ=3\lambda = 3, 20 sesiones sin falsas alarmas. La mitad de arriba de la figura sigue el estadístico del CUSUM (sin reinicio) en 2019: plano hasta julio y, desde el cambio, una subida casi recta.

Fig. — Marzo a octubre de 2019: estadístico CUSUM sobre el IC diario del modelo fijo, sin reinicio (arriba), y probabilidad filtrada de que el modelo haya dejado de funcionar según un filtro de dos estados (abajo). Los dos cruzan su umbral el mismo día

Falsas alarmas frente a retraso

Un detector no se juzga por un episodio. El cambio de 2019 es una sola realización, y 14 sesiones pueden ser suerte. Para medir el equilibrio se simulan muchas historias: 2.000 caminos de diez años con IC sacados al azar de los días sin cambio (2017-2019-S1) para contar falsas alarmas, y caminos de un año con IC de después del cambio (2019-S2 a 2020) para medir el retraso.

IN [07]python
tc = np.searchsorted(fechas, cambio)
t21 = np.searchsorted(fechas, pd.Timestamp("2021-01-01"))
sano, roto = x[t0:tc], x[tc:t21]     # IC antes y después
rng = np.random.default_rng(0)
NS, T = 2000, 2520                   # 2.000 caminos de 10 años
a = rng.choice(sano, (NS, T))
b = rng.choice(roto, (NS, 252))

def sim_cusum(h, k=0.06):
    S, fa = np.zeros(NS), np.zeros(NS)
    for t in range(T):
        S = np.maximum(0, S + (mu0 - k) - a[:, t])
        fa += S > h
        S[S > h] = 0
    S, r = np.zeros(NS), np.full(NS, 252)
    for t in range(252):
        S = np.maximum(0, S + (mu0 - k) - b[:, t])
        r[(S > h) & (r == 252)] = t + 1
    return fa.mean() / 10, np.median(r)

def sim_media(w):
    c = np.cumsum(a, 1)
    bajo = (c[:, w:] - c[:, :-w]) / w < 0
    fa = (bajo[:, 1:] & ~bajo[:, :-1]).sum(1).mean() / 10
    c = np.cumsum(np.hstack([np.zeros((NS, 1)), a[:, -w:], b]), 1)
    tras = ((c[:, w:] - c[:, :-w]) / w)[:, 1:] < 0
    r = np.where(tras.any(1), tras.argmax(1) + 1, 252)
    return fa, np.median(r)

filas = [("CUSUM", h, *sim_cusum(h)) for h in [1, 1.5, 2, 2.5, 3]]
filas += [("media móvil", w, *sim_media(w)) for w in [21, 42, 63]]
pd.DataFrame(filas, columns=["detector", "parámetro",
                             "falsas/año", "retraso mediano"]
             ).round(3)
OUT [07]Falsas alarmas frente a retraso, por simulación
      detector  parámetro  falsas/año  retraso mediano
0        CUSUM        1.0       1.030             11.0
1        CUSUM        1.5       0.193             17.0
2        CUSUM        2.0       0.038             23.0
3        CUSUM        2.5       0.007             30.0
4        CUSUM        3.0       0.001             36.0
5  media móvil       21.0       0.204             18.0
6  media móvil       42.0       0.002             36.0
7  media móvil       63.0       0.000             54.0
Fig. — Falsas alarmas por año frente al retraso mediano en detectar la caída del IC, en 2.000 caminos simulados con los IC diarios de antes y después del cambio: CUSUM con varios umbrales h y media móvil de 21, 42 y 63 sesiones con umbral 0

El retraso típico del CUSUM con h=2h = 2 es de 23 sesiones, no 14, con 0,04 falsas alarmas al año (una cada 25 años). La media móvil de 63 sesiones tarda 54 de mediana: los 43 de los datos también fueron algo afortunados. La figura pone las dos familias en el mismo plano. A igual tasa de falsas alarmas, el CUSUM llega unas sesiones antes: con 0,2 al año, 17 sesiones frente a 18; con unas 0,002-0,007, 30 frente a 36. La ventaja existe (el CUSUM es óptimo para este problema en el sentido de Lorden), pero es modesta. La decisión importante es dónde ponerse en la curva: cuánto cuesta una falsa alarma (apagar un modelo que funciona) frente a cuánto cuesta cada día de retraso (operar con un modelo roto).

Regímenes: el filtro de dos estados

La lección 2.1.7 detectaba regímenes con un modelo de Markov oculto y distinguía entre probabilidades filtradas (con la información hasta cada día) y suavizadas (con toda la muestra, también la futura). La misma idea vigila un modelo: dos estados, «funciona» (IC medio μ0=0,12\mu_0 = 0{,}12) y «no funciona» (IC medio 0), con la misma desviación, una probabilidad de cambiar de estado de 0,002 al día y la recursión del filtro:

πt∝(πt−1A)⊙ϕ(xt)\pi_t \propto (\pi_{t-1} A) \odot \phi(x_t)

donde AA es la matriz de transición y ϕ(xt)\phi(x_t) la densidad del IC de hoy en cada estado. Los parámetros se fijan de antemano, con 2014-2016, en vez de estimarlos con toda la muestra: estimarlos con datos de 2019-2025 sería mirar el futuro aunque se use el filtro.

IN [08]python
from scipy.stats import norm

medias = np.array([mu0, 0.0])     # el modelo funciona / no funciona
sd = ic[base].std()
p = 0.998                         # duración esperada: 500 sesiones
A = np.array([[p, 1 - p], [1 - p, p]])
pi, prob = np.array([1.0, 0.0]), []
for v in x[t0:]:
    pi = (pi @ A) * norm.pdf(v, medias, sd)  # predecir y actualizar
    pi = pi / pi.sum()
    prob.append(pi[1])
prob = pd.Series(prob, index=fechas[t0:])
print(f"desv. del IC diario: {sd:.3f}")
print("P(roto) máxima antes del cambio:",
      round(prob[:"2019-06-28"].max(), 2))
print("primera P(roto) > 0,9:",
      prob[(prob.index >= cambio) & (prob > 0.9)].index[0].date())
print(prob.resample("QE").mean()["2019":"2020"].round(2)
      .to_string())
OUT [08]Filtro de dos estados sobre el IC
desv. del IC diario: 0.184
P(roto) máxima antes del cambio: 0.63
primera P(roto) > 0,9: 2019-07-19
2019-03-31    0.02
2019-06-30    0.01
2019-09-30    0.83
2019-12-31    0.98
2020-03-31    0.98
2020-06-30    0.98
2020-09-30    0.97
2020-12-31    0.99
Freq: QE-DEC

La probabilidad de que el modelo esté roto pasa de 0,9 el 2019-07-19, el mismo día que el CUSUM. No es casualidad: con dos estados gaussianos de medias conocidas, el logaritmo del cociente de verosimilitudes de cada día es lineal en xtx_t con pendiente proporcional a la diferencia de medias, y el CUSUM con kk en el punto medio acumula exactamente ese cociente. El filtro añade la probabilidad previa de cambio y da una cifra que se lee directamente. Su falsa alarma más cercana antes del cambio llegó a 0,63, la mitad de abajo de la figura.

Los regímenes recurrentes piden otra respuesta. En el panel del nivel el momentum se invierte en cada rebote (lección 5.1.1): un detector que avise cuando el rebote ya ha empezado llega tarde a un régimen que dura unos meses, y reentrenar con datos del rebote enseña al modelo una relación que se va a invertir otra vez. Si los regímenes vuelven, es mejor que el modelo los vea como una feature observable y aprenda la interacción (lección 5.10.1); la detección de deriva sirve para lo que no vuelve.

Qué hacer: reentrenar, reponderar, apagar

Una alarma no dice qué hacer. Primero se diagnostica: si las features también cambiaron (PSI, validación adversaria), puede ser deriva de datos con efecto real en el modelo, un error de datos o las dos cosas; si no, es deriva de concepto.

nosínosíMonitor del IC¿Alarma?¿PSI o AUC altos?ConceptoDatos o erroresApagar, reentrenar
Fuente Mermaid
flowchart TB
  M[Monitor del IC] --> A{¿Alarma?}
  A -- no --> M
  A -- sí --> D{¿PSI o AUC altos?}
  D -- no --> C[Concepto]
  D -- sí --> E[Datos o errores]
  C --> R[Apagar, reentrenar]
  E --> R

Las respuestas posibles, comparadas en un walk-forward que reentrena el primer día de cada mes con los objetivos ya conocidos:

  • Reentrenar con ventana expansiva: todo el pasado.
  • Reentrenar con ventana móvil: solo el último año (252 sesiones) o medio año (126).
  • Reponderar: todo el pasado con pesos que caen a la mitad cada 126 sesiones.
  • El oráculo: los coeficientes verdaderos de cada día, el techo.
IN [09]python
def ols(i0, i1, semivida=None):
    """OLS con las sesiones i0..i1-1 (pesos exponenciales)."""
    A = np.column_stack([np.ones((i1 - i0) * NA),
                         X[i0:i1].reshape(-1, 6)])
    y = Y[i0:i1].ravel()
    if semivida:
        edad = np.repeat(np.arange(i1 - i0)[::-1], NA)
        w = np.sqrt(0.5 ** (edad / semivida))
        A, y = A * w[:, None], y * w
    return np.linalg.lstsq(A, y, rcond=None)[0][1:]

mes_n = (fechas.year * 12 + fechas.month).to_numpy()
reentreno = {t for t in range(t0, n) if mes_n[t] != mes_n[t - 1]}

def walk_forward(entrenar):
    """Reentrena el primer día de cada mes con lo que ya se sabe."""
    P, b = np.zeros((n, NA)), None
    for t in range(t0, n):
        if t in reentreno:
            b = entrenar(t - 1)      # objetivos conocidos hasta t
        P[t] = X[t] @ b
    return P

b_verdad = pd.read_csv("deriva5_coef_verdad.csv").iloc[:, 1:]
esquemas = {
    "fijo (2014-2016)": lambda t: beta[1:],
    "expansiva": lambda t: ols(0, t),
    "móvil 252": lambda t: ols(t - 252, t),
    "móvil 126": lambda t: ols(t - 126, t),
    "pesos, semivida 126": lambda t: ols(0, t, 126),
}
pred = {k: walk_forward(f) for k, f in esquemas.items()}
pred["oráculo (b verdaderos)"] = np.einsum(
    "tik,tk->ti", X, b_verdad.to_numpy())
res = {}
for k, P in pred.items():
    v = pd.Series(ic_diario(P[t0:], Y[t0:]), index=fechas[t0:])
    res[k] = {"2017-2025": v.mean(),
              "2019-S2 a 2020": v["2019-07":"2020-12"].mean(),
              "2022-S2 a 2023-S1": v["2022-07":"2023-06"].mean()}
pd.DataFrame(res).T.round(3)
OUT [09]Reentrenar: cinco esquemas en walk-forward
                        2017-2025  2019-S2 a 2020  2022-S2 a 2023-S1
fijo (2014-2016)            0.007          -0.019             -0.035
expansiva                   0.052           0.001              0.027
móvil 252                   0.101           0.055              0.105
móvil 126                   0.101           0.066              0.098
pesos, semivida 126         0.101           0.054              0.104
oráculo (b verdaderos)      0.111           0.091              0.108

En todo el periodo 2017-2025 el modelo fijo apenas suma (IC 0,007), la expansiva llega a 0,052 y las tres variantes que olvidan el pasado, a 0,101, cerca del oráculo (0,111). La diferencia está en los 18 meses que siguen al cambio de concepto: la expansiva da 0,001, porque hasta 2019 acumula cinco años de un efecto de f1 positivo; la móvil de 126 sesiones da 0,066 y la de 252, 0,055. En el periodo gradual de 2022-2023 todas las adaptativas siguen al oráculo (0,098-0,105 frente a 0,108): un cambio repartido en doce meses es fácil de seguir con una ventana de uno.

Es lo contrario de lo que la lección 5.4.3 encontró en el panel semanal, donde ganaba la expansiva, y no hay contradicción. Allí los efectos son pequeños y oscilan sin tendencia; aquí el cambio es grande frente al ruido y permanente. La ventana óptima depende de eso (Pesaran y Timmermann, 2007, citados en la lección 5.4.3).

Fig. — Coeficiente de f1 estimado cada mes con ventana expansiva, móvil de 252 sesiones y móvil de 126, frente al verdadero, que pasa de +8 a −8 pb por unidad en julio de 2019

La figura sigue el coeficiente de f1 estimado cada mes. La móvil de 126 sesiones cruza el cero a finales de 2019 y la de 252, en enero de 2020; después oscilan alrededor de −8 con su error de estimación. La expansiva ya bajaba antes del cambio (de 9 a 6,5 en 2017-2019: el desplazamiento de f1 en 2017, sin cambio en la media del objetivo, atenúa la pendiente de un OLS con una sola constante para los dos periodos) y no se vuelve negativa hasta noviembre de 2024.

La combinación con el detector: el modelo fijo hasta la alarma del CUSUM; después, apagado unas sesiones (sin posición) y reentreno solo con los datos posteriores a la alarma.

IN [10]python
alarma = t0 + cusum(x[t0:], mu0, 0.06, 2.0)[0]
print("alarma del CUSUM (h = 2):", fechas[alarma].date())

def tras_alarma(espera):
    """Modelo fijo hasta la alarma; apagado `espera` sesiones;
    después, OLS solo con datos desde la alarma (cada mes)."""
    P, b = np.zeros((n, NA)), beta[1:]
    for t in range(t0, n):
        if alarma <= t < alarma + espera:
            b = np.zeros(6)
        elif t >= alarma + espera and (t in reentreno
                                       or t == alarma + espera):
            b = ols(alarma, t - 1)
        P[t] = X[t] @ b
    with np.errstate(invalid="ignore"):   # apagado: P = 0, IC 0/0
        v = ic_diario(P[t0:], Y[t0:])
    return pd.Series(np.nan_to_num(v), index=fechas[t0:])

for espera in [21, 63, 126]:
    v = tras_alarma(espera)
    print(f"apagado {espera:3d} sesiones: IC 2017-2025 "
          f"{v.mean():.3f} · 2019-S2 a 2020 "
          f"{v['2019-07':'2020-12'].mean():.3f}")
OUT [10]Apagar y reentrenar tras la alarma
alarma del CUSUM (h = 2): 2019-07-19
apagado  21 sesiones: IC 2017-2025 0.103 · 2019-S2 a 2020 0.075
apagado  63 sesiones: IC 2017-2025 0.101 · 2019-S2 a 2020 0.063
apagado 126 sesiones: IC 2017-2025 0.100 · 2019-S2 a 2020 0.054
Fig. — IC medio de cada esquema en los 18 meses siguientes al cambio de concepto de julio de 2019 (walk-forward con reentreno mensual; el oráculo usa los coeficientes verdaderos)

Con 21 sesiones apagado, el IC de los 18 meses posteriores al cambio es 0,075, mejor que cualquier ventana fija y a 0,016 del oráculo. Esperar más cuesta: con 126 sesiones apagado baja a 0,054, porque apagar también apaga f2 y f3, que seguían funcionando. Apagar es una medida de emergencia (no operar con un modelo del que se sospecha), no una solución: el valor está en volver pronto con un modelo estimado con datos del régimen nuevo.

IN [11]python
movil = {fechas[t].date(): ols(t - 252, t) * 1e4
         for t in sorted(reentreno)}
movil = pd.DataFrame(movil, index=F).T
verdad = b_verdad.set_axis(F, axis=1).set_index(fechas) * 100
fechas_ver = ["2019-07-01", "2019-10-01", "2020-01-02",
              "2022-07-01", "2023-01-03", "2023-07-03", "2024-07-01"]
comp = pd.DataFrame({
    f: {"b1 móvil": movil.loc[pd.Timestamp(f).date(), "f1"],
        "b1 verdad": verdad.loc[f, "f1"],
        "b2 móvil": movil.loc[pd.Timestamp(f).date(), "f2"],
        "b2 verdad": verdad.loc[f, "f2"],
        "b5 móvil": movil.loc[pd.Timestamp(f).date(), "f5"],
        "b5 verdad": verdad.loc[f, "f5"]} for f in fechas_ver}).T
comp.round(1)
OUT [11]Coeficientes de la ventana móvil frente a la verdad
            b1 móvil  b1 verdad  b2 móvil  b2 verdad  b5 móvil  b5 verdad
2019-07-01       7.9       -8.0       4.0        5.0       0.9        0.0
2019-10-01       2.4       -8.0       4.9        5.0       1.5        0.0
2020-01-02      -0.5       -8.0       4.7        5.0       0.9        0.0
2022-07-01      -7.2       -8.0       2.6        5.0       1.3        0.0
2023-01-03      -7.9       -8.0       2.2        2.4       3.0        3.1
2023-07-03      -8.2       -8.0       1.8        0.0       4.3        6.0
2024-07-01     -10.1       -8.0      -1.0        0.0       6.6        6.0

La tabla muestra los límites de la ventana móvil de 252 sesiones con los coeficientes verdaderos. En julio de 2019 el modelo aún cree en el efecto viejo (7,9 frente a −8,0), en octubre va por 2,4 y en enero de 2020, por −0,5. En la deriva gradual, el coeficiente de f2 baja (2,6 en julio de 2022, 1,8 en julio de 2023, cuando la verdad ya es 0) y el de f5 sube (de 1,3 a 4,3, frente a 0 y 6): la ventana de un año va con unos seis meses de retraso, que es lo que cuesta promediar un año de datos ruidosos.

SituaciónRespuesta razonable
PSI o AUC altos, rendimiento normalinvestigar (¿error de datos?), no reentrenar por reflejo
Rendimiento cae, features establesderiva de concepto: apagar o reducir, reentrenar con datos recientes
Cambio gradualventana móvil o pesos con semivida del orden del cambio
Régimen recurrentemodelar el régimen como feature, no perseguirlo reentrenando
Cualquierael procedimiento completo (detector, umbral, respuesta) fijado de antemano y validado en walk-forward

Ejercicios

  1. La deriva de datos de 2017 es un covariate shift de manual. La corrección clásica es reponderar el entrenamiento por el cociente de densidades Pactual(x)/Pbase(x)P_{\text{actual}}(x)/P_{\text{base}}(x), que se estima con el clasificador adversario como p/(1−p)p/(1-p). Estima el OLS de 2014-2016 con esos pesos (actual = 2017-2019-S1) y compara coeficientes e IC con el modelo fijo sin pesos.
Solución
IN [12]python
actual = (fechas >= "2017-01-01") & (fechas < "2019-07-01")
A = np.vstack([X[base].reshape(-1, 6), X[actual].reshape(-1, 6)])
c = np.r_[np.zeros(base.sum() * NA), np.ones(actual.sum() * NA)]
clf = lgb.LGBMClassifier(
    n_estimators=60, num_leaves=8, min_child_samples=100,
    random_state=0, n_jobs=4, deterministic=True,
    force_row_wise=True, verbose=-1).fit(A, c)
pb = clf.predict_proba(X[base].reshape(-1, 6))[:, 1]
w = pb / (1 - pb)
w = w / w.mean()
sw = np.sqrt(w)
beta_w = np.linalg.lstsq(X1 * sw[:, None], Y[base].ravel() * sw,
                         rcond=None)[0]
print("sin pesos (pb):", np.round(beta[1:] * 1e4, 1))
print("con pesos (pb):", np.round(beta_w[1:] * 1e4, 1))
print(f"tamaño efectivo: {w.sum()**2 / (w**2).sum() / w.size:.0%} "
      "de las filas")
for nombre, b in [("sin pesos", beta), ("con pesos", beta_w)]:
    v = ic_diario(X[actual] @ b[1:], Y[actual]).mean()
    print(f"IC 2017-2019-S1 {nombre}: {v:.3f}")
OUT [12]Reponderar por el cociente de densidades
sin pesos (pb): [ 8.9  6.1 -4.5 -0.  -0.   1. ]
con pesos (pb): [ 5.5  4.4 -4.3  0.9 -2.   1.2]
tamaño efectivo: 13% de las filas
IC 2017-2019-S1 sin pesos: 0.117
IC 2017-2019-S1 con pesos: 0.112

Los pesos no ayudan, perjudican. Para que 2014-2016 se parezca a 2017-2019, el clasificador da mucho peso a las pocas filas de la base con f1 alta y f3 extrema, y el tamaño efectivo de la muestra cae al 13 % de las filas. Los coeficientes se vuelven más ruidosos (el de f1 baja de 8,9 a 5,5, el de f5 sale en −2,0 sin efecto verdadero) y el IC en 2017-2019-S1 baja de 0,117 a 0,112. La reponderación corrige el sesgo de un modelo mal especificado que extrapola a una zona poco poblada del entrenamiento; con un modelo lineal correcto, como aquí, el OLS sin pesos ya es insesgado en cualquier zona y los pesos solo añaden varianza. Y no sirve de nada ante la deriva de concepto: si P(y∣X)P(y \mid X) cambia, ningún peso sobre el pasado enseña la relación nueva.

  1. El CUSUM vigilaba el modelo fijo. Vigila ahora el modelo de ventana móvil de 252 sesiones con el mismo detector (k=0,06k = 0{,}06, h=2h = 2, μ0\mu_0 = su IC medio en 2017-2018). ¿Cuándo avisa y qué pasa en la deriva gradual?
Solución
IN [13]python
ic_movil = pd.Series(ic_diario(pred["móvil 252"][t0:], Y[t0:]),
                     index=fechas[t0:])
mu_m = ic_movil[:"2018-12-31"].mean()
al = cusum(ic_movil.to_numpy(), mu_m, 0.06, 2.0)
print(f"IC medio 2017-2018: {mu_m:.3f}")
print("alarmas:", [str(f.date()) for f in ic_movil.index[al]])
print(ic_movil.groupby(ic_movil.index.year).mean().round(3)
      .to_string())
OUT [13]CUSUM sobre el modelo que se reentrena
IC medio 2017-2018: 0.112
alarmas: ['2019-07-19', '2019-08-23', '2019-11-26']
2017    0.112
2018    0.112
2019    0.058
2020    0.084
2021    0.097
2022    0.105
2023    0.097
2024    0.133
2025    0.115

El CUSUM del modelo móvil también avisa el 2019-07-19 y otra vez el 2019-08-23 y el 2019-11-26: la ventana de un año tarda unos seis meses en cambiar de signo, y mientras tanto su IC cae (0,058 de media en 2019). En la deriva gradual de 2022-2023 no avisa: la ventana sigue el cambio con su retraso y el IC anual se queda entre 0,097 y 0,105. Dos lecciones: un modelo que se reentrena también necesita monitor, porque su adaptación tiene un retraso que puede costar meses; y después de cada reentreno o cambio de modelo hay que recalibrar μ0\mu_0 y reiniciar el detector, o las alarmas medirán el modelo viejo.

  1. Diseña la vigilancia de un modelo semanal de selección de acciones cuya etiqueta es el rendimiento a 21 sesiones (solapada, como ret_sig_21d del panel). ¿Qué vigilas cada día, qué cada semana y cómo calibras los umbrales?
Solución

Las etiquetas a 21 sesiones tardan un mes en conocerse, así que el rendimiento solo se puede vigilar con un mes de retraso añadido al del detector. Mientras tanto, cada día se vigilan las features y las predicciones: PSI de cada feature y de la distribución de las predicciones frente al entrenamiento, y una validación adversaria semanal con pliegues de semanas enteras; sirven para cazar errores de datos y cambios bruscos de entorno, no para decidir que el modelo ha dejado de funcionar. El rendimiento se vigila con el IC semanal de las etiquetas ya terminadas; como se solapan, conviene usar solo semanas que no se solapen o un CUSUM sobre IC de bloques de cuatro semanas. Los umbrales (hh, la ventana) se calibran con una simulación por bloques sobre un periodo sin cambios conocidos, fijando antes cuántas falsas alarmas al año se aceptan, y el procedimiento completo (detector, umbral, apagado, reentreno) se evalúa en un walk-forward antes de usarlo. Y con varias señales vigiladas, la tasa de falsas alarmas se cuenta para el conjunto (lección 2.1.8).

Respuesta plegada en cada pregunta

Autoevaluación

  1. En 2017 el PSI de f1 sube a 0,97 y el de f3 a 0,50, y el IC del modelo fijo pasa de 0,120 a 0,117. ¿Qué indica?

    • AQue el modelo está roto y hay que reentrenarlo cuanto antes
    • BQue el PSI está mal calculado, porque un PSI tan alto siempre degrada el modelo
    • CUna deriva de datos que no toca la relación con el objetivo: la distribución de las features cambió, pero el modelo sigue ordenando igual de bien
    • DUna deriva de concepto, porque el PSI pasa de 0,25
    Ver respuesta

    Respuesta C. Una deriva de datos que no toca la relación con el objetivo: la distribución de las features cambió, pero el modelo sigue ordenando igual de bien

    El PSI mide si cambia la distribución de una feature, no si cambia su relación con el objetivo. Aquí la media de f1 sube una desviación y la dispersión de f3 se duplica, pero el efecto de cada feature es el mismo. Una alarma de PSI es una señal para investigar, no para reentrenar.

  2. En julio de 2019 el efecto de f1 cambia de signo. ¿Qué detecta el cambio?

    • ASolo lo que mira el rendimiento del modelo (su IC, sus errores), porque las features no cambian de distribución: el PSI y la validación adversaria quedan en 0,00 y 0,52
    • BEl PSI de f1, que sube por encima de 0,25
    • CEl KS de f1, con p < 0,05
    • DLa importancia SHAP de f1, que baja a cero
    Ver respuesta

    Respuesta A. Solo lo que mira el rendimiento del modelo (su IC, sus errores), porque las features no cambian de distribución: el PSI y la validación adversaria quedan en 0,00 y 0,52

    Es deriva de concepto pura: P(X) no cambia y P(y|X) sí. Ninguna medida sobre las features puede verla. Solo aparece en los resultados, y por eso tarde: hacen falta etiquetas nuevas para notar que el modelo falla.

  3. Con 72 pruebas KS (12 meses de 2016 × 6 features) sin ninguna deriva plantada, el 33 % sale con p < 0,05. ¿Por qué?

    • APorque 2016 tiene una deriva oculta
    • BPorque el KS no sirve para variables continuas
    • CPorque los datos son de 30 activos
    • DPorque el KS supone observaciones independientes y las features son autocorrelacionadas (AR(1) de 0,5): con miles de filas dependientes, sus p-valores salen demasiado pequeños
    Ver respuesta

    Respuesta D. Porque el KS supone observaciones independientes y las features son autocorrelacionadas (AR(1) de 0,5): con miles de filas dependientes, sus p-valores salen demasiado pequeños

    Con dependencia, el tamaño efectivo de la muestra es mucho menor que el número de filas y el contraste es demasiado liberal. Además, con muestras grandes cualquier diferencia minúscula sale significativa. Mejor un tamaño del efecto (el estadístico D, el PSI) con un umbral calibrado en periodos sin cambios.

  4. Un CUSUM con h = 2 avisa 14 sesiones después del cambio de 2019 y la media móvil de 63 sesiones del IC, 43. En la simulación, el retraso mediano del CUSUM con h = 2 es de 23 sesiones. ¿Cómo se lee?

    • AEl CUSUM siempre tarda 14 sesiones
    • BLos 14 de los datos son una realización con suerte; el retraso típico, con 0,04 falsas alarmas al año, es de unas 23 sesiones. Un detector se juzga por su par de falsas alarmas y retraso en muchas realizaciones, no por un episodio
    • CLa simulación está mal, porque no coincide con los datos
    • DLa media móvil es mejor, porque no da falsas alarmas
    Ver respuesta

    Respuesta B. Los 14 de los datos son una realización con suerte; el retraso típico, con 0,04 falsas alarmas al año, es de unas 23 sesiones. Un detector se juzga por su par de falsas alarmas y retraso en muchas realizaciones, no por un episodio

    Un solo cambio es una sola muestra del retraso. La simulación con los IC de antes y después da la distribución; con el mismo número de falsas alarmas, el CUSUM llega unas pocas sesiones antes que una media móvil, no veinte.

  5. Tras el cambio de 2019, la ventana expansiva tiene un IC de 0,001 y la móvil de 252 sesiones, de 0,055. La lección 5.4.3 recomendaba la expansiva en el panel semanal. ¿Hay contradicción?

    • ASí: la lección 5.4.3 estaba equivocada
    • BSí: aquí hay una fuga en la ventana móvil
    • CNo: con una ruptura grande y permanente frente al ruido, la historia anterior enseña lo contrario de lo que ocurre y conviene olvidarla; con efectos pequeños que oscilan, como en el panel, usar todos los datos reduce más error del que añade
    • DNo: la ventana expansiva siempre gana a largo plazo
    Ver respuesta

    Respuesta C. No: con una ruptura grande y permanente frente al ruido, la historia anterior enseña lo contrario de lo que ocurre y conviene olvidarla; con efectos pequeños que oscilan, como en el panel, usar todos los datos reduce más error del que añade

    La ventana óptima depende del tamaño y la persistencia de los cambios frente al ruido. Aquí el coeficiente de f1 pasa de +8 a −8 pb y se queda ahí: la expansiva no lo refleja con el signo correcto hasta noviembre de 2024.

Ideas clave

  • Deriva de datos (cambia P(X)) y deriva de concepto (cambia P(y|X)) son problemas distintos. En los datos del nivel, las dos derivas de datos disparan el PSI (0,97 y 1,23) sin tocar el IC del modelo, y la de concepto lo hunde de 0,120 a −0,019 sin mover ningún PSI.
  • PSI, KS y validación adversaria vigilan las features. Necesitan umbrales calibrados sin cambios: el KS da un 33 % de falsas alarmas con features autocorrelacionadas, y el AUC adversario se calcula por pliegue y con pliegues de meses enteros.
  • La deriva que importa se vigila en el rendimiento. Un CUSUM sobre el IC diario con k = la mitad de la caída esperada y h = 2 avisa a los 14 días; la media móvil de 63 sesiones, a los 43. Un filtro de dos estados avisa el mismo día que el CUSUM.
  • Un detector se elige por su par de falsas alarmas y retraso medido por simulación. A igual tasa de falsas alarmas, el CUSUM gana unas pocas sesiones a la media móvil.
  • Ante una ruptura grande y permanente, la ventana expansiva tarda años en cambiar de signo. La móvil se adapta en unos meses. Reentrenar solo con datos posteriores a la alarma da el mejor IC tras el cambio (0,075 frente al 0,091 del oráculo).
  • Apagar es la respuesta de emergencia, no la solución: también apaga lo que sigue funcionando. Detectar, diagnosticar con el PSI y la validación adversaria y reentrenar es un procedimiento que se fija y se valida antes de usarlo.

Recursos

Datos