import { useEffect, useMemo, useRef, useState } from "react"; import type { Meta } from "./api"; import { CierreDiario, CurvaRezago, nf } from "./charts"; const NOMBRE_MES = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "setiembre", "octubre", "noviembre", "diciembre", ]; const mesLegible = (p: string) => { const [a, m] = p.split("-"); return `${NOMBRE_MES[+m - 1]} ${a}`; }; const FASES = [ { k: "en_cola", t: "En cola" }, { k: "datos", t: "Serie diaria" }, { k: "ajuste", t: "Ajustando GLM" }, { k: "backtest", t: "Validando" }, { k: "rezago", t: "Curva de rezago" }, { k: "listo", t: "Listo" }, ]; export default function Nowcast({ meta }: { meta: Meta }) { const [tipo, setTipo] = useState("HOMICIDIO"); const [sexo, setSexo] = useState(""); const [departamento, setDepartamento] = useState(""); const [lugar, setLugar] = useState(""); const [mes, setMes] = useState(meta.ultimo_mes); const [ventana, setVentana] = useState(24); const [origenes, setOrigenes] = useState(12); const [job, setJob] = useState(null); const [error, setError] = useState(null); const timer = useRef(null); // Los últimos 18 meses con datos, como opciones de mes a cerrar. const meses = useMemo(() => { const out: string[] = []; let [a, m] = meta.ultimo_mes.split("-").map(Number); for (let i = 0; i < 18; i++) { out.push(`${a}-${String(m).padStart(2, "0")}`); m -= 1; if (m === 0) { m = 12; a -= 1; } } return out; }, [meta.ultimo_mes]); const detener = () => { if (timer.current) { window.clearInterval(timer.current); timer.current = null; } }; useEffect(() => detener, []); async function ejecutar() { detener(); setError(null); setJob(null); const p = new URLSearchParams({ mes, ventana: String(ventana), origenes: String(origenes) }); for (const [k, v] of Object.entries({ tipo, sexo, departamento, lugar })) if (v) p.set(k, v); try { const r = await fetch(`/api/nowcast?${p}`, { method: "POST" }); if (!r.ok) throw new Error((await r.json()).detail ?? r.statusText); const j = await r.json(); setJob(j); if (j.estado === "listo") return; timer.current = window.setInterval(async () => { const e = await fetch(`/api/forecast/${j.id}`); if (!e.ok) return; const d = await e.json(); setJob(d); if (d.estado === "listo" || d.estado === "error") detener(); }, 1500); } catch (e: any) { setError(String(e.message ?? e)); } } const r = job?.estado === "listo" ? job.resultado : null; const corriendo = job && (job.estado === "en_cola" || job.estado === "corriendo"); const iFase = FASES.findIndex((f) => f.k === (job?.progreso?.fase ?? null)); const vsAnterior = useMemo(() => { if (!r?.historico_mismo_mes?.length) return null; const prev = r.historico_mismo_mes.at(-1); return { anio: prev.anio, n: prev.n, pct: ((r.total - prev.n) / prev.n) * 100 }; }, [r]); return ( <>
{[ ["MUERTE_VIOLENTA", tipo, setTipo, meta.tipos_muerte, "(todas las defunciones)"], ["SEXO", sexo, setSexo, meta.sexos, "(todos)"], ["DEPARTAMENTO", departamento, setDepartamento, meta.departamentos, "(todos)"], ["TIPO_LUGAR", lugar, setLugar, meta.lugares, "(todos)"], ].map(([lab, val, set, opts, vacio]: any) => (
))}
Ajustes del modelo

24 meses es el valor elegido tras comparar seis ventanas: da el menor sesgo (+0.8%) y una cobertura de intervalos exacta. Usar toda la serie desde 2017 empeora el resultado, porque arrastra una pendiente de largo plazo que ya no describe el nivel actual.

{error &&
No se pudo iniciar: {error}
} {job?.estado === "error" &&
Falló: {job.error}
} {job && (corriendo || r) && (
{FASES.map((f, i) => (
{f.t}
))}

{job.progreso?.mensaje} {job.desde_cache ? " · desde el repositorio" : ""} {job.duracion != null ? ` · ${job.duracion}s` : ""}

)} {r && ( <>
Cierre proyectado · {mesLegible(r.mes)}
{nf.format(r.total)}
intervalo 80%: {nf.format(r.lo80)} – {nf.format(r.hi80)}
Ya registrado
{nf.format(r.observado)}
{r.dias_transcurridos} días, hasta el {r.ultimo_dia_con_datos}
Rezago pendiente
+{nf.format(Math.round(r.rezago_pendiente))}
certificados de días ya transcurridos que aún no entran
Días por venir
+{nf.format(Math.round(r.proyectado_faltantes))}
{r.dias_faltantes} días hasta cerrar el mes
{vsAnterior && (
vs {mesLegible(`${vsAnterior.anio}-${r.mes.split("-")[1]}`)} = 0 ? "up" : "down"}`}> {vsAnterior.pct >= 0 ? "+" : ""}{vsAnterior.pct.toFixed(1)}% ese mes cerró en {nf.format(vsAnterior.n)}
{r.historico_mismo_mes.map((h: any) => (
{h.anio} {nf.format(h.n)} mismo mes
))}
)}

Día a día — {mesLegible(r.mes)}

Azul: lo ya registrado. Azul claro: lo que falta por registrar de esos mismos días. Naranja: los días que aún no ocurren, con su intervalo al 80%.

Registrado Rezago estimado Proyectado

Curva de rezago de registro

Qué porcentaje de los casos de un día ya entró al sistema, según cuántos días hayan pasado. Estimada de los propios datos.

Efecto del día de la semana

Factor multiplicativo respecto al lunes, estimado por el GLM.

{Object.entries(r.efectos_dow).map(([dia, e]: any) => ( ))}
Día Factor Efecto p
{dia}{e.referencia ? " (referencia)" : ""} {e.factor.toFixed(3)} 0 ? "var(--critical)" : e.pct < 0 ? "var(--good)" : "inherit" }}> {e.pct > 0 ? "+" : ""}{e.pct}% {e.p == null ? "—" : e.p < 0.001 ? "<0.001" : e.p.toFixed(3)} {e.p != null && e.p < 0.05 ? " *" : ""}

Metodología

El problema
SINADEF publica por fecha de ocurrencia, pero los certificados llegan con retraso. Los últimos días siempre aparecen incompletos: hoy el día más reciente muestra una fracción de lo que terminará mostrando. Proyectar el mes en curso sin corregir eso subestima el resultado de forma sistemática.
Modelo
GLM de binomial negativa con enlace logarítmico sobre el conteo diario. Predictores: día de la semana (indicadoras), tendencia lineal y cuadrática, y estacionalidad anual mediante armónicos de Fourier. Se usa binomial negativa y no Poisson porque los conteos están sobredispersos (varianza/media ≈ 1.4); asumir Poisson produciría intervalos demasiado estrechos. Es la misma familia del algoritmo de Farrington, estándar en vigilancia epidemiológica europea. {" "}Ajustado con {r.modelo.dias_entrenamiento} días ({r.modelo.desde} a {r.modelo.hasta}), α = {r.modelo.alpha}.
Corrección de rezago
El modelo se ajusta únicamente con días consolidados (más de 21 días de antigüedad), de modo que no aprende la caída artificial del final de la serie. Después se compara lo observado en los días recientes contra lo que el modelo esperaba: ese cociente estima la fracción ya registrada. Sobre esos valores se aplica regresión isotónica, que impone lo único que se sabe con certeza — un caso no puede desregistrarse al envejecer. Es el razonamiento del ajuste por truncamiento a la derecha usado en vigilancia epidemiológica, adaptado a que aquí no existe una fecha de reporte en los datos.
Cómo se imputa
Lo pendiente de cada día se estima como μ × (1 − fracción registrada), donde μ es lo que el modelo espera para ese día. No se usa el cociente observado ÷ fracción, que falla justo donde más importa: el día más reciente suele tener cero certificados cargados, y cero dividido por cualquier fracción sigue siendo cero — ese día quedaría como si no hubiera ocurrido nada. Con la forma aditiva, un día sin registros aporta igualmente su valor esperado pendiente, y se ve en el gráfico como una barra íntegramente celeste.
Composición del resultado
{nf.format(r.observado)} ya registrados {" + "}{Math.round(r.rezago_pendiente)} de rezago pendiente {" + "}{Math.round(r.proyectado_faltantes)} de los {r.dias_faltantes} días que faltan {" = "}{nf.format(r.total)}.
Validación
Origen móvil con {r.backtest.n_origenes} ventanas, evaluando solo contra días ya consolidados — medir contra días incompletos evaluaría el rezago y no el modelo. Error absoluto medio: {r.backtest.mae_diario} casos/día. Error de la ventana: {r.backtest.mape_ventana}%. Sesgo: {r.backtest.sesgo_ventana > 0 ? "+" : ""} {r.backtest.sesgo_ventana}% (cerca de cero indica que no sobre ni subestima de forma sistemática). Cobertura del intervalo al 80%:{" "} {r.backtest.cobertura_80}% — si estuviera muy por debajo, los intervalos serían más estrechos de lo que admiten los datos.
Por qué los cálculos llevan decimales
El modelo no predice cuántas muertes habrá un día concreto, sino su{" "} valor esperado, que es un promedio y por definición no es entero — igual que "2.3 hijos por familia". Lo que ocurrirá será un entero, y está en el rango que acompaña a cada día. Los cálculos internos conservan los decimales a propósito: redondear cada día antes de sumarlos introduce un sesgo que se acumula (seis días redondeados hacia arriba suman ~2 casos de más). En la interfaz todo se muestra redondeado, porque las defunciones se cuentan enteras.
Intervalos
Al 80%. Para los días futuros se suman las varianzas de cada día, no sus límites: sumar límites daría un intervalo mucho más ancho de lo correcto, porque los errores diarios no ocurren todos en la misma dirección. A eso se añade la incertidumbre del rezago imputado, tomada como un 35% de lo imputado por ser la parte peor conocida del cálculo.
Limitaciones
La curva de rezago se estima con las observaciones recientes disponibles, así que es la pieza más frágil: si el retraso de registro cambiara respecto a su patrón habitual, la corrección quedaría descalibrada. El modelo tampoco incorpora intervenciones, cambios de política ni shocks; proyecta bajo el supuesto de que las condiciones se mantienen. Con filtros muy restrictivos (pocos casos por día) los intervalos se ensanchan mucho y conviene interpretarlos con cautela.
)} {!job && !error && (
Elige los filtros y el mes, y ejecuta. Se ajusta un modelo de conteo diario, se estima la curva de rezago de registro y se proyecta el cierre del mes con intervalos al 80%. Toma entre 15 y 60 segundos.
)} ); }