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.
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')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
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()Yungay mensual: 155 meses | media S/ 1.027/kg % meses < T=0.8: histórico(<2021)=56.2% reciente(>=2021)=7.6%
El umbral
T=0.8deja 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).Tes 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 cruzaT(esto equivale aproximadamente a la CDF evaluada enT).
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)
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()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 NaNLa 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
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))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.8los 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 fijarTen 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
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))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
# 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).')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.
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))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] NaNEl 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.8los meses no rentables son raros en el régimen actual (~5%), lo que deprime la precision;Tdebe 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
Normalpor simplicidad; las colas reales podrían requerirStudentTo cuantiles directos (MQLoss).
Próximos pasos
- Ensamble de cuantiles DeepAR + TFT (
MQLoss) para una cola baja más robusta. - Recalibración periódica (conformal / Platt) cuando lleguen los datos 2026-Q3.
- 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.