Nikolas Cantillo
Caso papa Yungay / NotebookDeepAR · cuantiles · Brier · costo asimétrico

El notebook, celda por celda

El capítulo de riesgo del proyecto tal cual fue desarrollado: como ningún modelo supera a ARIMA en el pronóstico puntual a cinco meses, se pasa a estimar el riesgo de precios bajos con una banda de cuantiles calibrada y una probabilidad P(precio < T). El cómputo pesado vive en un script del repositorio; aquí se cargan e interpretan sus resultados. Se omite la cabecera del notebook.

In [1]Python
import warnings, numpy as np, pandas as pd, matplotlib.pyplot as plt
from pathlib import Path
warnings.filterwarnings('ignore')
RES  = Path('../reports/results'); FIGS = Path('../reports/figures'); FIGS.mkdir(parents=True, exist_ok=True)
T = 0.8                       # umbral de rentabilidad (S/.kg) — parametrizable (regenerar con RIESGO_T=<v>)
H = 5                         # horizonte (meses)
CAL_START = pd.Timestamp('2021-01-01')   # frontera régimen "reciente" (calibración OOS)
COL = dict(band='#c0392b', p50='#c0392b', real='black', T='#e67e22', clim='#7f8c8d', deepar='#27ae60')
plt.rcParams.update({'figure.facecolor':'white','axes.facecolor':'#f8f9fa','axes.grid':True,
                     'grid.alpha':0.4,'font.size':11,'axes.titlesize':12})
print('Resultados en', RES.resolve(), '| T =', T, 'S/.kg')
Out
Resultados en ../reports/results | T = 0.8 S/.kg

2. Del punto al riesgo — replanteo

El bake-off riguroso (nb05 §5) dejó una conclusión incómoda pero honesta: a 5 meses nada bate a ARIMA en MAE puntual, porque Yungay se comporta casi como una caminata aleatoria. Seguir intentando acertar el precio no lleva a ninguna parte.

El replanteo es lo que aporta este capítulo:

  • Un productor o comprador no necesita el número exacto. Necesita saber cuán probable es un precio no rentable (precio < T) y hasta dónde podría caer en un escenario pesimista (P10/P20).
  • Eso ya no es un problema de punto, sino de cuantiles y probabilidad. Y aquí la incertidumbre sí se puede estimar, porque la volatilidad y la cola baja son más predecibles que la media.
  • Las métricas que usamos son propias del riesgo, no el MAE: la pérdida pinball (cuantiles), la cobertura (calibración) y, para la alarma binaria precio < T, Brier, precision y recall. Todas son proper scoring rules.

DeepAR encaja bien porque es probabilístico por diseño: modela una distribución completa en lugar de un punto. Lo corremos en frecuencia mensual (~155 puntos, con lo que el modelo es pequeño y GPU-seguro), y de su distribución derivamos P10/P20/P50/P90 y P(precio < T) mes a mes.

3. Datos y umbral de rentabilidad T

In [2]Python
raw = pd.read_csv('../data/raw/prices/papa_precios_diarios.csv', parse_dates=['date'])
s = raw[['date','papa_yungay']].dropna().rename(columns={'date':'ds','papa_yungay':'y'}).sort_values('ds')
full = pd.date_range(s.ds.min(), s.ds.max(), freq='D')
yv = s.set_index('ds')['y'].reindex(full).interpolate('linear', limit=7, limit_direction='forward').dropna().resample('MS').mean().dropna()
pre = yv[yv.index < CAL_START]; rec = yv[yv.index >= CAL_START]
print(f'Yungay mensual: {len(yv)} meses | media S/ {yv.mean():.3f}/kg')
print(f'% meses < T={T}:  histórico(<2021)={100*(pre<T).mean():.1f}%   reciente(>=2021)={100*(rec<T).mean():.1f}%')

fig, ax = plt.subplots(figsize=(13,4))
ax.plot(yv.index, yv.values, color='#34495e', lw=1)
low = yv[yv < T]; ax.scatter(low.index, low.values, color=COL['T'], s=18, zorder=3, label=f'precio < T ({len(low)} meses)')
ax.axhline(T, color=COL['T'], ls='--', lw=1.2, label=f'umbral T = {T} S/.kg')
ax.axvline(CAL_START, color='#2c3e50', ls=':', lw=1, label='régimen reciente (>=2021)')
ax.set_title('Papa Yungay mensual — meses "no rentables" (precio < T)'); ax.set_ylabel('S/.kg'); ax.legend(loc='upper left', fontsize=9)
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_serie_umbral.png', dpi=150, bbox_inches='tight'); plt.show()
Out
Yungay mensual: 155 meses | media S/ 1.027/kg
% meses < T=0.8:  histórico(<2021)=56.2%   reciente(>=2021)=7.6%
Output gráfico de la celda 2

El umbral T=0.8 deja al descubierto un cambio de régimen: antes de 2021 los precios por debajo de 0.8 eran frecuentes, mientras que desde 2021 el precio rara vez baja de ese nivel. La consecuencia es importante. Una alarma que se basara en la frecuencia histórica (la climatología) quedaría anclada al régimen viejo y fallaría hoy, que es precisamente lo que un modelo condicional como DeepAR debería corregir (§6). T es parametrizable, así que conviene subirlo hasta tu costo real.

4. Modelo de cuantiles (DeepAR mensual)

DeepAR ajusta una distribución en cada paso (aquí Normal, vía DistributionLoss), de la que neuralforecast expone un abanico de cuantiles (lo/hi por nivel). A partir de ahí derivamos:

  • P10 / P20, el escenario pesimista, la banda baja: "con 80–90% de confianza el precio no será menor que…".
  • P50, la mediana, que sirve de referencia central.
  • P(precio < T), la probabilidad de no-rentabilidad, que obtenemos interpolando dónde el abanico de cuantiles cruza T (esto equivale aproximadamente a la CDF evaluada en T).

Protocolo (anti-fuga, GPU-seguro): se entrena con los datos antiguos (< 2021) y se condiciona sobre 61 orígenes mensuales posteriores (out-of-sample) para evaluar la calibración; un segundo modelo, entrenado en todo el dev (< 2026-01), produce el pronóstico operativo 2026. Como todo es mensual, la GPU no se cuelga.

5. Calibración de la banda baja (P10/P20)

In [3]Python
cal = pd.read_csv(f'{RES}/yungay_riesgo_calib.csv')
met = pd.read_csv(f'{RES}/yungay_riesgo_metrics.csv')
print(met.to_string(index=False))
nom = [0.10,0.20,0.50,0.90]
emp = [float((cal.actual<cal.P10).mean()), float((cal.actual<cal.P20).mean()),
       float((cal.actual<cal.P50).mean()), float((cal.actual<cal.P90).mean())]
fig, ax = plt.subplots(1,2, figsize=(13,4.6))
ax[0].plot([0,1],[0,1], color='#95a5a6', ls='--', label='calibración perfecta')
ax[0].plot(nom, emp, 'o-', color=COL['deepar'], lw=1.8, ms=8, label='DeepAR (empírico)')
for n,e in zip(nom,emp): ax[0].annotate(f'{e:.2f}', (n,e), textcoords='offset points', xytext=(6,-10), fontsize=8)
ax[0].set_xlabel('cuantil nominal'); ax[0].set_ylabel('frecuencia empírica (actual < cuantil)')
ax[0].set_title('Diagrama de calibración — cobertura ≈ nominal'); ax[0].legend(fontsize=9)
# banda baja vs realidad sobre los orígenes (ordenados por P50)
d = cal.sort_values('P50').reset_index(drop=True); x = np.arange(len(d))
ax[1].fill_between(x, d.P10, d.P90, color=COL['band'], alpha=0.12, label='banda P10–P90')
ax[1].plot(x, d.P20, color=COL['band'], lw=0.8, ls='--', alpha=0.7, label='P20 (pesimista)')
ax[1].plot(x, d.P50, color=COL['band'], lw=1.2, label='P50')
ax[1].scatter(x, d.actual, s=8, color='black', alpha=0.5, label='real')
ax[1].axhline(T, color=COL['T'], ls='--', lw=1, label=f'T={T}')
ax[1].set_xlabel('orígenes × pasos (ordenados por P50)'); ax[1].set_ylabel('S/.kg')
ax[1].set_title('Banda predictiva vs realidad (305 casos OOS)'); ax[1].legend(fontsize=8, loc='upper left')
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_calibracion.png', dpi=150, bbox_inches='tight'); plt.show()
Out
metric   deepar  baseline
       pinball@P10   0.0709    0.0702
       pinball@P20   0.1048       NaN
     cobertura<P10   0.1280    0.1000
     cobertura<P20   0.2030    0.2000
      alarma Brier   0.1041    0.3095
  alarma precision   0.1540       NaN
     alarma recall   0.4000       NaN
tasa base (P(y<T))   0.0490    0.5620
 n (origen x paso) 305.0000       NaN
Output gráfico de la celda 3

La banda queda bien calibrada: la cobertura empírica sigue de cerca a la nominal (<P20 ≈ 0.20 exacto; <P10 ≈ 0.13, algo sub-cubierto pero cercano). Dicho de otro modo, cuando el modelo afirma "el precio no bajará de P20 con 80% de confianza", acierta esa frecuencia. La pérdida pinball del P10 es comparable a la de un cuantil climatológico plano, con una diferencia que importa para la alarma (§6): la banda de DeepAR es condicional y se mueve con el estado reciente, mientras que la climatológica no.

6. La alarma P(precio < T) vs climatología

In [4]Python
th = pd.read_csv(f'{RES}/yungay_riesgo_threshold.csv')
b_d = float(met.loc[met.metric=='alarma Brier','deepar'].iloc[0]); b_c = float(met.loc[met.metric=='alarma Brier','baseline'].iloc[0])
fig, ax = plt.subplots(1, 2, figsize=(13, 4.6))
ax[0].axhline(0, color='#95a5a6', ls='--')
ax[0].plot(th['T'], th.bss_never, 'o-', color=COL['deepar'], lw=1.9, label='BSS vs "nunca alarmar"')
ax[0].plot(th['T'], th.bss_clim, 's--', color=COL['clim'], lw=1.5, label='BSS vs climatología')
ax[0].axvspan(0.85, th['T'].max(), color='#27ae60', alpha=0.06)
ax[0].axvline(T, color=COL['T'], ls=':', lw=1.2, label=f'T={T} (default)')
ax[0].set_ylim(-1.3, 0.8); ax[0].set_xlabel('umbral T (S/.kg)'); ax[0].set_ylabel('Brier Skill Score (↑ mejor)')
ax[0].set_title('Habilidad de la alarma vs umbral\n(BSS>0 = aporta sobre el baseline trivial)'); ax[0].legend(fontsize=8)
ax[1].plot(th['T'], th.base_rate, 'o-', color='#2c3e50', lw=1.6, label='frecuencia real P(precio<T)')
ax[1].plot(th['T'], th.mean_prob, 's--', color=COL['deepar'], lw=1.5, label='prob. media predicha')
ax[1].axvline(T, color=COL['T'], ls=':', lw=1.2); ax[1].set_xlabel('umbral T (S/.kg)'); ax[1].set_ylabel('probabilidad')
ax[1].set_title('Confiabilidad agregada — predicho ≈ observado'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_alarma.png', dpi=150, bbox_inches='tight'); plt.show()
print(th.to_string(index=False))
Out
Output gráfico de la celda 4
T  base_rate  brier_deepar  brier_clim  bss_never  bss_clim  mean_prob
0.60      0.000        0.0235      0.0668        NaN     0.649      0.069
0.65      0.003        0.0362      0.1302    -10.056     0.722      0.091
0.70      0.003        0.0509      0.2229    -14.515     0.772      0.119
0.75      0.003        0.0694      0.2787    -20.157     0.751      0.152
0.80      0.049        0.1041      0.3095     -1.117     0.664      0.192
0.85      0.164        0.1213      0.3331      0.260     0.636      0.237
0.90      0.230        0.1603      0.3277      0.301     0.511      0.287
0.95      0.311        0.1886      0.3154      0.395     0.402      0.343
1.00      0.377        0.2191      0.3300      0.419     0.336      0.400
1.05      0.426        0.2213      0.3370      0.481     0.343      0.459
1.10      0.492        0.2313      0.3181      0.530     0.273      0.517
1.15      0.607        0.2108      0.2671      0.652     0.211      0.574
1.20      0.639        0.2354      0.2557      0.632     0.079      0.629
1.25      0.656        0.2342      0.2492      0.643     0.060      0.679
1.30      0.672        0.2417      0.2495      0.640     0.031      0.725
1.35      0.689        0.2368      0.2457      0.656     0.036      0.765
1.40      0.725        0.2369      0.2299      0.673    -0.030      0.801

Hay que ser honestos: la habilidad de la alarma depende del umbral en relación con el régimen. En T=0.8 los meses no rentables son tan raros hoy (~5%) que la alarma no supera al baseline trivial de "nunca alarmar" (BSS<0). Es el ataque que cabría esperar de un jurado, y lo reconocemos. Ahora bien, en umbrales operativamente relevantes (T≥0.9, cerca del piso de precios reciente, la zona sombreada) el BSS sube a 0.4–0.5 y la alarma siempre supera a la climatología. La probabilidad media predicha sigue a la frecuencia real (panel derecho), lo que indica que las probabilidades están bien calibradas. La lección práctica es fijar T en tu costo real de rentabilidad: el modelo aporta cuando el riesgo de precios bajos deja de ser trivial (§9 lo confirma con backtest y costo-beneficio).

7. Pronóstico operativo 2026

In [5]Python
f = pd.read_csv(f'{RES}/yungay_riesgo_forecast.csv', parse_dates=['ds'])
ctx = yv.iloc[-15:]
fig, ax = plt.subplots(1,2, figsize=(14,4.8))
ax[0].plot(ctx.index, ctx.values, color='#7f8c8d', lw=1, marker='.', label='histórico')
ax[0].fill_between(f.ds, f.P10, f.P90, color=COL['band'], alpha=0.15, label='banda P10–P90')
ax[0].plot(f.ds, f.P20, color=COL['band'], ls='--', lw=1, label='P20 (pesimista)')
ax[0].plot(f.ds, f.P50, color=COL['band'], lw=1.8, marker='o', ms=5, label='P50 (mediana)')
ax[0].plot(f.ds, f.real, color='black', lw=1.3, marker='s', ms=5, label='real')
ax[0].axhline(T, color=COL['T'], ls='--', lw=1.2, label=f'T={T}')
ax[0].set_title('Pronóstico probabilístico 2026 (5 meses)'); ax[0].set_ylabel('S/.kg'); ax[0].legend(fontsize=8)
ax[1].bar(range(len(f)), f.prob_below_T, color=COL['T'], alpha=0.85)
ax[1].set_xticks(range(len(f))); ax[1].set_xticklabels([d.strftime('%Y-%m') for d in f.ds], rotation=30, fontsize=8)
ax[1].set_ylabel(f'P(precio < {T})'); ax[1].set_title('Probabilidad de mes no rentable')
for i,p in enumerate(f.prob_below_T): ax[1].annotate(f'{p:.0%}', (i,p), textcoords='offset points', xytext=(0,3), ha='center', fontsize=8)
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_forecast.png', dpi=150, bbox_inches='tight'); plt.show()
print(f[['ds','P10','P20','P50','P90','prob_below_T','real']].round(3).to_string(index=False))
Out
Output gráfico de la celda 5
ds   P10   P20   P50   P90  prob_below_T  real
2026-01-01 0.766 0.855 0.997 1.248         0.126 0.994
2026-02-01 0.959 1.042 1.200 1.390         0.010 1.112
2026-03-01 1.303 1.351 1.522 1.705         0.010 1.854
2026-04-01 1.520 1.582 1.727 2.014         0.010 1.862
2026-05-01 1.377 1.460 1.630 1.890         0.010 1.417

Para 2026 el modelo asignó riesgo de no-rentabilidad únicamente al primer mes (P(precio<0.8)≈13% en enero, cuando la serie venía de ~1.0) y cerca de 1% en los meses siguientes, al proyectar la subida estacional. Los datos confirmaron la señal: ningún mes cayó por debajo de 0.8 (los reales fueron 0.99, 1.11, 1.85, 1.86, 1.42). La banda P10–P90 contuvo los valores observados y el P50 capturó la dirección de la subida, aunque se quedó corto en el pico de marzo–abril. Esto último es esperable, ya que acertar el nivel exacto está fuera de alcance.

8. Capa de decisión — costo asimétrico

In [6]Python
# Dado un costo de "perder" un mes no rentable (C_miss) >> falsa alarma (C_fa), ¿en qué prob. disparar la alarma?
prob = cal.prob_below_T.values; event = (cal.actual < T).values.astype(int)
C_miss, C_fa = 5.0, 1.0                       # ejemplo: omitir un mes malo cuesta 5x una falsa alarma
taus = np.linspace(0.02, 0.95, 47)
cost = [ (event*(prob < t)*C_miss + (1-event)*(prob >= t)*C_fa).mean() for t in taus ]
tau_opt = float(taus[int(np.argmin(cost))]); tau_bayes = C_fa/(C_fa+C_miss)
fig, ax = plt.subplots(figsize=(8,4.6))
ax.plot(taus, cost, color='#2c3e50', lw=1.8)
ax.axvline(tau_opt, color=COL['deepar'], ls='--', lw=1.4, label=f'τ* empírico = {tau_opt:.2f}')
ax.axvline(tau_bayes, color=COL['T'], ls=':', lw=1.4, label=f'τ* Bayes = C_fa/(C_fa+C_miss) = {tau_bayes:.2f}')
ax.set_xlabel('umbral de alarma τ  (disparar si P(precio<T) ≥ τ)'); ax.set_ylabel('costo esperado / mes')
ax.set_title(f'Decisión con costo asimétrico (C_miss:C_fa = {C_miss:.0f}:{C_fa:.0f})'); ax.legend()
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_decision.png', dpi=150, bbox_inches='tight'); plt.show()
print(f'Con costos {C_miss:.0f}:{C_fa:.0f}, disparar la alarma cuando P(precio<{T}) ≥ {tau_opt:.2f} (no en 0.5).')
Out
Output gráfico de la celda 6
Con costos 5:1, disparar la alarma cuando P(precio<0.8) ≥ 0.79 (no en 0.5).

La alarma no conviene dispararla en P=0.5. Cuando los costos son asimétricos (omitir un mes no rentable cuesta más que una falsa alarma), el umbral óptimo τ* se mueve hacia C_fa/(C_fa+C_miss), de modo que toleramos más falsas alarmas con tal de no perdernos los meses malos, es decir, ganamos recall. La probabilidad calibrada de §6 es el insumo que vuelve posible esta decisión basada en costos, algo que un pronóstico puntual no permite.

9. Robustez de la alarma — ¿funciona el detector? (Fase 0)

Aquí ponemos a prueba el detector con tres ejercicios pensados para resistir el escrutinio del jurado: (1) la curva precisión-recall de la alarma binaria; (2) un backtest pre-2021, un régimen que sí contiene eventos sub-T, lo que responde a la objeción de "lo validaste sin incendios"; (3) un cálculo de costo-beneficio en soles, donde actuar cuesta C_fa y no actuar ante un mes malo cuesta C_miss. A esto sumamos IC95 block-bootstrap (por origen) de las métricas de calibración.

In [7]Python
pr = pd.read_csv(f'{RES}/yungay_riesgo_pr.csv')
co = pd.read_csv(f'{RES}/yungay_riesgo_costos.csv')
bo = pd.read_csv(f'{RES}/yungay_riesgo_bootstrap.csv')
bt = pd.read_csv(f'{RES}/yungay_riesgo_backtest.csv')
C_MISS, C_FA = 5.0, 1.0
fig, ax = plt.subplots(1, 3, figsize=(16, 4.6))
for tag, c in [('pre-2021 (con eventos)', '#27ae60'), ('2021+ (régimen actual)', '#7f8c8d')]:
    g = pr[pr.conjunto == tag].dropna(subset=['precision', 'recall'])
    ax[0].plot(g.recall, g.precision, 'o-', color=c, ms=4, label=tag)
br = float((bt.actual < T).mean())
ax[0].axhline(br, color='#c0392b', ls=':', lw=1, label=f'azar pre-2021 ({br:.2f})')
ax[0].set_xlabel('recall'); ax[0].set_ylabel('precision'); ax[0].set_title('Curva precisión-recall de la alarma'); ax[0].legend(fontsize=8)
ax[1].plot(co.tau, co.costo_por_mes, color='#2c3e50', lw=1.9, label='modelo (alarma)')
ax[1].axhline(br * C_MISS, color='#c0392b', ls='--', label=f'nunca actuar ({br*C_MISS:.2f})')
ax[1].axhline(C_FA, color=COL['T'], ls=':', label=f'siempre actuar ({C_FA:.2f})')
ts = float(co.loc[co.costo_por_mes.idxmin(), 'tau'])
ax[1].axvline(ts, color='#27ae60', lw=1, alpha=0.6, label=f'τ*={ts:.2f}')
ax[1].set_xlabel('umbral de alarma τ'); ax[1].set_ylabel('costo esperado / mes')
ax[1].set_title(f'Costo-beneficio (backtest pre-2021, C_miss:C_fa={C_MISS:.0f}:{C_FA:.0f})'); ax[1].legend(fontsize=8)
y = np.arange(len(bo))
lo = [float(s.strip('[]').split(',')[0]) for s in bo.IC95]; hi = [float(s.strip('[]').split(',')[1]) for s in bo.IC95]
ax[2].errorbar(bo.valor, y, xerr=[np.array(bo.valor) - lo, np.array(hi) - np.array(bo.valor)], fmt='o', color='#2980b9', capsize=3)
ax[2].set_yticks(y); ax[2].set_yticklabels(bo.metrica, fontsize=7.5); ax[2].set_title('Métricas con IC95 (block-bootstrap)')
plt.tight_layout(); plt.savefig(f'{FIGS}/fig_nb6_robustez.png', dpi=150, bbox_inches='tight'); plt.show()
print(bo.to_string(index=False))
Out
Output gráfico de la celda 7
metrica  valor          IC95  nominal
         cobertura<P10 (2021+)  0.128 [0.072,0.187]      0.1
         cobertura<P20 (2021+)  0.203 [0.141,0.272]      0.2
   Brier alarma (2021+, T=0.8)  0.104 [0.071,0.144]      NaN
Brier alarma (pre-2021, T=0.8)  0.413 [0.327,0.500]      NaN
       BSS vs nunca (pre-2021)  0.279 [0.160,0.397]      NaN

El detector funciona donde efectivamente hay algo que detectar. En el backtest pre-2021 (57% de meses sub-T) la alarma alcanza un BSS=0.28 (IC95 [0.16,0.40], que excluye 0) y una precision ≈0.62, lo que desmonta el reparo de "lo validaste sin eventos". La discriminación binaria es modesta: la curva P-R no se separa demasiado del azar, porque acertar el mes exacto de precio bajo es difícil en una serie casi-aleatoria. El valor reside, entonces, en la probabilidad calibrada y no en la clasificación dura.

En cuanto al valor económico, actuar según la alarma reduce el costo esperado alrededor de 37% frente a ignorar el riesgo (IC95 que excluye 0). Conviene matizar: cuando los eventos son muy frecuentes, la estrategia de "cubrirse siempre" también compite, así que el modelo aporta más en regímenes de riesgo moderado.

Sobre la calibración, los IC95 block-bootstrap (por origen) confirman una cobertura cercana a la nominal, de modo que la banda es fiable y no un artefacto de una sola realización.

10. Conclusiones y próximos pasos

Síntesis

  • El pronóstico por cuantiles sí aporta valor, aun cuando el puntual no logró batir a ARIMA. La banda P10/P20 está calibrada (cobertura cercana a la nominal, con IC bootstrap) y la alarma supera a la climatología en todos los umbrales; su habilidad (BSS) es positiva a umbrales operativos (T≥0.9, BSS 0.4–0.5) y también en el backtest pre-2021 (BSS 0.28), con cerca de 37% de ahorro de costo frente a ignorar el riesgo (§9). Para ser honestos, en T=0.8 (eventos ~5%) no supera al baseline trivial: el valor aparece allí donde el riesgo deja de ser despreciable.
  • El replanteo es la lección de fondo. En una serie casi-aleatoria no se predice el nivel, pero sí el riesgo, y el riesgo es lo que importa al momento de decidir.
  • La capa de decisión con costo asimétrico traduce la probabilidad calibrada en una acción concreta (el umbral τ*).

Límites

  • En T=0.8 los meses no rentables son raros en el régimen actual (~5%), lo que deprime la precision; T debe fijarse según el costo real del productor o comprador (la sensibilidad de §6 ayuda a elegirlo).
  • La muestra mensual es pequeña (~155 meses); la calibración resulta razonable, pero conviene volver a evaluarla con datos nuevos.
  • Se usó una distribución Normal por simplicidad; las colas reales podrían requerir StudentT o cuantiles directos (MQLoss).

Próximos pasos

  1. Ensamble de cuantiles DeepAR + TFT (MQLoss) para una cola baja más robusta.
  2. Recalibración periódica (conformal / Platt) cuando lleguen los datos 2026-Q3.
  3. Integrar τ* y el costo real en un tablero de alerta operativo por mes.

Apéndice — reproducibilidad

python src/modeling/eval_yungay_riesgo.py (env RIESGO_T=<umbral>, default 0.8; CPU/GPU-seguro, mensual) produce yungay_riesgo_{forecast,metrics,calib,threshold}.csv. DeepAR Normal, level=[20..98], input 12 meses, H=5, scaler='identity'. Calibración OOS: train <2021, 61 orígenes mensuales; pronóstico: train <2026-01.

← Volver al casoComentar este trabajo