Prueba Técnica Tukan: la curva de rendimientos nominales de México

Estructura, pronóstico y reducción de dimensionalidad con tasas de subasta de Banxico

Author

Emiliano Montalvo Vásquez

0. Pregunta, objetivo de pronóstico y conjunto de información

El ejercicio plantea tres preguntas encadenadas:

  1. P1. ¿Existe estructura común en cómo se mueven las nueve maduraciones?
  2. P2. ¿Podemos pronosticar la curva a uno y cuatro trimestres?
  3. P3. ¿La estructura de P1 mejora los pronósticos de P2?

Definiciones que usamos en todo el documento

  • Dato: tasa promedio anualizada ponderada de subasta primaria del mes \(m\) para el instrumento \(j\), \(y_{j,m}\). No son cotizaciones simultáneas de mercado secundario.
  • Origen de pronóstico \(t\): el cierre del mes \(t\). En ese momento ya se conocen todas las subastas del mes \(t\) y anteriores.
  • Conjunto de información en \(t\): \(\mathcal{I}_t = \{y_{j,m}: m \le t \text{ y } j \text{ subastado en } m\}\). No usamos datos externos.
  • Uno y cuatro trimestres adelante: \(h = 3\) y \(h = 12\) meses. El objetivo es la tasa promedio de subasta del mes \(t+h\), \(y_{j,t+h}\), para cada uno de los 9 instrumentos (no el promedio del trimestre).
  • Evaluación: solo contra valores observados de \(y_{j,t+h}\); nunca contra valores rellenados.

1. Auditoría de datos

Objetivo. Verificar estructura, duplicados, NA, fechas y cobertura de cada instrumento antes de tocar los datos. Justificación: el PCA, los VAR y los modelos de ML requieren un panel balanceado, y la forma de llegar a él depende de por qué faltan observaciones.

Mostrar código
archivo   <- "banxico_auction_rates_monthly.csv"
fecha_ini <- as.Date("2006-10-01")
fecha_fin <- as.Date("2026-09-01")
meses     <- seq(fecha_ini, fecha_fin, by = "month")
N         <- length(meses)

# Catálogo de instrumentos: nombre exacto en la base, código corto y plazo en años
instrumentos <- tibble::tribble(
  ~INSTRUMENT,         ~codigo, ~plazo_anios, ~familia,
  "CETEs, 28 days",    "c28",   28/364,       "CETEs",
  "CETEs, 91 days",    "c91",   91/364,       "CETEs",
  "CETEs, 182 days",   "c182",  182/364,      "CETEs",
  "CETEs, 364 days",   "c364",  364/364,      "CETEs",
  "MBONOs, 3 years",   "m3",    3,            "MBONOs",
  "MBONOs, 5 years",   "m5",    5,            "MBONOs",
  "MBONOs, 10 years",  "m10",   10,           "MBONOs",
  "MBONOs, 20 years",  "m20",   20,           "MBONOs",
  "MBONOs, 30 years",  "m30",   30,           "MBONOs"
)
codigos <- instrumentos$codigo
plazos  <- setNames(instrumentos$plazo_anios, codigos)
p       <- length(codigos)

datos_raw <- readr::read_csv(archivo, show_col_types = FALSE)
glimpse(datos_raw)
Rows: 1,936
Columns: 4
$ DATE       <date> 2006-10-01, 2006-10-01, 2006-10-01, 2006-10-01, 2006-10-01…
$ INSTRUMENT <chr> "CETEs, 182 days", "CETEs, 28 days", "CETEs, 364 days", "CE…
$ INDICATOR  <chr> "Annualized monthly average rate", "Annualized monthly aver…
$ VALUE_PCT  <dbl> 7.42, 7.05, 7.50, 7.21, 8.40, 7.62, 8.08, 7.88, 7.26, 7.04,…

1.1 Chequeos de integridad

Mostrar código
chequeos <- tibble::tibble(
  chequeo = c("Filas", "Columnas",
              "Indicadores distintos (INDICATOR)",
              "Instrumentos distintos",
              "Instrumentos no esperados",
              "Instrumentos esperados ausentes",
              "NA en DATE / INSTRUMENT / VALUE_PCT",
              "Duplicados DATE-INSTRUMENT",
              "Fechas que no son día 1",
              "Fechas fuera del periodo declarado",
              "Fecha mínima", "Fecha máxima",
              "Valores <= 0 o > 30 %"),
  resultado = c(
    nrow(datos_raw), ncol(datos_raw),
    paste(unique(datos_raw$INDICATOR), collapse = " | "),
    n_distinct(datos_raw$INSTRUMENT),
    sum(!unique(datos_raw$INSTRUMENT) %in% instrumentos$INSTRUMENT),
    sum(!instrumentos$INSTRUMENT %in% unique(datos_raw$INSTRUMENT)),
    paste(sum(is.na(datos_raw$DATE)), sum(is.na(datos_raw$INSTRUMENT)),
          sum(is.na(datos_raw$VALUE_PCT)), sep = " / "),
    sum(duplicated(datos_raw[, c("DATE", "INSTRUMENT")])),
    sum(lubridate::day(datos_raw$DATE) != 1),
    sum(datos_raw$DATE < fecha_ini | datos_raw$DATE > fecha_fin),
    as.character(min(datos_raw$DATE)), as.character(max(datos_raw$DATE)),
    sum(datos_raw$VALUE_PCT <= 0 | datos_raw$VALUE_PCT > 30, na.rm = TRUE)
  )
)
kbl(chequeos)
chequeo resultado
Filas 1936
Columnas 4
Indicadores distintos (INDICATOR) Annualized monthly average rate
Instrumentos distintos 9
Instrumentos no esperados 0
Instrumentos esperados ausentes 0
NA en DATE / INSTRUMENT / VALUE_PCT 0 / 0 / 0
Duplicados DATE-INSTRUMENT 0
Fechas que no son día 1 0
Fechas fuera del periodo declarado 0
Fecha mínima 2006-10-01
Fecha máxima 2026-09-01
Valores <= 0 o > 30 % 0

1.2 Cobertura por instrumento

Comparamos las observaciones con el calendario completo de 240 meses × 9 instrumentos = 2160 combinaciones.

Mostrar código
panel_largo <- tidyr::expand_grid(DATE = meses, INSTRUMENT = instrumentos$INSTRUMENT) |>
  left_join(dplyr::select(datos_raw, DATE, INSTRUMENT, VALUE_PCT),
            by = c("DATE", "INSTRUMENT")) |>
  left_join(instrumentos, by = "INSTRUMENT") |>
  mutate(codigo = factor(codigo, levels = codigos),
         observado = !is.na(VALUE_PCT))

cobertura <- panel_largo |>
  group_by(INSTRUMENT, codigo) |>
  summarise(obs_validas = sum(observado),
            primera     = min(DATE[observado]),
            ultima      = max(DATE[observado]),
            meses_ausentes = sum(!observado),
            ausentes_internos = sum(!observado & DATE > primera & DATE < ultima),
            ausentes_borde    = meses_ausentes - ausentes_internos,
            pct_cobertura     = 100 * obs_validas / n()) |>
  ungroup() |>
  arrange(codigo)
kbl(cobertura, digits = 1)
INSTRUMENT codigo obs_validas primera ultima meses_ausentes ausentes_internos ausentes_borde pct_cobertura
CETEs, 28 days c28 240 2006-10-01 2026-09-01 0 0 0 100.0
CETEs, 91 days c91 240 2006-10-01 2026-09-01 0 0 0 100.0
CETEs, 182 days c182 240 2006-10-01 2026-09-01 0 0 0 100.0
CETEs, 364 days c364 240 2006-10-01 2026-09-01 0 0 0 100.0
MBONOs, 3 years m3 236 2006-10-01 2026-09-01 4 4 0 98.3
MBONOs, 5 years m5 224 2006-10-01 2026-09-01 16 16 0 93.3
MBONOs, 10 years m10 177 2006-10-01 2026-09-01 63 63 0 73.8
MBONOs, 20 years m20 171 2006-11-01 2026-08-01 69 67 2 71.2
MBONOs, 30 years m30 168 2006-10-01 2026-09-01 72 72 0 70.0

1.3 Rachas de meses ausentes (huecos)

Un hueco es una racha de meses consecutivos sin observación.

Mostrar código
rachas <- panel_largo |>
  arrange(codigo, DATE) |>
  group_by(codigo) |>
  mutate(id_racha = cumsum(observado != dplyr::lag(observado, default = first(observado)))) |>
  dplyr::filter(!observado) |>
  group_by(codigo, id_racha) |>
  summarise(inicio = min(DATE), fin = max(DATE), meses = n()) |>
  ungroup()

resumen_huecos <- tibble::tibble(codigo = factor(codigos, levels = codigos)) |>
  left_join(rachas |>
              group_by(codigo) |>
              summarise(numero_huecos = n(),
                        meses_ausentes = sum(meses),
                        hueco_maximo_meses = max(meses),
                        huecos_1_mes = sum(meses == 1),
                        huecos_2_meses = sum(meses == 2),
                        huecos_3omas_meses = sum(meses >= 3)),
            by = "codigo") |>
  mutate(across(-codigo, ~ tidyr::replace_na(.x, 0L)))
kbl(resumen_huecos)
codigo numero_huecos meses_ausentes hueco_maximo_meses huecos_1_mes huecos_2_meses huecos_3omas_meses
c28 0 0 0 0 0 0
c91 0 0 0 0 0 0
c182 0 0 0 0 0 0
c364 0 0 0 0 0 0
m3 4 4 1 4 0 0
m5 8 16 3 4 0 4
m10 58 63 4 56 0 2
m20 67 69 3 66 0 1
m30 64 72 3 58 4 2

Fechas exactas de los meses ausentes

Mostrar código
panel_largo |>
  dplyr::filter(!observado) |>
  group_by(codigo) |>
  summarise(n = n(), meses_ausentes = paste(format(DATE, "%Y-%m"), collapse = ", ")) |>
  kbl()
codigo n meses_ausentes
m3 4 2022-07, 2025-09, 2026-02, 2026-05
m5 16 2010-07, 2010-08, 2010-09, 2011-07, 2011-08, 2011-09, 2012-07, 2012-08, 2012-09, 2013-07, 2013-08, 2013-09, 2014-11, 2022-04, 2022-11, 2024-04
m10 63 2007-10, 2008-02, 2008-05, 2008-09, 2009-01, 2009-04, 2009-08, 2009-11, 2010-01, 2010-03, 2010-06, 2010-10, 2011-01, 2011-02, 2011-03, 2011-05, 2011-09, 2012-01, 2012-02, 2012-03, 2012-04, 2012-08, 2012-12, 2013-03, 2013-07, 2013-11, 2014-02, 2014-06, 2014-09, 2015-01, 2015-05, 2015-08, 2015-10, 2015-12, 2016-04, 2016-07, 2016-11, 2017-02, 2017-06, 2017-10, 2018-01, 2018-05, 2018-09, 2018-12, 2019-04, 2019-07, 2019-11, 2020-03, 2020-06, 2020-10, 2021-02, 2021-05, 2021-09, 2022-01, 2022-08, 2023-03, 2023-07, 2023-10, 2024-02, 2024-06, 2025-03, 2026-03, 2026-06
m20 69 2006-10, 2007-02, 2007-05, 2007-09, 2007-12, 2008-04, 2008-08, 2008-11, 2009-03, 2009-06, 2009-10, 2010-02, 2010-05, 2010-07, 2010-09, 2011-01, 2011-04, 2011-07, 2011-08, 2011-09, 2011-11, 2012-03, 2012-07, 2012-10, 2013-02, 2013-06, 2013-09, 2014-01, 2014-05, 2014-08, 2014-12, 2015-03, 2015-07, 2015-11, 2016-02, 2016-06, 2016-10, 2017-01, 2017-05, 2017-09, 2017-12, 2018-04, 2018-07, 2018-11, 2019-03, 2019-06, 2019-10, 2020-02, 2020-05, 2020-09, 2021-01, 2021-04, 2021-08, 2021-11, 2022-05, 2023-02, 2023-05, 2023-09, 2024-01, 2024-05, 2024-07, 2024-09, 2024-12, 2025-02, 2025-05, 2025-07, 2025-11, 2026-03, 2026-09
m30 72 2006-11, 2006-12, 2007-01, 2007-03, 2007-04, 2007-06, 2007-07, 2007-11, 2008-03, 2008-06, 2008-10, 2009-02, 2009-05, 2009-09, 2010-01, 2010-04, 2010-07, 2010-08, 2010-11, 2011-03, 2011-07, 2011-10, 2012-02, 2012-04, 2012-05, 2012-06, 2012-09, 2013-01, 2013-04, 2013-08, 2013-12, 2014-03, 2014-07, 2014-11, 2015-02, 2015-06, 2015-09, 2016-01, 2016-05, 2016-08, 2016-12, 2017-04, 2017-07, 2017-11, 2018-02, 2018-06, 2018-10, 2019-02, 2019-05, 2019-09, 2019-12, 2020-04, 2020-08, 2020-11, 2021-02, 2021-03, 2021-06, 2021-10, 2022-02, 2022-10, 2023-01, 2023-04, 2023-08, 2023-12, 2024-03, 2024-08, 2024-11, 2025-01, 2025-06, 2025-08, 2026-01, 2026-08
Mostrar código
ggplot(panel_largo, aes(DATE, forcats::fct_rev(codigo), fill = observado)) +
  geom_tile(colour = "white", linewidth = 0.1) +
  scale_fill_manual(values = c(`TRUE` = "#1f6f8b", `FALSE` = "grey85"),
                    labels = c(`TRUE` = "Observado", `FALSE` = "Ausente"), name = NULL) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(x = NULL, y = NULL) +
  theme(legend.position = "top")
Figure 1: Disponibilidad mensual por instrumento (gris = sin subasta registrada).

1.4 ¿Los huecos de los bonos largos siguen un calendario?

Si los MBONOs de 10, 20 y 30 años se subastan en rotación, en casi todos los meses debería haber al menos uno de ellos y rara vez los tres a la vez.

Mostrar código
patron <- panel_largo |>
  dplyr::filter(codigo %in% c("m10", "m20", "m30")) |>
  group_by(DATE) |>
  summarise(n_largos = sum(observado),
            combinacion = paste(as.character(codigo)[observado], collapse = "+")) |>
  mutate(combinacion = if_else(combinacion == "", "(ninguno)", combinacion))

kbl(count(patron, n_largos, name = "meses"))
n_largos meses
1 11
2 182
3 47
Mostrar código
kbl(count(patron, combinacion, name = "meses", sort = TRUE))
combinacion meses
m10+m20 65
m10+m30 63
m20+m30 54
m10+m20+m30 47
m20 5
m30 4
m10 2
Mostrar código
meses_completos <- panel_largo |>
  group_by(DATE) |>
  summarise(completo = all(observado)) |>
  dplyr::filter(completo) |>
  pull(DATE)

Meses con las 9 tasas observadas simultáneamente: 40 de 240.

Lectura de la auditoría. Los CETEs se subastan cada mes. Los MBONOs, sobre todo 10, 20 y 30 años, no aparecen todos los meses, y sus ausencias son casi siempre de un mes. El patrón de combinaciones muestra si responden a una rotación en el calendario de subastas. Si es así, el dato faltante significa “ese mes no hubo subasta de ese plazo”: no es un error de captura, y la tasa de mercado de ese plazo existía aunque no la observemos. Esto descarta eliminar meses incompletos (nos quedaríamos con 40 observaciones no consecutivas) y descarta interpolar linealmente en el tiempo (usaría el dato del mes siguiente, información futura).

1.5 Tratamiento de faltantes: relleno causal por diferencial

Decisión. Para construir un panel balanceado, cada celda ausente \((j,t)\) se rellena con información disponible al cierre de \(t\):

\[\tilde y_{j,t} = y_{k,t} + \left(y_{j,s} - y_{k,s}\right),\]

donde \(k\) es el instrumento observado en \(t\) con plazo más cercano a \(j\) (en log-plazo), y \(s<t\) es el último mes en que \(j\) y \(k\) se observaron juntos. Es decir, la curva se mueve con su vecino y el diferencial se mantiene en su último valor conocido. Si no existe ningún vecino utilizable, se usa el último valor observado de \(j\) (LOCF).

Por qué así:

  1. Es causal: solo usa datos de \(t\) y anteriores. Por eso rellenar el panel una sola vez equivale a rellenarlo en cada origen del backtest, sin fuga de información.
  2. Aprovecha la información contemporánea de la curva: si el 30 años no se subastó, el 20 o el 10 sí suelen haberse subastado ese mes.
  3. Es transparente: guardamos qué método llenó cada celda y la antigüedad del diferencial usado.

La única excepción es una celda sin historia previa (el primer mes de la muestra), que se rellena con el primer diferencial posterior. Ese valor queda años antes del primer origen de pronóstico, así que no afecta a ningún conjunto de información de evaluación.

Las tasas rellenadas se usan solo como insumo (features, PCA, estimación). Nunca se usan como objetivo de evaluación.

Mostrar código
Yobs <- panel_largo |>
  dplyr::select(DATE, codigo, VALUE_PCT) |>
  tidyr::pivot_wider(names_from = codigo, values_from = VALUE_PCT) |>
  arrange(DATE) |>
  dplyr::select(all_of(codigos)) |>
  as.matrix()
rownames(Yobs) <- format(meses, "%Y-%m")

# Imputa la celda (t, j) usando únicamente información de meses <= t.
# 'permitir_futuro' solo se usa para celdas sin ninguna historia previa.
imputar_celda <- function(Y, t, j, permitir_futuro = FALSE) {
  obs_t <- setdiff(which(!is.na(Y[t, ])), j)
  refs  <- obs_t[order(abs(log(plazos[obs_t]) - log(plazos[j])))]
  previos <- seq_len(t - 1)
  for (k in refs) {
    s_ok <- previos[!is.na(Y[previos, j]) & !is.na(Y[previos, k])]
    if (length(s_ok) > 0) {
      s <- max(s_ok)
      return(list(valor = Y[t, k] + (Y[s, j] - Y[s, k]),
                  metodo = paste0("diferencial vs ", colnames(Y)[k]),
                  antiguedad = t - s))
    }
  }
  prev_j <- previos[!is.na(Y[previos, j])]
  if (length(prev_j) > 0) {
    s <- max(prev_j)
    return(list(valor = Y[s, j], metodo = "LOCF", antiguedad = t - s))
  }
  if (permitir_futuro) {  # solo primer mes de la muestra
    for (k in refs) {
      s_ok <- which(!is.na(Y[, j]) & !is.na(Y[, k]))
      if (length(s_ok) > 0) {
        s <- min(s_ok)
        return(list(valor = Y[t, k] + (Y[s, j] - Y[s, k]),
                    metodo = paste0("diferencial (posterior) vs ", colnames(Y)[k]),
                    antiguedad = t - s))
      }
    }
  }
  list(valor = NA_real_, metodo = NA_character_, antiguedad = NA_integer_)
}

Yf <- Yobs
bitacora_relleno <- list()
for (t in seq_len(N)) {
  for (j in which(is.na(Yobs[t, ]))) {
    r <- imputar_celda(Yobs, t, j, permitir_futuro = TRUE)
    Yf[t, j] <- r$valor
    bitacora_relleno[[length(bitacora_relleno) + 1]] <-
      tibble::tibble(DATE = meses[t], codigo = codigos[j], valor = r$valor,
                     metodo = r$metodo, antiguedad_meses = r$antiguedad)
  }
}
bitacora_relleno <- bind_rows(bitacora_relleno)

stopifnot(!anyNA(Yf))
kbl(count(bitacora_relleno, codigo, metodo, name = "celdas"))
codigo metodo celdas
m10 diferencial vs m20 59
m10 diferencial vs m30 1
m10 diferencial vs m5 3
m20 diferencial (posterior) vs m30 1
m20 diferencial vs m10 4
m20 diferencial vs m30 64
m3 diferencial vs m5 4
m30 diferencial vs m10 9
m30 diferencial vs m20 63
m5 diferencial vs m3 16
Mostrar código
kbl(bitacora_relleno |>
      group_by(codigo) |>
      summarise(celdas = n(),
                antiguedad_mediana = median(antiguedad_meses),
                antiguedad_max = max(antiguedad_meses)))
codigo celdas antiguedad_mediana antiguedad_max
m10 63 2.0 5
m20 69 2.0 6
m3 4 1.0 1
m30 72 1.0 5
m5 16 1.5 3

Validación del relleno

¿El relleno por diferencial es mejor que simplemente repetir la última subasta (LOCF)? Para cada valor observado de un MBONO aplicamos ambos métodos como si faltara (con información hasta ese mes) y comparamos con el valor real.

Mostrar código
val_relleno <- list()
for (j in which(codigos %in% c("m3", "m5", "m10", "m20", "m30"))) {
  for (t in which(!is.na(Yobs[, j]))) {
    if (t == 1) next
    r <- imputar_celda(Yobs, t, j)
    prev <- which(!is.na(Yobs[seq_len(t - 1), j]))
    if (is.na(r$valor) || length(prev) == 0) next
    val_relleno[[length(val_relleno) + 1]] <- tibble::tibble(
      codigo = codigos[j], real = Yobs[t, j],
      err_diferencial = Yobs[t, j] - r$valor,
      err_locf = Yobs[t, j] - Yobs[max(prev), j])
  }
}
val_relleno <- bind_rows(val_relleno)
kbl(val_relleno |>
      group_by(codigo = factor(codigo, levels = codigos)) |>
      summarise(n = n(),
                MAE_diferencial = mean(abs(err_diferencial)),
                MAE_LOCF = mean(abs(err_locf)),
                RMSE_diferencial = sqrt(mean(err_diferencial^2)),
                RMSE_LOCF = sqrt(mean(err_locf^2))))
codigo n MAE_diferencial MAE_LOCF RMSE_diferencial RMSE_LOCF
m3 235 0.248 0.230 0.345 0.323
m5 223 0.257 0.284 0.355 0.384
m10 176 0.285 0.328 0.401 0.451
m20 170 0.274 0.317 0.403 0.437
m30 167 0.322 0.317 0.454 0.455
Mostrar código
as_tibble(Yf) |>
  mutate(DATE = meses) |>
  pivot_longer(-DATE, names_to = "codigo", values_to = "rellenado") |>
  left_join(as_tibble(Yobs) |> mutate(DATE = meses) |>
              pivot_longer(-DATE, names_to = "codigo", values_to = "observado"),
            by = c("DATE", "codigo")) |>
  dplyr::filter(codigo %in% c("m10", "m20", "m30")) |>
  ggplot(aes(DATE)) +
  geom_line(aes(y = rellenado), colour = "grey50") +
  geom_point(aes(y = observado), size = 0.7, colour = "#1f6f8b") +
  facet_wrap(~ factor(codigo, levels = codigos), ncol = 1) +
  labs(x = NULL, y = "%")
Figure 2: MBONOs largos: valores observados (puntos) y panel rellenado (línea).

2. P1: estructura común de la curva

Objetivo. Ver si las nueve tasas comparten unos pocos factores comunes. Método: correlaciones (niveles y cambios mensuales), diagnóstico de dependencia lineal y PCA en niveles y en primeras diferencias. Precauciones: (i) las correlaciones se calculan con los datos observados (pares completos), no con el panel rellenado; (ii) el relleno induce co-movimiento artificial, así que el PCA del panel rellenado se contrasta con un PCA-EM que estima los componentes ignorando los faltantes y con un PCA de los meses completos.

Mostrar código
panel_largo |>
  ggplot(aes(DATE, VALUE_PCT, colour = codigo)) +
  geom_line(na.rm = TRUE, linewidth = 0.4) +
  scale_colour_viridis_d(option = "turbo", name = NULL) +
  labs(x = NULL, y = "%")
Figure 3: Las nueve tasas de subasta (observadas).

2.1 Correlaciones

Mostrar código
dYobs <- diff(Yobs)  # NA si alguno de los dos meses no fue observado
cor_niv <- cor(Yobs,  use = "pairwise.complete.obs")
cor_dif <- cor(dYobs, use = "pairwise.complete.obs")
n_pares_dif <- crossprod(!is.na(dYobs))

graf_cor <- function(M, titulo) {
  as_tibble(M, rownames = "a") |>
    pivot_longer(-a, names_to = "b", values_to = "r") |>
    mutate(a = factor(a, levels = codigos), b = factor(b, levels = rev(codigos))) |>
    ggplot(aes(a, b, fill = r)) +
    geom_tile() +
    geom_text(aes(label = sprintf("%.2f", r)), size = 2.8) +
    scale_fill_gradient2(limits = c(-1, 1), low = "#b2182b", mid = "white", high = "#2166ac") +
    labs(x = NULL, y = NULL, title = titulo) +
    coord_equal()
}
graf_cor(cor_niv, "Correlación de niveles (pares observados)")

Mostrar código
graf_cor(cor_dif, "Correlación de cambios mensuales (pares observados)")

Mostrar código
kbl(n_pares_dif, digits = 0, caption = "Número de pares de cambios mensuales observados")
Número de pares de cambios mensuales observados
c28 c91 c182 c364 m3 m5 m10 m20 m30
c28 239 239 239 239 231 215 118 105 103
c91 239 239 239 239 231 215 118 105 103
c182 239 239 239 239 231 215 118 105 103
c364 239 239 239 239 231 215 118 105 103
m3 231 231 231 231 231 207 113 98 97
m5 215 215 215 215 207 215 102 98 94
m10 118 118 118 118 113 102 118 37 33
m20 105 105 105 105 98 98 37 105 29
m30 103 103 103 103 97 94 33 29 103

2.2 Dependencia lineal y PCA

Mostrar código
# PCA estándar sobre tasas centradas (no estandarizadas): todas están en la
# misma unidad (%), así que la varianza de cada plazo es informativa.
pca_ajuste <- function(X, k) {
  pc <- prcomp(X, center = TRUE, scale. = FALSE)
  list(mu = pc$center, W = pc$rotation[, seq_len(k), drop = FALSE],
       F = pc$x[, seq_len(k), drop = FALSE], sdev = pc$sdev)
}
reconstruir <- function(Fmat, aj) sweep(Fmat %*% t(aj$W), 2, aj$mu, "+")

# PCA-EM (iterativo): estima un PCA de rango k ignorando celdas faltantes.
pca_em <- function(X, k, max_iter = 1000, tol = 1e-8) {
  falt <- is.na(X)
  Xc <- X
  Xc[falt] <- matrix(colMeans(X, na.rm = TRUE), nrow(X), ncol(X), byrow = TRUE)[falt]
  for (it in seq_len(max_iter)) {
    m <- colMeans(Xc)
    s <- svd(sweep(Xc, 2, m), nu = k, nv = k)
    R <- sweep(s$u %*% diag(s$d[seq_len(k)], k) %*% t(s$v), 2, m, "+")
    cambio <- sqrt(mean((Xc[falt] - R[falt])^2))
    Xc[falt] <- R[falt]
    if (cambio < tol) break
  }
  list(X = Xc, pca = prcomp(Xc, center = TRUE, scale. = FALSE), iter = it)
}

var_explicada <- function(pc, metodo) {
  v <- pc$sdev^2
  tibble::tibble(metodo = metodo, componente = paste0("PC", seq_along(v)),
                 var_pct = 100 * v / sum(v), acumulada = cumsum(var_pct))
}
Mostrar código
ev <- eigen(cor(Yf))$values
kbl(tibble::tibble(componente = seq_along(ev), eigenvalor_cor = ev,
                   pct = 100 * ev / sum(ev)),
    caption = "Eigenvalores de la matriz de correlación del panel rellenado")
Eigenvalores de la matriz de correlación del panel rellenado
componente eigenvalor_cor pct
1 8.105 90.057
2 0.709 7.878
3 0.099 1.104
4 0.035 0.388
5 0.028 0.307
6 0.016 0.181
7 0.007 0.075
8 0.001 0.009
9 0.000 0.002
Mostrar código
cat("Número de condición de la matriz de correlación:", round(max(ev) / min(ev), 1), "\n")
Número de condición de la matriz de correlación: 49726.1 
Mostrar código
dYf <- diff(Yf)
pcas <- list(
  "Niveles - panel rellenado"        = prcomp(Yf, center = TRUE, scale. = FALSE),
  "Niveles - PCA-EM (k=3)"           = pca_em(Yobs, 3)$pca,
  "Niveles - 40 meses completos"     = prcomp(Yobs[complete.cases(Yobs), ], center = TRUE, scale. = FALSE),
  "Diferencias - panel rellenado"    = prcomp(dYf, center = TRUE, scale. = FALSE),
  "Diferencias - PCA-EM (k=3)"       = pca_em(dYobs, 3)$pca
)

ve <- imap_dfr(pcas, var_explicada)
ve |>
  dplyr::filter(componente %in% paste0("PC", 1:4)) |>
  dplyr::select(-acumulada) |>
  pivot_wider(names_from = componente, values_from = var_pct) |>
  left_join(ve |> dplyr::filter(componente == "PC3") |> dplyr::select(metodo, acumulada_3 = acumulada),
            by = "metodo") |>
  kbl(digits = 2, caption = "% de varianza explicada por componente")
% de varianza explicada por componente
metodo PC1 PC2 PC3 PC4 acumulada_3
Niveles - panel rellenado 93.85 4.81 0.76 0.20 99.41
Niveles - PCA-EM (k=3) 94.18 4.70 0.70 0.14 99.58
Niveles - 40 meses completos 92.85 5.79 0.87 0.20 99.52
Diferencias - panel rellenado 53.36 19.31 11.46 5.74 84.12
Diferencias - PCA-EM (k=3) 54.43 32.51 9.01 2.24 95.96

Nota: en el PCA-EM, las celdas faltantes se reconstruyen dentro de un subespacio de rango 3, por lo que su varianza explicada por 3 componentes está sesgada al alza. Su utilidad es comparar la forma de las cargas, no el porcentaje.

Mostrar código
ref <- pcas[["Niveles - panel rellenado"]]$rotation[, 1:3]
# Normalización de signo: PC1 con cargas positivas en promedio
ref[, 1] <- ref[, 1] * sign(sum(ref[, 1]))
ref[, 2] <- ref[, 2] * sign(ref["m30", 2] - ref["c28", 2])  # PC2 sube con el plazo
ref[, 3] <- ref[, 3] * sign(ref["m3", 3] - (ref["c28", 3] + ref["m30", 3]) / 2)  # PC3 joroba positiva

cargas <- imap_dfr(pcas, function(pc, nm) {
  W <- pc$rotation[, 1:3]
  W <- sweep(W, 2, sign(colSums(W * ref)), "*")  # alinear con la referencia
  as_tibble(W, rownames = "codigo") |> mutate(metodo = nm)
}) |>
  pivot_longer(PC1:PC3, names_to = "componente", values_to = "carga") |>
  mutate(plazo = plazos[codigo],
         tipo = if_else(str_detect(metodo, "Niveles"), "Niveles", "Diferencias"))

ggplot(cargas, aes(plazo, carga, colour = metodo)) +
  geom_hline(yintercept = 0, colour = "grey70") +
  geom_line() + geom_point(size = 1.2) +
  scale_x_log10(breaks = plazos, labels = codigos) +
  facet_grid(tipo ~ componente) +
  labs(x = "Plazo", y = "Carga", colour = NULL) +
  theme(legend.position = "bottom", axis.text.x = element_text(angle = 90, size = 7)) +
  guides(colour = guide_legend(ncol = 2))
Figure 4: Cargas de los tres primeros componentes según el plazo (eje logarítmico). Signos alineados con el PCA de niveles del panel rellenado.

2.3 ¿Son interpretables como nivel, pendiente y curvatura?

Comparamos los scores del PCA de niveles con proxies empíricos estándar: nivel = promedio de las 9 tasas; pendiente = 10a − 28d; curvatura = 2·3a − 28d − 30a.

Mostrar código
aj_full <- pca_ajuste(Yf, 3)
Fs <- sweep(aj_full$F, 2, sign(colSums(aj_full$W * ref)), "*")
proxies <- cbind(nivel = rowMeans(Yf),
                 pendiente = Yf[, "m10"] - Yf[, "c28"],
                 curvatura = 2 * Yf[, "m3"] - Yf[, "c28"] - Yf[, "m30"])
kbl(cor(Fs, proxies), caption = "Correlación entre scores PCA (niveles) y proxies empíricos")
Correlación entre scores PCA (niveles) y proxies empíricos
nivel pendiente curvatura
PC1 0.997 -0.760 0.276
PC2 0.074 0.616 0.110
PC3 -0.005 0.152 0.913

2.4 Estabilidad en el tiempo

Mostrar código
mitad <- which(meses == as.Date("2016-09-01"))
W1 <- prcomp(Yf[1:mitad, ])$rotation[, 1:3]
W2 <- prcomp(Yf[(mitad + 1):N, ])$rotation[, 1:3]
ve1 <- var_explicada(prcomp(Yf[1:mitad, ]), "2006-10 a 2016-09")
ve2 <- var_explicada(prcomp(Yf[(mitad + 1):N, ]), "2016-10 a 2026-09")
kbl(bind_rows(ve1, ve2) |> dplyr::filter(componente %in% paste0("PC", 1:3)), digits = 2)
metodo componente var_pct acumulada
2006-10 a 2016-09 PC1 90.24 90.24
2006-10 a 2016-09 PC2 7.64 97.88
2006-10 a 2016-09 PC3 1.32 99.20
2016-10 a 2026-09 PC1 91.23 91.23
2016-10 a 2026-09 PC2 7.01 98.24
2016-10 a 2026-09 PC3 1.05 99.29
Mostrar código
kbl(tibble::tibble(componente = paste0("PC", 1:3),
                   cos_similitud_cargas = abs(colSums(W1 * W2))),
    caption = "Similitud (|coseno|) entre cargas de las dos submuestras")
Similitud (|coseno|) entre cargas de las dos submuestras
componente cos_similitud_cargas
PC1 0.999
PC2 0.980
PC3 0.815

3. Propiedades temporales

Objetivo. Decidir cómo modelar la dinámica: niveles o diferencias, y si hay cointegración que justifique un VECM. ADF (H0: raíz unitaria) y KPSS (H0: estacionariedad) se usan como pruebas complementarias. Las pruebas sobre MBONOs usan el panel rellenado, lo que conviene tener presente al leerlas.

Mostrar código
prueba <- function(x) {
  c(ADF_p = suppressWarnings(tseries::adf.test(x)$p.value),
    KPSS_p = suppressWarnings(tseries::kpss.test(x, null = "Level")$p.value))
}
series_test <- cbind(Yf,
                     `m10-c28` = Yf[, "m10"] - Yf[, "c28"],
                     `m30-c28` = Yf[, "m30"] - Yf[, "c28"],
                     `c364-c28` = Yf[, "c364"] - Yf[, "c28"],
                     `m30-m10` = Yf[, "m30"] - Yf[, "m10"],
                     PC1 = Fs[, 1], PC2 = Fs[, 2], PC3 = Fs[, 3])
tab_estac <- map_dfr(colnames(series_test), function(nm) {
  x <- series_test[, nm]
  tibble::tibble(serie = nm,
                 ADF_p_niveles = prueba(x)[["ADF_p"]],
                 KPSS_p_niveles = prueba(x)[["KPSS_p"]],
                 ADF_p_dif = prueba(diff(x))[["ADF_p"]],
                 KPSS_p_dif = prueba(diff(x))[["KPSS_p"]])
})
kbl(tab_estac, caption = "p-valores (tseries trunca en 0.01 y 0.10/0.99)")
p-valores (tseries trunca en 0.01 y 0.10/0.99)
serie ADF_p_niveles KPSS_p_niveles ADF_p_dif KPSS_p_dif
c28 0.099 0.010 0.089 0.1
c91 0.096 0.010 0.081 0.1
c182 0.127 0.010 0.090 0.1
c364 0.224 0.010 0.081 0.1
m3 0.395 0.010 0.010 0.1
m5 0.580 0.010 0.010 0.1
m10 0.615 0.010 0.010 0.1
m20 0.572 0.010 0.010 0.1
m30 0.551 0.010 0.010 0.1
m10-c28 0.399 0.010 0.010 0.1
m30-c28 0.315 0.010 0.010 0.1
c364-c28 0.075 0.100 0.010 0.1
m30-m10 0.030 0.039 0.010 0.1
PC1 0.311 0.010 0.048 0.1
PC2 0.362 0.098 0.010 0.1
PC3 0.048 0.100 0.010 0.1
Mostrar código
gridExtra_ok <- requireNamespace("patchwork", quietly = TRUE)
p1 <- forecast::ggAcf(Yf[, "c28"], lag.max = 36) + labs(title = "ACF c28 (niveles)")
p2 <- forecast::ggAcf(diff(Yf[, "c28"]), lag.max = 36) + labs(title = "ACF c28 (dif.)")
p3 <- forecast::ggAcf(Yf[, "m10"], lag.max = 36) + labs(title = "ACF m10 (niveles)")
p4 <- forecast::ggAcf(diff(Yf[, "m10"]), lag.max = 36) + labs(title = "ACF m10 (dif.)")
if (gridExtra_ok) {
  print(patchwork::wrap_plots(p1, p2, p3, p4, ncol = 2))
} else {
  print(p1); print(p2); print(p3); print(p4)
}
Figure 5

3.1 Cointegración (Johansen)

Para que la decisión sobre el VECM no use información del periodo de evaluación, el rango de cointegración se determina con la primera ventana de entrenamiento (hasta 2014-12) y se mantiene fijo en el backtest. Como referencia, también se reporta la muestra completa.

Mostrar código
origen_primero <- as.Date("2014-12-01")
t0 <- which(meses == origen_primero)

rango_johansen <- function(cj) {
  est <- rev(cj@teststat)          # de r = 0 hacia arriba
  cv5 <- rev(cj@cval[, "5pct"])
  rechaza <- est > cv5
  if (all(rechaza)) length(est) else which(!rechaza)[1] - 1
}
tabla_johansen <- function(cj) {
  tibble::tibble(H0 = rev(rownames(cj@cval)), estadistico_traza = rev(cj@teststat),
                 vc_10 = rev(cj@cval[, "10pct"]), vc_5 = rev(cj@cval[, "5pct"]),
                 vc_1 = rev(cj@cval[, "1pct"]))
}

cj_ini  <- urca::ca.jo(Yf[1:t0, ], type = "trace", ecdet = "const", K = 2, spec = "transitory")
cj_full <- urca::ca.jo(Yf,         type = "trace", ecdet = "const", K = 2, spec = "transitory")
r_vecm  <- rango_johansen(cj_ini)

kbl(tabla_johansen(cj_ini),  caption = "Johansen (traza), ventana inicial 2006-10 a 2014-12")
Johansen (traza), ventana inicial 2006-10 a 2014-12
H0 estadistico_traza vc_10 vc_5 vc_1
r = 0 | 234.319 196.37 202.92 215.74
r <= 1 | 168.258 159.48 165.58 177.20
r <= 2 | 120.191 126.58 131.70 143.09
r <= 3 | 82.355 97.18 102.14 111.01
r <= 4 | 58.024 71.86 76.07 84.45
r <= 5 | 35.982 49.65 53.12 60.16
r <= 6 | 20.182 32.00 34.91 41.07
r <= 7 | 7.516 17.85 19.96 24.60
r <= 8 | 3.142 7.52 9.24 12.97
Mostrar código
kbl(tabla_johansen(cj_full), caption = "Johansen (traza), muestra completa (solo referencia)")
Johansen (traza), muestra completa (solo referencia)
H0 estadistico_traza vc_10 vc_5 vc_1
r = 0 | 473.391 196.37 202.92 215.74
r <= 1 | 363.543 159.48 165.58 177.20
r <= 2 | 278.104 126.58 131.70 143.09
r <= 3 | 196.732 97.18 102.14 111.01
r <= 4 | 136.906 71.86 76.07 84.45
r <= 5 | 82.472 49.65 53.12 60.16
r <= 6 | 40.596 32.00 34.91 41.07
r <= 7 | 15.165 17.85 19.96 24.60
r <= 8 | 5.724 7.52 9.24 12.97
Mostrar código
usar_vecm <- r_vecm >= 1 && r_vecm < p

Rango de cointegración en la ventana inicial (5 %): r = 2. VECM incluido en el backtest: sí.

4. Diseño experimental

  • Origen móvil con ventana expansiva. En cada origen \(t\) se re-estima todo (modelos, PCA, escaladores, selección de rezagos) usando solo los meses \(1,\dots,t\).
  • Primer origen: 2014-12 (99 meses de entrenamiento). Último origen: el último con objetivo dentro de la muestra para cada \(h\).
  • Validación (orígenes 2015-01 a 2020-12): se usa para elegir hiperparámetros y el número de factores \(k\). Prueba (orígenes desde 2021-01): solo para reportar, nunca para elegir. Los objetivos de validación (hasta 2021-03 para \(h=3\), hasta 2021-12 para \(h=12\)) no se traslapan con los de prueba.
  • Objetivos comparables: todos los modelos se evalúan sobre exactamente los mismos pares (origen, instrumento, \(h\)), y solo donde \(y_{j,t+h}\) fue observado.
  • Línea base: paseo aleatorio, \(\hat y_{j,t+h\mid t}\) = última subasta observada de \(j\) hasta \(t\). Es la referencia estándar para tasas, que se comportan casi como raíces unitarias, y es la que usaría un analista sin modelo.
  • Métricas: RMSE y MAE por instrumento y horizonte, y RMSE relativo al naive (promediado entre los 9 instrumentos, para que ninguna maduración domine por su escala de error). Diebold-Mariano (con corrección HLN) contra el naive.
Mostrar código
inicio_valid <- as.Date("2015-01-01")
inicio_test  <- as.Date("2021-01-01")
H <- c(3, 12)
origenes <- t0:(N - min(H))
cat("Orígenes totales:", length(origenes), "| de", format(meses[min(origenes)], "%Y-%m"),
    "a", format(meses[max(origenes)], "%Y-%m"), "\n")
Orígenes totales: 139 | de 2014-12 a 2026-06 

5. Modelos

Familia Modelo Representación Qué pregunta responde
Referencia Naive (paseo aleatorio) — ¿Se puede mejorar la persistencia?
Econometría directa ARIMA por instrumento (auto.arima, sin deriva) 9 tasas P2
VAR en diferencias (rezagos por BIC, ≤4) 9 tasas P2
VAR en niveles (rezagos por BIC, ≤2) 9 tasas P2
VECM (si Johansen lo justifica) 9 tasas P2
Econometría con factores AR(1) por factor + reconstrucción \(k\) factores PCA P3
VAR(1) de factores + reconstrucción \(k\) factores PCA P3
ML directo Elastic Net 9 tasas vs \(k\) factores P2, P3
SVR (kernel radial) 9 tasas vs \(k\) factores P2, P3

Detalles relevantes

  • Modelos de factores: el PCA se estima en cada origen con los datos hasta \(t\). Los factores se pronostican y la curva se reconstruye sumando el residuo idiosincrático actual (\(y_t - \hat y_t^{PCA}\)), que se supone persistente. Así, un pronóstico de factores “sin cambio” coincide con el naive, y la comparación aísla el valor de la dinámica de los factores.
  • ML directo: un modelo por instrumento y horizonte. El objetivo es el cambio \(y_{j,s+h} - \tilde y_{j,s}\), observado. Los features en \(s\) son los niveles y los cambios a 3 meses de las 9 tasas (o de los \(k\) factores). Solo se usan filas con \(s + h \le t\), para que el objetivo ya se conozca en el origen. Elastic Net estandariza y SVR escala con la muestra de entrenamiento de cada origen.
  • Hiperparámetros (elegidos en validación): \(k \in \{1,\dots,6\}\) (la primera versión usó \(\{1,\dots,4\}\) y la validación eligió siempre \(k=4\), el borde de la rejilla); Elastic Net \(\alpha \in \{0, 0.5, 1\}\), \(\lambda \in \{0.3, 0.1, 0.03, 0.01, 0.003\}\); SVR \(C \in \{0.1, 1, 10\}\), \(\varepsilon \in \{0.05, 0.2\}\).
  • Descartados: LSTM y XGBoost, por el tamaño de muestra (≈100–230 observaciones mensuales por ventana) y el riesgo de sobreajuste. Se documentan en la bitácora.
Mostrar código
K_GRID   <- 1:6
LAMBDAS  <- c(0.3, 0.1, 0.03, 0.01, 0.003)
ALPHAS   <- c(0, 0.5, 1)
SVR_GRID <- tidyr::expand_grid(cost = c(0.1, 1, 10), epsilon = c(0.05, 0.2))

# Cada función devuelve una matriz 2 x p: filas = h3, h12; columnas = instrumentos
como_2xp <- function(M) { M <- matrix(M, nrow = 2); colnames(M) <- codigos; M }

f_naive <- function(t) {
  u <- apply(Yobs[1:t, , drop = FALSE], 2, function(x) { v <- x[!is.na(x)]; v[length(v)] })
  como_2xp(rbind(u, u))
}

f_arima <- function(t) {
  como_2xp(sapply(seq_len(p), function(j) {
    fit <- forecast::auto.arima(ts(Yf[1:t, j], frequency = 12), seasonal = FALSE,
                                allowdrift = FALSE, stepwise = TRUE, approximation = FALSE)
    as.numeric(forecast::forecast(fit, h = 12)$mean)[c(3, 12)]
  }))
}

f_var_dif <- function(t) {
  dY  <- diff(Yf[1:t, ])
  sel <- vars::VARselect(dY, lag.max = 4, type = "none")$selection[["SC(n)"]]
  fit <- vars::VAR(dY, p = max(1, sel), type = "none")
  dfc <- sapply(predict(fit, n.ahead = 12)$fcst, function(m) m[, "fcst"])
  niv <- sweep(apply(dfc, 2, cumsum), 2, Yf[t, ], "+")
  como_2xp(niv[c(3, 12), ])
}

f_var_niv <- function(t) {
  sel <- vars::VARselect(Yf[1:t, ], lag.max = 2, type = "const")$selection[["SC(n)"]]
  fit <- vars::VAR(Yf[1:t, ], p = max(1, sel), type = "const")
  como_2xp(sapply(predict(fit, n.ahead = 12)$fcst, function(m) m[c(3, 12), "fcst"]))
}

f_vecm <- function(t) {
  cj <- urca::ca.jo(Yf[1:t, ], type = "trace", ecdet = "const", K = 2, spec = "transitory")
  vv <- vars::vec2var(cj, r = r_vecm)
  como_2xp(sapply(predict(vv, n.ahead = 12)$fcst, function(m) m[c(3, 12), "fcst"]))
}

# Factores: pronóstico + reconstrucción + residuo actual persistente
reconstruir_con_residuo <- function(Ff, aj, t) {
  res_t <- Yf[t, ] - reconstruir(aj$F[t, , drop = FALSE], aj)
  como_2xp(sweep(reconstruir(Ff, aj), 2, as.numeric(res_t), "+"))
}

f_pca_ar <- function(t, k) {
  aj <- pca_ajuste(Yf[1:t, ], k)
  Ff <- sapply(seq_len(k), function(i) {
    f <- aj$F[, i]
    tryCatch(as.numeric(predict(arima(f, order = c(1, 0, 0)), n.ahead = 12)$pred)[c(3, 12)],
             error = function(e) rep(f[length(f)], 2))
  })
  reconstruir_con_residuo(matrix(Ff, nrow = 2), aj, t)
}

f_pca_var <- function(t, k) {
  aj <- pca_ajuste(Yf[1:t, ], k)
  if (k == 1) {  # VAR univariado = AR(1)
    return(f_pca_ar(t, 1))
  }
  fit <- vars::VAR(aj$F, p = 1, type = "const")
  Ff  <- sapply(predict(fit, n.ahead = 12)$fcst, function(m) m[c(3, 12), "fcst"])
  reconstruir_con_residuo(matrix(Ff, nrow = 2), aj, t)
}

# ML ---------------------------------------------------------------------------
crear_features <- function(X) {
  X <- as.matrix(X)
  rez3 <- rbind(matrix(NA, 3, ncol(X)), X[1:(nrow(X) - 3), , drop = FALSE])
  out <- cbind(X, X - rez3)
  colnames(out) <- c(paste0(colnames(X), "_niv"), paste0(colnames(X), "_d3"))
  out
}

ml_origen <- function(t) {
  bloques <- list(raw = crear_features(Yf[1:t, , drop = FALSE]))
  for (k in K_GRID) bloques[[paste0("k", k)]] <- crear_features(pca_ajuste(Yf[1:t, ], k)$F)
  out <- list()
  for (nb in names(bloques)) {
    Fe <- bloques[[nb]]
    xt <- Fe[t, , drop = FALSE]
    etiqueta <- if (nb == "raw") "tasas" else "PCA"
    for (h in H) {
      if (t + h > N) next
      s_idx <- 4:(t - h)
      for (j in seq_len(p)) {
        ok <- s_idx[!is.na(Yobs[s_idx + h, j])]
        x  <- Fe[ok, , drop = FALSE]
        y  <- Yobs[ok + h, j] - Yf[ok, j]
        # Elastic Net
        for (a in ALPHAS) {
          pr <- tryCatch({
            fit <- glmnet::glmnet(x, y, alpha = a, lambda = LAMBDAS, standardize = TRUE)
            as.numeric(predict(fit, newx = xt, s = LAMBDAS))
          }, error = function(e) rep(0, length(LAMBDAS)))
          out[[length(out) + 1]] <- data.frame(
            modelo = paste0("EN-", etiqueta), config = paste0(nb, "_a", a, "_l", LAMBDAS),
            origen = t, h = h, codigo = codigos[j], pronostico = Yf[t, j] + pr)
        }
        # SVR
        pr_svr <- vapply(seq_len(nrow(SVR_GRID)), function(g) {
          tryCatch({
            fit <- e1071::svm(x = x, y = y, type = "eps-regression", kernel = "radial",
                              cost = SVR_GRID$cost[g], epsilon = SVR_GRID$epsilon[g], scale = TRUE)
            as.numeric(predict(fit, xt))
          }, error = function(e) 0)
        }, numeric(1))
        out[[length(out) + 1]] <- data.frame(
          modelo = paste0("SVR-", etiqueta),
          config = paste0(nb, "_C", SVR_GRID$cost, "_e", SVR_GRID$epsilon),
          origen = t, h = h, codigo = codigos[j], pronostico = Yf[t, j] + pr_svr)
      }
    }
  }
  bind_rows(out)
}
Mostrar código
# El backtest tarda varios minutos. Se guarda en disco; borra el .rds para recalcular.
archivo_bt <- "resultados_backtest_v2.rds"  # v2: rejilla de k ampliada a 1:6

a_tabla <- function(M, modelo, config, t) {
  data.frame(modelo = modelo, config = config, origen = t,
             h = rep(H, times = p), codigo = rep(codigos, each = 2),
             pronostico = as.vector(M))
}

ejecutar_backtest <- function() {
  fallos <- list()
  seguro <- function(fun, t, nombre) {
    tryCatch(fun(t), error = function(e) {
      fallos[[length(fallos) + 1]] <<- data.frame(modelo = nombre, origen = t, error = conditionMessage(e))
      f_naive(t)  # respaldo, queda registrado
    })
  }
  res <- vector("list", length(origenes))
  t_ini <- Sys.time()
  for (i in seq_along(origenes)) {
    t <- origenes[i]
    piezas <- list(
      a_tabla(f_naive(t), "Naive", "rw", t),
      a_tabla(seguro(f_arima, t, "ARIMA"), "ARIMA", "auto", t),
      a_tabla(seguro(f_var_dif, t, "VAR-dif"), "VAR-dif", "bic", t),
      a_tabla(seguro(f_var_niv, t, "VAR-niv"), "VAR-niv", "bic", t)
    )
    if (usar_vecm) piezas <- c(piezas, list(a_tabla(seguro(f_vecm, t, "VECM"), "VECM", paste0("r", r_vecm), t)))
    for (k in K_GRID) {
      piezas <- c(piezas, list(
        a_tabla(seguro(function(tt) f_pca_ar(tt, k), t, "PCA-AR"), "PCA-AR", paste0("k", k), t),
        a_tabla(seguro(function(tt) f_pca_var(tt, k), t, "PCA-VAR"), "PCA-VAR", paste0("k", k), t)))
    }
    piezas <- c(piezas, list(ml_origen(t)))
    res[[i]] <- bind_rows(piezas)
  }
  list(pronosticos = bind_rows(res), fallos = bind_rows(fallos),
       segundos = as.numeric(difftime(Sys.time(), t_ini, units = "secs")))
}

if (file.exists(archivo_bt)) {
  bt <- readRDS(archivo_bt)
} else {
  bt <- ejecutar_backtest()
  saveRDS(bt, archivo_bt)
}
cat("Tiempo de cómputo del backtest:", round(bt$segundos / 60, 1), "min\n")
Tiempo de cómputo del backtest: 34 min
Mostrar código
cat("Filas de pronósticos:", nrow(bt$pronosticos), "\n")
Filas de pronósticos: 398421 
Mostrar código
if (nrow(bt$fallos) > 0) kbl(count(bt$fallos, modelo, name = "fallos (sustituidos por naive)")) else cat("Sin fallos de estimación.\n")
Sin fallos de estimación.

6. Evaluación fuera de muestra

Mostrar código
eval_df <- bt$pronosticos |>
  dplyr::filter(origen + h <= N) |>
  mutate(objetivo = origen + h,
         real = Yobs[cbind(objetivo, match(codigo, codigos))]) |>
  dplyr::filter(!is.na(real)) |>
  mutate(error = real - pronostico,
         periodo = case_when(meses[origen] >= inicio_test  ~ "prueba",
                             meses[origen] >= inicio_valid ~ "validacion",
                             TRUE ~ "calentamiento"))

metricas <- eval_df |>
  dplyr::filter(periodo != "calentamiento") |>
  group_by(modelo, config, h, periodo, codigo) |>
  summarise(n = n(), rmse = sqrt(mean(error^2)), mae = mean(abs(error))) |>
  ungroup()

metricas <- metricas |>
  left_join(metricas |> dplyr::filter(modelo == "Naive") |>
              dplyr::select(h, periodo, codigo, rmse_naive = rmse, mae_naive = mae),
            by = c("h", "periodo", "codigo")) |>
  mutate(rel_rmse = rmse / rmse_naive, rel_mae = mae / mae_naive)

# Verificación: todos los modelos usan los mismos objetivos
chk <- eval_df |> dplyr::filter(periodo != "calentamiento") |>
  count(modelo, config, h, periodo) |> distinct(h, periodo, n)
kbl(chk, caption = "Número de pares (origen, instrumento) evaluados por h y periodo: debe ser único por fila")
Número de pares (origen, instrumento) evaluados por h y periodo: debe ser único por fila
h periodo n
3 prueba 541
3 validacion 587
12 prueba 467
12 validacion 588

6.1 Selección de configuraciones en validación

Mostrar código
seleccion <- metricas |>
  dplyr::filter(periodo == "validacion") |>
  group_by(modelo, config, h) |>
  summarise(rel_rmse_medio_valid = mean(rel_rmse)) |>
  group_by(modelo, h) |>
  slice_min(rel_rmse_medio_valid, n = 1, with_ties = FALSE) |>
  ungroup()
kbl(seleccion |> arrange(h, rel_rmse_medio_valid), caption = "Configuración elegida por modelo y horizonte (solo con validación)")
Configuración elegida por modelo y horizonte (solo con validación)
modelo config h rel_rmse_medio_valid
PCA-VAR k5 3 0.881
EN-tasas raw_a1_l0.01 3 0.891
EN-PCA k4_a1_l0.01 3 0.894
ARIMA auto 3 0.933
VAR-niv bic 3 0.941
PCA-AR k6 3 0.978
SVR-PCA k4_C1_e0.2 3 0.992
VAR-dif bic 3 0.994
VECM r2 3 0.996
Naive rw 3 1.000
SVR-tasas raw_C0.1_e0.2 3 1.018
PCA-AR k6 12 0.933
PCA-VAR k5 12 0.943
VAR-niv bic 12 0.951
EN-PCA k4_a0_l0.1 12 0.970
EN-tasas raw_a1_l0.1 12 0.975
Naive rw 12 1.000
ARIMA auto 12 1.000
SVR-PCA k4_C1_e0.05 12 1.012
VAR-dif bic 12 1.013
VECM r2 12 1.022
SVR-tasas raw_C1_e0.2 12 1.042

6.2 Resultados en el periodo de prueba

Mostrar código
orden_modelos <- c("Naive", "ARIMA", "VAR-dif", "VAR-niv", "VECM", "PCA-AR", "PCA-VAR",
                   "EN-tasas", "EN-PCA", "SVR-tasas", "SVR-PCA")

prueba_sel <- metricas |>
  dplyr::filter(periodo == "prueba") |>
  semi_join(seleccion, by = c("modelo", "config", "h"))

resumen_global <- prueba_sel |>
  group_by(modelo, h) |>
  summarise(rel_rmse_medio = mean(rel_rmse), rel_mae_medio = mean(rel_mae),
            instrumentos_mejor_que_naive = sum(rel_rmse < 1),
            rmse_medio_pp = mean(rmse)) |>
  ungroup() |>
  mutate(modelo = factor(modelo, levels = orden_modelos)) |>
  arrange(h, modelo)
kbl(resumen_global, caption = "Periodo de prueba: RMSE y MAE relativos al naive (promedio de 9 instrumentos; <1 = mejor que naive)")
Periodo de prueba: RMSE y MAE relativos al naive (promedio de 9 instrumentos; <1 = mejor que naive)
modelo h rel_rmse_medio rel_mae_medio instrumentos_mejor_que_naive rmse_medio_pp
Naive 3 1.000 1.000 0 0.662
ARIMA 3 0.851 0.843 7 0.555
VAR-dif 3 0.906 0.879 5 0.590
VAR-niv 3 0.848 0.842 5 0.543
VECM 3 0.878 0.860 5 0.570
PCA-AR 3 1.000 1.017 4 0.664
PCA-VAR 3 0.911 0.924 4 0.591
EN-tasas 3 0.854 0.849 4 0.553
EN-PCA 3 0.853 0.852 4 0.552
SVR-tasas 3 0.972 0.954 4 0.640
SVR-PCA 3 0.965 0.958 4 0.632
Naive 12 1.000 1.000 0 1.914
ARIMA 12 0.973 0.965 7 1.827
VAR-dif 12 0.968 0.963 7 1.816
VAR-niv 12 0.916 0.928 5 1.594
VECM 12 0.950 0.935 6 1.770
PCA-AR 12 1.001 1.001 4 1.893
PCA-VAR 12 0.980 0.995 4 1.753
EN-tasas 12 1.023 1.038 4 1.851
EN-PCA 12 1.028 1.048 4 1.838
SVR-tasas 12 1.082 1.127 4 2.001
SVR-PCA 12 1.076 1.087 4 1.930
Mostrar código
for (hh in H) {
  print(kbl(prueba_sel |>
              dplyr::filter(h == hh) |>
              mutate(modelo = factor(modelo, levels = orden_modelos),
                     codigo = factor(codigo, levels = codigos)) |>
              dplyr::select(modelo, codigo, rmse) |>
              pivot_wider(names_from = codigo, values_from = rmse) |>
              arrange(modelo),
            caption = paste0("RMSE (puntos porcentuales) por instrumento, h = ", hh, ", periodo de prueba"))); cat("\n\n")
  print(kbl(prueba_sel |>
              dplyr::filter(h == hh) |>
              mutate(modelo = factor(modelo, levels = orden_modelos),
                     codigo = factor(codigo, levels = codigos)) |>
              dplyr::select(modelo, codigo, mae) |>
              pivot_wider(names_from = codigo, values_from = mae) |>
              arrange(modelo),
            caption = paste0("MAE (puntos porcentuales) por instrumento, h = ", hh, ", periodo de prueba"))); cat("\n\n")
}
RMSE (puntos porcentuales) por instrumento, h = 3, periodo de prueba
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
Naive 0.721 0.722 0.725 0.717 0.601 0.525 0.774 0.526 0.647
ARIMA 0.444 0.478 0.577 0.413 0.599 0.497 0.788 0.559 0.638
VAR-dif 0.551 0.476 0.605 0.492 0.677 0.555 0.753 0.555 0.648
VAR-niv 0.419 0.277 0.569 0.315 0.655 0.625 0.725 0.604 0.694
VECM 0.518 0.434 0.574 0.439 0.646 0.566 0.745 0.549 0.663
PCA-AR 0.753 0.602 0.824 0.668 0.578 0.533 0.851 0.506 0.660
PCA-VAR 0.524 0.423 0.604 0.460 0.664 0.630 0.796 0.542 0.677
EN-tasas 0.467 0.349 0.553 0.385 0.639 0.562 0.784 0.556 0.679
EN-PCA 0.453 0.360 0.539 0.376 0.663 0.578 0.785 0.535 0.676
SVR-tasas 0.664 0.641 0.668 0.641 0.618 0.533 0.779 0.557 0.656
SVR-PCA 0.613 0.564 0.638 0.575 0.620 0.568 0.820 0.571 0.722
MAE (puntos porcentuales) por instrumento, h = 3, periodo de prueba
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
Naive 0.589 0.576 0.600 0.573 0.492 0.411 0.648 0.421 0.557
ARIMA 0.362 0.392 0.456 0.334 0.478 0.383 0.654 0.445 0.548
VAR-dif 0.432 0.370 0.476 0.382 0.533 0.418 0.618 0.446 0.536
VAR-niv 0.351 0.204 0.469 0.258 0.515 0.515 0.608 0.477 0.574
VECM 0.412 0.332 0.463 0.344 0.510 0.432 0.614 0.446 0.555
PCA-AR 0.625 0.497 0.686 0.555 0.464 0.453 0.712 0.405 0.563
PCA-VAR 0.450 0.343 0.506 0.388 0.555 0.514 0.660 0.438 0.558
EN-tasas 0.386 0.277 0.452 0.324 0.504 0.466 0.650 0.440 0.546
EN-PCA 0.391 0.295 0.448 0.327 0.532 0.461 0.650 0.408 0.560
SVR-tasas 0.523 0.499 0.546 0.487 0.498 0.404 0.649 0.451 0.557
SVR-PCA 0.504 0.456 0.537 0.459 0.491 0.433 0.676 0.467 0.599
RMSE (puntos porcentuales) por instrumento, h = 12, periodo de prueba
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
Naive 2.741 2.741 2.623 2.767 1.190 0.965 1.872 0.920 1.404
ARIMA 2.321 2.656 2.554 2.476 1.185 0.959 1.902 0.986 1.401
VAR-dif 2.533 2.441 2.461 2.508 1.234 0.958 1.850 0.954 1.403
VAR-niv 1.985 1.634 2.119 1.820 1.299 1.156 1.769 1.060 1.502
VECM 2.454 2.333 2.384 2.400 1.189 0.969 1.830 0.959 1.413
PCA-AR 2.678 2.490 2.656 2.608 1.156 1.005 2.034 0.948 1.463
PCA-VAR 2.275 2.077 2.277 2.199 1.298 1.188 1.898 1.075 1.494
EN-tasas 2.390 2.234 2.394 2.334 1.395 1.126 2.099 1.048 1.637
EN-PCA 2.332 2.114 2.377 2.238 1.384 1.192 2.122 1.078 1.704
SVR-tasas 2.655 2.664 2.589 2.700 1.375 1.046 2.135 1.148 1.695
SVR-PCA 2.413 2.458 2.376 2.426 1.421 1.190 2.120 1.198 1.767
MAE (puntos porcentuales) por instrumento, h = 12, periodo de prueba
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
Naive 2.340 2.361 2.220 2.375 0.949 0.769 1.420 0.764 1.055
ARIMA 1.972 2.202 2.173 2.134 0.935 0.738 1.473 0.809 1.042
VAR-dif 2.123 2.086 2.039 2.125 0.992 0.752 1.420 0.802 1.040
VAR-niv 1.576 1.352 1.656 1.473 1.073 0.998 1.439 0.901 1.179
VECM 2.004 1.960 1.922 1.982 0.961 0.760 1.409 0.800 1.046
PCA-AR 2.312 2.166 2.290 2.259 0.850 0.815 1.607 0.756 1.100
PCA-VAR 1.827 1.742 1.796 1.808 1.058 1.047 1.522 0.923 1.169
EN-tasas 1.917 1.784 1.922 1.874 1.134 0.978 1.766 0.869 1.332
EN-PCA 1.903 1.762 1.933 1.840 1.108 1.031 1.784 0.874 1.396
SVR-tasas 2.230 2.169 2.174 2.239 1.190 0.844 1.865 0.986 1.473
SVR-PCA 1.959 2.004 1.930 1.970 1.174 0.972 1.763 1.012 1.404
Mostrar código
n_prueba <- eval_df |> dplyr::filter(periodo == "prueba", modelo == "Naive") |> count(h, codigo) |>
  pivot_wider(names_from = codigo, values_from = n)
print(kbl(n_prueba, caption = "Número de objetivos observados en prueba por instrumento"))
Número de objetivos observados en prueba por instrumento
h c182 c28 c364 c91 m10 m20 m3 m30 m5
3 66 66 66 66 54 48 62 50 63
12 57 57 57 57 47 42 53 43 54
Mostrar código
prueba_sel |>
  dplyr::filter(modelo != "Naive") |>
  mutate(codigo = factor(codigo, levels = codigos),
         modelo = factor(modelo, levels = orden_modelos),
         h = paste0("h = ", h)) |>
  ggplot(aes(codigo, rel_rmse, colour = modelo, group = modelo)) +
  geom_hline(yintercept = 1, colour = "black") +
  geom_line(alpha = 0.7) + geom_point(size = 1.4) +
  scale_colour_brewer(palette = "Paired") +
  facet_wrap(~ h) +
  labs(x = NULL, y = "RMSE / RMSE naive", colour = NULL)
Figure 6: RMSE relativo al naive por instrumento (periodo de prueba). Debajo de 1 = mejor que el naive.

6.3 Diebold-Mariano contra el naive

Pérdida cuadrática, prueba bilateral con corrección HLN (forecast::dm.test, horizonte \(h\)). Un estadístico negativo indica que el modelo tiene menor error que el naive. Advertencia: en los MBONOs largos la serie de errores tiene huecos (meses sin subasta), así que el ajuste por autocorrelación es aproximado.

Mostrar código
dm_tab <- eval_df |>
  dplyr::filter(periodo == "prueba") |>
  semi_join(seleccion, by = c("modelo", "config", "h")) |>
  dplyr::select(modelo, h, codigo, origen, error) |>
  group_by(h, codigo) |>
  group_modify(function(d, key) {
    base <- d |> dplyr::filter(modelo == "Naive") |> dplyr::select(origen, e_naive = error)
    d |> dplyr::filter(modelo != "Naive") |>
      inner_join(base, by = "origen") |>
      arrange(origen) |>
      group_by(modelo) |>
      summarise(dm = tryCatch(forecast::dm.test(error, e_naive, h = key$h, power = 2)$statistic,
                              error = function(e) NA_real_),
                p = tryCatch(forecast::dm.test(error, e_naive, h = key$h, power = 2)$p.value,
                             error = function(e) NA_real_))
  }) |>
  ungroup()

for (hh in H) {
  print(kbl(dm_tab |> dplyr::filter(h == hh) |>
              mutate(celda = sprintf("%.2f (%.2f)", dm, p),
                     codigo = factor(codigo, levels = codigos),
                     modelo = factor(modelo, levels = orden_modelos)) |>
              dplyr::select(modelo, codigo, celda) |>
              pivot_wider(names_from = codigo, values_from = celda) |>
              arrange(modelo),
            caption = paste0("DM vs naive: estadístico (p-valor), h = ", hh))); cat("\n\n")
}
DM vs naive: estadístico (p-valor), h = 3
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
ARIMA -2.25 (0.03) -1.80 (0.08) -1.60 (0.12) -2.38 (0.02) -0.14 (0.89) -0.81 (0.42) 0.60 (0.55) 0.80 (0.43) -1.28 (0.21)
VAR-dif -2.61 (0.01) -2.81 (0.01) -2.40 (0.02) -2.82 (0.01) 1.80 (0.08) 0.76 (0.45) -1.73 (0.09) 0.79 (0.44) 0.07 (0.95)
VAR-niv -2.11 (0.04) -2.74 (0.01) -1.54 (0.13) -2.53 (0.01) 1.05 (0.30) 3.83 (0.00) -0.96 (0.34) 2.03 (0.05) 0.99 (0.33)
VECM -2.54 (0.01) -2.81 (0.01) -2.29 (0.03) -2.80 (0.01) 1.43 (0.16) 1.11 (0.27) -1.58 (0.12) 0.70 (0.49) 0.62 (0.54)
PCA-AR 1.19 (0.24) -1.86 (0.07) 2.40 (0.02) -1.12 (0.26) -0.80 (0.43) 0.25 (0.81) 0.87 (0.39) -0.61 (0.54) 0.30 (0.77)
PCA-VAR -1.82 (0.07) -2.22 (0.03) -1.50 (0.14) -2.09 (0.04) 1.11 (0.27) 2.54 (0.01) 0.55 (0.58) 0.37 (0.72) 0.84 (0.41)
EN-tasas -2.12 (0.04) -2.75 (0.01) -1.83 (0.07) -2.46 (0.02) 0.98 (0.33) 1.53 (0.13) 0.19 (0.85) 1.30 (0.20) 0.53 (0.60)
EN-PCA -2.20 (0.03) -2.61 (0.01) -2.01 (0.05) -2.50 (0.02) 1.52 (0.13) 1.23 (0.23) 0.24 (0.81) 0.21 (0.83) 0.82 (0.42)
SVR-tasas -2.02 (0.05) -2.82 (0.01) -1.97 (0.05) -2.68 (0.01) 0.87 (0.39) 0.25 (0.80) 0.25 (0.80) 0.88 (0.38) 0.54 (0.59)
SVR-PCA -1.96 (0.05) -2.45 (0.02) -1.44 (0.15) -2.29 (0.03) 0.38 (0.71) 1.18 (0.24) 0.85 (0.40) 0.98 (0.33) 1.43 (0.16)
DM vs naive: estadístico (p-valor), h = 12
modelo c182 c28 c364 c91 m10 m20 m3 m30 m5
ARIMA -1.08 (0.28) -0.16 (0.87) -0.15 (0.88) -0.83 (0.41) -0.19 (0.85) -0.08 (0.94) 0.56 (0.58) 6.28 (0.00) -0.56 (0.58)
VAR-dif -1.97 (0.05) -2.17 (0.03) -1.82 (0.07) -2.09 (0.04) 1.61 (0.11) -0.15 (0.88) -0.79 (0.43) 1.71 (0.09) -0.02 (0.99)
VAR-niv -1.94 (0.06) -1.86 (0.07) -1.90 (0.06) -1.89 (0.06) 1.55 (0.13) 13.15 (0.00) -0.68 (0.50) 2.63 (0.01) 2.20 (0.03)
VECM -2.07 (0.04) -2.28 (0.03) -1.92 (0.06) -2.20 (0.03) -0.03 (0.98) 0.06 (0.95) -1.00 (0.32) 0.79 (0.44) 0.43 (0.67)
PCA-AR -0.68 (0.50) -1.33 (0.19) 0.45 (0.66) -1.16 (0.25) -0.39 (0.70) 0.44 (0.66) 0.82 (0.42) 0.87 (0.39) 0.51 (0.61)
PCA-VAR -1.79 (0.08) -1.65 (0.11) -1.78 (0.08) -1.71 (0.09) 8.06 (0.00) 5.85 (0.00) 0.22 (0.83) 3.13 (0.00) 1.41 (0.16)
EN-tasas -0.99 (0.33) -1.45 (0.15) -0.63 (0.53) -1.23 (0.23) 1.17 (0.25) 1.15 (0.26) 0.84 (0.41) 2.02 (0.05) 1.32 (0.19)
EN-PCA -1.49 (0.14) -1.91 (0.06) -0.89 (0.38) -1.77 (0.08) 2.81 (0.01) 3.97 (0.00) 1.38 (0.17) 2.53 (0.02) 5.60 (0.00)
SVR-tasas -0.20 (0.84) -0.16 (0.87) -0.08 (0.93) -0.14 (0.89) 1.59 (0.12) 3.05 (0.00) 1.56 (0.12) 1.42 (0.16) 2.91 (0.01)
SVR-PCA -1.26 (0.21) -0.92 (0.36) -0.97 (0.34) -1.23 (0.22) 5.36 (0.00) 2.23 (0.03) 2.46 (0.02) 13.01 (0.00) 2.37 (0.02)

6.4 P3: ¿la reducción de dimensionalidad mejora los pronósticos?

Comparamos cada modelo directo con su análogo de factores, en prueba y con la configuración elegida en validación.

Mostrar código
pares <- tibble::tribble(
  ~directo,   ~factores,
  "ARIMA",    "PCA-AR",
  "VAR-dif",  "PCA-VAR",
  "VAR-niv",  "PCA-VAR",
  "EN-tasas", "EN-PCA",
  "SVR-tasas","SVR-PCA"
)
p3 <- pares |>
  tidyr::crossing(h = H) |>
  left_join(resumen_global |> mutate(modelo = as.character(modelo)) |>
              dplyr::select(directo = modelo, h, rel_directo = rel_rmse_medio), by = c("directo", "h")) |>
  left_join(resumen_global |> mutate(modelo = as.character(modelo)) |>
              dplyr::select(factores = modelo, h, rel_factores = rel_rmse_medio), by = c("factores", "h")) |>
  mutate(instrumentos_factores_mejor = pmap_int(list(directo, factores, h), function(md, mf, hh) {
    a <- prueba_sel |> dplyr::filter(modelo == md, h == hh) |> dplyr::select(codigo, rmse_d = rmse)
    b <- prueba_sel |> dplyr::filter(modelo == mf, h == hh) |> dplyr::select(codigo, rmse_f = rmse)
    m <- inner_join(a, b, by = "codigo")
    sum(m$rmse_f < m$rmse_d)
  })) |>
  arrange(h)
kbl(p3, caption = "RMSE relativo al naive (promedio de instrumentos): modelos directos vs de factores, periodo de prueba")
RMSE relativo al naive (promedio de instrumentos): modelos directos vs de factores, periodo de prueba
directo factores h rel_directo rel_factores instrumentos_factores_mejor
ARIMA PCA-AR 3 0.851 1.000 2
EN-tasas EN-PCA 3 0.854 0.853 5
SVR-tasas SVR-PCA 3 0.972 0.965 4
VAR-dif PCA-VAR 3 0.906 0.911 6
VAR-niv PCA-VAR 3 0.848 0.911 2
ARIMA PCA-AR 12 0.973 1.001 3
EN-tasas EN-PCA 12 1.023 1.028 5
SVR-tasas SVR-PCA 12 1.082 1.076 5
VAR-dif PCA-VAR 12 0.968 0.980 4
VAR-niv PCA-VAR 12 0.916 0.980 2
Mostrar código
mejor_econ <- resumen_global |> dplyr::filter(h == 12, modelo %in% c("ARIMA", "VAR-dif", "VAR-niv", "VECM", "PCA-AR", "PCA-VAR")) |>
  slice_min(rel_rmse_medio, n = 1) |> pull(modelo) |> as.character()
mejor_ml <- resumen_global |> dplyr::filter(h == 12, str_detect(modelo, "EN|SVR")) |>
  slice_min(rel_rmse_medio, n = 1) |> pull(modelo) |> as.character()

eval_df |>
  dplyr::filter(periodo == "prueba", h == 12, codigo %in% c("c28", "m10"),
                modelo %in% c("Naive", mejor_econ, mejor_ml)) |>
  semi_join(seleccion, by = c("modelo", "config", "h")) |>
  mutate(fecha_objetivo = meses[objetivo]) |>
  ggplot(aes(fecha_objetivo)) +
  geom_line(aes(y = real), colour = "black", linewidth = 0.6) +
  geom_point(aes(y = pronostico, colour = modelo), size = 1) +
  facet_wrap(~ factor(codigo, levels = codigos), ncol = 1, scales = "free_y") +
  labs(x = "Mes objetivo", y = "%", colour = NULL)
Figure 7: Pronósticos a 12 meses vs observado (periodo de prueba), para 28 días y 10 años. Cada punto es el pronóstico hecho 12 meses antes.

6.5 Sensibilidad al número de factores

Para cada modelo de factores y cada \(k\), se toma la mejor configuración en validación y se reporta su RMSE relativo medio en validación y en prueba. Es un diagnóstico: la selección oficial sigue siendo la de 6.1.

Mostrar código
sens_k <- metricas |>
  dplyr::filter(modelo %in% c("PCA-AR", "PCA-VAR", "EN-PCA", "SVR-PCA")) |>
  mutate(k = as.integer(sub("^k(\\d+).*", "\\1", config))) |>
  group_by(modelo, config, k, h, periodo) |>
  summarise(rel = mean(rel_rmse)) |>
  ungroup() |>
  pivot_wider(names_from = periodo, values_from = rel) |>
  group_by(modelo, k, h) |>
  slice_min(validacion, n = 1, with_ties = FALSE) |>
  ungroup() |>
  dplyr::select(modelo, h, k, validacion, prueba) |>
  arrange(modelo, h, k)

kbl(sens_k |>
      mutate(celda = sprintf("%.3f / %.3f", validacion, prueba)) |>
      dplyr::select(modelo, h, k, celda) |>
      pivot_wider(names_from = k, names_prefix = "k=", values_from = celda),
    caption = "RMSE relativo medio al naive: validación / prueba, por número de factores")
RMSE relativo medio al naive: validación / prueba, por número de factores
modelo h k=1 k=2 k=3 k=4 k=5 k=6
EN-PCA 3 0.962 / 0.885 0.946 / 0.863 0.912 / 0.866 0.894 / 0.853 0.907 / 0.847 0.902 / 0.851
EN-PCA 12 1.031 / 0.989 1.017 / 1.004 0.977 / 1.080 0.970 / 1.028 0.987 / 0.991 0.985 / 0.991
PCA-AR 3 1.010 / 1.005 0.992 / 1.019 0.994 / 1.013 0.987 / 1.008 0.981 / 1.007 0.978 / 1.000
PCA-AR 12 0.976 / 0.986 0.947 / 1.007 0.943 / 1.009 0.940 / 1.005 0.936 / 1.003 0.933 / 1.001
PCA-VAR 3 1.010 / 1.005 1.001 / 1.054 0.931 / 0.957 0.927 / 0.922 0.881 / 0.911 0.921 / 0.897
PCA-VAR 12 0.976 / 0.986 0.998 / 1.097 0.966 / 1.028 0.960 / 1.013 0.943 / 0.980 0.954 / 0.981
SVR-PCA 3 1.018 / 0.969 1.016 / 0.970 1.002 / 0.980 0.992 / 0.965 1.008 / 0.975 1.012 / 0.988
SVR-PCA 12 1.087 / 0.977 1.045 / 0.988 1.021 / 1.124 1.012 / 1.076 1.025 / 1.087 1.013 / 1.116
Mostrar código
sens_k |>
  pivot_longer(c(validacion, prueba), names_to = "periodo", values_to = "rel") |>
  mutate(h = paste0("h = ", h)) |>
  ggplot(aes(k, rel, colour = modelo, linetype = periodo)) +
  geom_hline(yintercept = 1, colour = "black") +
  geom_line() + geom_point(size = 1.2) +
  scale_x_continuous(breaks = K_GRID) +
  facet_wrap(~ h) +
  labs(x = "Número de factores k", y = "RMSE / RMSE naive", colour = NULL, linetype = NULL)
Figure 8: RMSE relativo al naive según el número de factores (línea continua = prueba, punteada = validación).

6.6 Robustez: resultados por subperiodo de prueba

Mostrar código
eval_df |>
  dplyr::filter(periodo == "prueba") |>
  semi_join(seleccion, by = c("modelo", "config", "h")) |>
  mutate(subperiodo = if_else(meses[origen] < as.Date("2023-07-01"), "orígenes 2021-01 a 2023-06", "orígenes 2023-07 en adelante")) |>
  group_by(modelo, h, subperiodo, codigo) |>
  summarise(rmse = sqrt(mean(error^2))) |>
  group_by(h, subperiodo, codigo) |>
  mutate(rel = rmse / rmse[modelo == "Naive"]) |>
  group_by(modelo, h, subperiodo) |>
  summarise(rel_rmse_medio = mean(rel)) |>
  pivot_wider(names_from = subperiodo, values_from = rel_rmse_medio) |>
  mutate(modelo = factor(modelo, levels = orden_modelos)) |>
  arrange(h, modelo) |>
  kbl(caption = "RMSE relativo al naive por subperiodo de prueba")
RMSE relativo al naive por subperiodo de prueba
modelo h orígenes 2021-01 a 2023-06 orígenes 2023-07 en adelante
Naive 3 1.000 1.000
ARIMA 3 0.859 0.844
VAR-dif 3 0.916 0.897
VAR-niv 3 0.826 0.886
VECM 3 0.890 0.861
PCA-AR 3 0.984 1.037
PCA-VAR 3 0.860 0.988
EN-tasas 3 0.835 0.881
EN-PCA 3 0.836 0.892
SVR-tasas 3 0.992 0.932
SVR-PCA 3 0.981 0.930
Naive 12 1.000 1.000
ARIMA 12 1.005 0.874
VAR-dif 12 0.972 0.955
VAR-niv 12 0.907 0.945
VECM 12 0.963 0.909
PCA-AR 12 1.012 0.943
PCA-VAR 12 0.956 1.048
EN-tasas 12 1.078 0.829
EN-PCA 12 1.046 0.972
SVR-tasas 12 1.099 1.017
SVR-PCA 12 1.094 1.003

7. Conclusiones

Todas las cifras de esta sección provienen de las tablas anteriores (periodo de prueba, configuraciones elegidas solo con validación).

P1: ¿hay estructura común? Sí, y muy marcada en niveles. Un factor explica ~94 % de la varianza de las nueve tasas y tres factores llegan al 99.4 %. Tienen forma de nivel, pendiente y curvatura (correlaciones de 0.997, 0.62 y 0.91 con sus proxies empíricos), y son robustos al método (panel rellenado, PCA-EM, 40 meses completos) y a la submuestra (nivel y pendiente; la curvatura es menos estable). En cambios mensuales la estructura es mucho más débil: PC1 explica ~54 % y la correlación de cambios entre el CETE de 28 días y el MBONO de 10 años es 0.07. La curva comparte un ciclo de política monetaria en niveles, pero mes a mes la parte corta y la larga responden a choques distintos.

P2: ¿podemos pronosticar la curva a 3 y 12 meses? Solo la parte corta.

  • A \(h=3\), cuatro modelos bajan el RMSE medio relativo al naive a ~0.85: VAR en niveles (0.848), ARIMA (0.851), Elastic Net con factores (0.853) y Elastic Net con tasas (0.854). La ganancia se concentra en los CETEs. Con el VAR en niveles, el RMSE del CETE de 28 días pasa de 0.72 a 0.28 pp, y las diferencias frente al naive son significativas en DM para 28, 91 y 182 días. En los MBONOs (3 a 30 años) ningún modelo le gana al paseo aleatorio de forma consistente.
  • A \(h=12\), el mejor es el VAR en niveles (0.916), seguido del VECM (0.950). Las ganancias en los CETEs son grandes (28 días: 2.74 → 1.63 pp), pero el VAR en niveles empeora en 10, 20 y 30 años, y la significancia es marginal (p ≈ 0.06–0.07). Los modelos de ML no le ganan al naive a 12 meses.
  • El VAR en niveles y el VECM le ganan al naive en los dos subperiodos de prueba y en ambos horizontes. El ML a 12 meses depende del subperiodo: Elastic Net con tasas pasa de 1.08 en 2021–23 a 0.83 desde 2023-07.

P3: ¿la estructura mejora el pronóstico? No de forma consistente.

  • Los modelos econométricos de factores pierden frente a sus análogos directos: VAR de factores 0.911/0.980 contra VAR en niveles 0.848/0.916; AR por factor ≈ naive (1.000/1.001) contra ARIMA 0.851/0.973. Frente al VAR en diferencias, el VAR de factores prácticamente empata (0.906/0.968).
  • En ML, usar factores o tasas como predictores da prácticamente lo mismo (Elastic Net 0.853 vs 0.854 a \(h=3\); SVR 0.965 vs 0.972). Esto es consistente con que Elastic Net ya regulariza predictores muy colineales.
  • La sensibilidad a \(k\) muestra que los modelos de factores mejoran al agregar componentes más allá de los tres que explican el 99.4 % de la varianza. La validación elige \(k=5\) para el VAR de factores y \(k=4\) para Elastic Net y SVR. La información predictiva no está concentrada en los componentes de mayor varianza: está en la dinámica propia de la parte corta, que el PCA de covarianzas mezcla con el resto de la curva.
  • Conclusión: la estructura de P1 describe muy bien la curva, pero comprimirla no mejora el pronóstico. La utilidad de los factores aquí es interpretativa (y de parsimonia: un VAR(1) de 5 factores tiene 30 parámetros contra 90 o más de un VAR de 9 tasas), no predictiva.

Limitaciones. El periodo de prueba es esencialmente un solo ciclo de política (alzas de 2021–23, recortes de 2024–26). El DM en los MBONOs largos usa series de errores con huecos, y algunos estadísticos extremos (>5) reflejan varianzas mal estimadas, no evidencia. El relleno por diferencial puede usar diferenciales de hasta 6 meses de antigüedad. En el AR por factor, la validación eligió \(k=6\), el borde de la rejilla, pero su desempeño es casi plano en \(k\) y en prueba siempre ≈ naive.

Mostrar código
sessionInfo()
R version 4.4.1 (2024-06-14 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default


locale:
[1] LC_COLLATE=Spanish_Mexico.utf8  LC_CTYPE=Spanish_Mexico.utf8   
[3] LC_MONETARY=Spanish_Mexico.utf8 LC_NUMERIC=C                   
[5] LC_TIME=Spanish_Mexico.utf8    

time zone: America/Mexico_City
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] scales_1.4.0      knitr_1.51        lubridate_1.9.3   forcats_1.0.0    
 [5] stringr_1.5.1     dplyr_1.1.4       purrr_1.0.2       readr_2.1.5      
 [9] tidyr_1.3.1       tibble_3.2.1      ggplot2_4.0.3     tidyverse_2.0.0  
[13] e1071_1.7-14      glmnet_4.1-8      Matrix_1.7-0      tseries_0.10-56  
[17] vars_1.6-1        lmtest_0.9-40     urca_1.3-4        strucchange_1.5-3
[21] sandwich_3.1-0    zoo_1.8-12        MASS_7.3-60.2     forecast_8.23.0  

loaded via a namespace (and not attached):
 [1] gtable_0.3.6       shape_1.4.6.1      xfun_0.52          htmlwidgets_1.6.4 
 [5] lattice_0.22-6     tzdb_0.4.0         quadprog_1.5-8     vctrs_0.6.5       
 [9] tools_4.4.1        generics_0.1.4     curl_5.2.1         parallel_4.4.1    
[13] proxy_0.4-27       xts_0.14.0         pkgconfig_2.0.3    RColorBrewer_1.1-3
[17] S7_0.2.1           lifecycle_1.0.5    compiler_4.4.1     farver_2.1.2      
[21] codetools_0.2-20   htmltools_0.5.8.1  class_7.3-22       yaml_2.3.10       
[25] crayon_1.5.3       pillar_1.11.1      iterators_1.0.14   foreach_1.5.2     
[29] nlme_3.1-164       fracdiff_1.5-3     tidyselect_1.2.1   digest_0.6.36     
[33] stringi_1.8.4      labeling_0.4.3     splines_4.4.1      fastmap_1.2.0     
[37] grid_4.4.1         colorspace_2.1-1   cli_3.6.3          magrittr_2.0.3    
[41] patchwork_1.2.0    survival_3.6-4     withr_3.0.2        bit64_4.0.5       
[45] timechange_0.3.0   TTR_0.24.4         rmarkdown_2.27     bit_4.0.5         
[49] quantmod_0.4.26    nnet_7.3-19        timeDate_4032.109  hms_1.1.4         
[53] evaluate_0.24.0    viridisLite_0.4.2  rlang_1.2.0        Rcpp_1.0.13       
[57] glue_1.7.0         vroom_1.6.5        rstudioapi_0.16.0  jsonlite_1.8.8    
[61] R6_2.6.1