factcircuit
FactCircuit: an auditable claim-and-evidence verification loop with immutable provenance, staged checks, bounded retrieval, and reproducible evaluation.
Pulso TransMi: qué tan bien predice el modelo la demanda de las estaciones de TransMilenio, ciclo a ciclo.
Translation pending
DataPython · HTMLGitHub @kevin-nieto-callejasOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Proyecto del equipo para el reto MLOps Pulso TransMi
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Implementación del reto MLOps Pulso TransMi (Universidad Externado, Departamento de Matemáticas). Pronostica demanda de pasajeros para 12 estaciones de TransMilenio cada 15 minutos, con un pipeline pensado para operar de forma continua, no solo entrenar un modelo una vez. Este repo parte del starter kit oficial del profesor (remoto upstream), que provee el SDK cliente (src/pulso_transmi/) para consumir la API pública. Todo lo demás — análisis, esquema de datos, migración— es trabajo propio del equipo.Read the full README ↗
Implementación del reto MLOps Pulso TransMi (Universidad Externado, Departamento de Matemáticas). Pronostica demanda de pasajeros para 12 estaciones de TransMilenio cada 15 minutos, con un pipeline pensado para operar de forma continua, no solo entrenar un modelo una vez.
Este repo parte del starter kit oficial
del profesor (remoto upstream), que provee el SDK cliente
(src/pulso_transmi/) para consumir la API pública. Todo lo demás —
análisis, esquema de datos, migración— es trabajo propio del equipo.
Las cinco fases de la guía están implementadas y operando. La competencia
está en su fase final (API 0.9.0: drift revisión 4 y contrato de
observación v2), que cierra el domingo 4 de octubre de 2026 a las 23:59,
hora de Bogotá.
Resultado final (cierre 5-oct 04:59 UTC): puesto 5 de 32 con 76.96
de accuracy acumulada y cobertura de 99.1% (215 ciclos resueltos). Champion base catboost_sin_semanal (86.76 de validación) más una
capa adaptativa para el drift: onda corta (rev 3) y, en la rev 4, una onda larga
ajustada por estación en cada ciclo (sinusoide de 1-2 armónicos) mezclada con
persistencia + tendencia. Con ella, y con una corrección LightGBM del residuo
(hallazgo #42), los ciclos oficiales pasaron de 73-79 a 83-90. Las entregas corren en la nube con relay.yml, que se relanza solo y
toma el código nuevo antes de cada ciclo, sin depender de ningún PC. 76 tests
en verde. La fase final está contada en
docs/INFORME_FINAL.md y en los hallazgos #37-#41.
docs/RUNBOOK.md.docs/INFORME_FINAL.md.docs/HALLAZGOS.md.docs/BITACORA.md..claude/skills/enviar-predicciones/.eda/EDA_REPORT.md.
El EDA es deliberadamente de un solo horizonte (+15 min, "¿qué tan
predecible es esto en principio?"); el modelo que realmente opera
(src/train.py) usa el esquema multi-horizonte correcto — ver
"Modelo" más abajo.docs/entity-relation.md para el esquema,
el diagrama entidad-relación y las políticas de seguridad (RLS).src/ingest.py sigue el patrón que exige la guía metodológica
("Automatizaciones esperadas — Collector"):
collector_runs con status='success'
guardado en Supabase — nunca lo fabrica a partir de la hora local./v1/stream/observations hasta agotar next_cursor.context_readings (que no tiene cursor propio en la API), usa el
máximo observed_at ya guardado como filtro start.observations/context_readings).collector_runs siempre, incluso cuando no hay
novedades (status='no_new_data') — así se ve en la bitácora real de
hoy: una corrida con un bug de comparación de fechas (detectado y
corregido, con test de regresión en tests/test_ingest.py), y las
siguientes ya limpias.next_cursor: null) sin dejar claro cómo continuar después,
el collector conserva el cursor con el que pidió esa última página en
vez de arriesgarse a perderlo — el peor caso es repetir una página
(inofensivo, gracias al upsert idempotente), nunca perder el progreso..github/workflows/collector.yml, cron en los minutos
8,23,38,53 (UTC). La guía pide cada 30 minutos; se dispara cada 15 y
fuera de :00/:30 porque GitHub descarta corridas programadas bajo
carga (ver "Hallazgo operacional" más abajo), así la cadencia efectiva
se acerca a los 30 minutos pedidos. Perder una corrida no pierde datos
—el cursor es incremental y el upsert idempotente, la siguiente corrida
recupera el atraso— pero sí deja huecos en la bitácora. También se puede
disparar a mano desde la pestaña Actions (workflow_dispatch).python src/ingest.py
src/infer.py sigue "Cómo se entrega una predicción" y "Automatizaciones
esperadas → Inferencia y submission" de la guía:
/v1/clock y /v1/forecast-cycles/current. Si no hay ciclo
abierto, termina sin error — es el comportamiento normal entre
ventanas o después del cierre de una fase.data_cutoff, los targets
(station_id + target_at) y el closes_at que devuelve la API —
nunca los calcula ni los asume por horario local.data_cutoff, rellenando el
contexto histórico que la fuente ya no actualiza con medianas causales.POST /v1/submissions con la API key en el header
Authorization. Si esa key no está configurada, el script arma y valida
el batch igual, lo imprime, y se detiene explícitamente en vez de fallar.Probado con httpx.MockTransport (tests/test_infer.py, 13 tests): el
camino "sin ciclo abierto", el armado del batch (horizonte correcto por
target, valores recortados a [0, 100000] como exige el contrato,
estación desconocida levanta error), la llave de idempotencia estable, la
detección de "este ciclo ya fue entregado" y la forma exacta del payload
de SubmissionInput. En total el repo corre 76 tests en CI.
Automatizado vía .github/workflows/relay.yml: un job que consulta el ciclo
cada 45 s durante ~5 h 40 min y se relanza solo al terminar (hallazgo #39).
.github/workflows/inference.yml queda de respaldo con su cron. Los minutos son una
recomendación operacional: la decisión real siempre la toma src/infer.py
consultando el estado de la API, nunca el cron por sí solo.
La guía advierte que "los workflows programados pueden presentar
retrasos". Medido en este repo el 18/09, el problema resultó ser peor que
un retraso: de ~28 corridas programadas esperadas en 7 horas, solo se
ejecutaron 2. GitHub no encola las corridas atrasadas — las descarta
bajo carga, y los minutos :00 y :30 (donde estaba el cron original del
collector) son los más congestionados de la plataforma. No es un problema
de cuota: el repo es público, con minutos ilimitados.
Durante la competencia eso sería grave: perder la corrida de un ciclo significa perder su ventana de 25 minutos, o sea 48 predicciones en cero. Es exactamente la tercera señal que describe la guía ("falla operacional: el pipeline no produjo o envió resultados — corregir la operación antes de culpar al modelo"). Mitigación en dos capas:
3,13,23,33,43,53), collector cada 15 (8,23,38,53) — nunca en
:00 ni :30.PULSO_POLL_MINUTES), revisando
la API cada 2 minutos, para que una sola corrida que sí arranque cubra
buena parte de la ventana aunque las demás se descarten. Un fallo
pasajero de red dentro de la ventana se reintenta en vez de abortar la
corrida completa.Reenviar no duplica ni gasta intentos: la llave de idempotencia es estable
por (ciclo, modelo) y, antes de rearmar el batch, src/infer.py consulta
en Supabase si ese ciclo ya fue entregado con ese mismo modelo.
python src/infer.py
src/train.py entrena y compara 13 candidatos con la misma validación
cruzada temporal (5 folds, TimeSeriesSplit, nunca aleatoria). Evidencia
completa en eda/reports/candidate_comparison.csv:
| Candidato | Features | Accuracy (CV) |
|---|---|---|
catboost_sin_semanal (champion vigente) | 45 + one-hot de estación | 86.76 |
ensamble_extendido (champion anterior → historical) | 49 + one-hot de estación | 86.61 |
xgboost_extendido | 49 | 86.47 |
lgbm_extendido | 49 | 86.44 |
catboost_extendido | 49 | 86.41 |
xgboost_station (champion anterior → historical) | 28 | 85.22 |
rf_tuned (RF, 500 árboles, más profundo) | 16 | 84.55 |
extra_trees_full | 16 | 84.37 |
xgboost_full | 16 | 84.28 |
xgboost_tuned2 (más árboles, learning rate más bajo) | 16 | 83.60 |
rf_full | 16 | 82.92 |
gbr_full | 16 | 80.82 |
rf_no_weekly_lag (RF sin estacionalidad semanal) | 13 (sin lag_672/roll_mean_96/roll_std_96) | 80.81 |
| baseline ingenuo (repetir el valor de hace 24 h) | 0 | 77.89 |
El champion es CatBoost solo, con 45 features: el conjunto extendido
menos las cuatro de estacionalidad semanal (lag_672, lag_1344,
roll_mean_96, roll_std_96). Salió del barrido de 657 experimentos, no de
elegir a mano: sus hiperparámetros (depth=10, learning_rate=0.08) están
lejos del 8/0.03 que habíamos puesto por intuición.
Le ganó al ensamble de LightGBM + XGBoost + CatBoost que era champion (86.76 contra 86.61) y además es más simple: un solo modelo de 19 MB en vez de tres de 38 MB en total, y una dependencia de producción en vez de tres. El ensamble sigue en la tabla porque sigue siendo el segundo mejor.
Desglose del champion por horizonte (misma validación, filtrando cada subconjunto):
| Horizonte | Accuracy |
|---|---|
| +15 min | 86.87 |
| +30 min | 86.58 |
| +45 min | 86.29 |
| +60 min | 86.09 |
Predecir una hora adelante cuesta 0.78 puntos frente a predecir 15 minutos, y la caída es pareja: no hay un horizonte donde el modelo se rompa.
Además de esos candidatos elegidos a mano, src/sweep.py explora cientos
de combinaciones al azar con validación más barata y las registra en MLflow
(eda/reports/sweep_results.csv). Esos
números no son comparables con los de arriba —se miden con 3 cortes en
vez de 5—, por eso src/revalidar.py vuelve a medir los mejores con el
protocolo oficial antes de considerarlos promovibles.
Corrección importante de esta ronda — horizonte directo, no
"nowcasting": la primera versión del modelo (86.77 de accuracy, la que
aparecía antes en este README) tenía un problema real: predecía la
demanda de su propia fila usando lag_1 = el período anterior. Eso
solo generaliza a +15 min. Para +30/+45/+60 min, ese mismo truco exigiría
conocer demanda que todavía no existe en data_cutoff (fuga de futuro al
revés). La API pide los 4 horizontes en cada ciclo, así que ese modelo no
podía usarse para submissions reales sin hacer trampa.
La corrección: cada fila ancla (lo que se sabe en un momento dado) se
apila 4 veces, una por horizonte, agregando horizon_minutes como
feature y usando como target la demanda real desplazada ese horizonte
hacia adelante — siempre con lags anclados en el momento de predicción,
nunca en el futuro. Es la estrategia de "horizonte directo" (un solo
modelo, el horizonte como feature) en vez de recursiva (encadenar 4
predicciones de un paso, que acumula error). El accuracy bajó de 86.77 a
85.22 — eso no es una regresión: predecir 4 horizontes de verdad es
un problema más difícil que predecir solo el siguiente paso, y el número
anterior nunca hubiera sido alcanzable en la competencia real. El
desglose por horizonte (arriba) muestra exactamente el patrón esperado:
más lejos en el futuro, más difícil — 86.23 en +15 min bajando a 84.59 en
+60 min.
Hallazgo que se mantiene: agregar la identidad de la estación
(one-hot) sigue siendo la mejora más grande sobre el resto de candidatos
(85.22 vs 84.28 de xgboost_full), igual que en la ronda anterior. El
EDA ya había mostrado que la demanda promedio varía más de 3x entre
estaciones; afinar hiperparámetros de XGBoost sin esa señal
(xgboost_tuned2) sigue moviendo la aguja mucho menos.
rf_no_weekly_lag existe a propósito para medir cuánto se apoya el modelo
en lag_672 (demanda de hace una semana): ¿qué tan mal quedaríamos si esa
señal dejara de ser confiable por drift? Para Random Forest la respuesta son
2.11 puntos.
Esa pregunta terminó respondiéndose al revés de lo esperado. El barrido
mostró que las mejores configuraciones no usaban lag_672, y al medirlo
con el protocolo oficial quitar esas cuatro features sube a CatBoost de
86.29 a 86.76. O sea que 2.11 no mide una fragilidad del dato: mide cuánto
se sobreajusta un modelo concreto a esa señal. El champion vigente no
usa ninguna feature semanal, así que ese drift en particular ya no es un
riesgo para nosotros — pasó de ser "el riesgo principal" a no aplicar.
Regla de promoción real, no solo "el número más alto": train.py
consulta el champion vigente en Supabase antes de decidir. Un candidato
nuevo solo se promueve si supera esa métrica; si no, se registra igual
como candidate (evidencia del experimento) sin tocar el champion. Un
índice único parcial en Postgres (one_champion_only) impide a nivel
de base de datos que existan dos champions a la vez.
Esta corrección de esquema es también un caso real de esa regla puesta a
prueba: como el champion anterior (86.77) y el nuevo (85.22) usan
definiciones de métrica distintas (un horizonte vs cuatro apilados),
train.py detecta la incompatibilidad revisando si horizon_minutes
está en la lista de features del champion vigente, y si no lo está,
reemplaza sin comparar los dos números directamente — la comparación
numérica ciega hubiera sido incorrecta (habría "reprobado" un modelo
mejor solo porque resuelve un problema más difícil). Antes de esta
corrección, sí hubo dos promociones válidas y comparables entre sí:
xgboost_full reemplazó a rf_full (86.61 > 86.34), y xgboost_station
reemplazó a xgboost_full (86.77 > 86.61). Todos los champions
anteriores quedaron marcados historical, no borrados — se conservan
como evidencia de cada experimento.
El modelo ganador se guarda en artifacts/ (ignorado por git) y se
sube a Supabase Storage (model-artifacts bucket) para que sea
reproducible desde cualquier máquina, no solo la que lo entrenó.
src/predict.py hace una prueba honesta de pronóstico (no solo "el
archivo carga"): toma un ancla 60 minutos antes del último dato conocido
por estación, predice los 4 horizontes y compara contra el valor real ya
conocido en el histórico. Última corrida: 88.03% de accuracy agregada
sobre esa muestra puntual (referencial — la cifra oficial del modelo es
la de la validación cruzada en train.py, no esta).
API Pulso TransMi → collector → Supabase (Postgres) → experimentos / modelo
↑ (cron 30min) ↓
└────────────── GitHub Actions ←─────────────── predicciones + submission
(cron min 7,37)
supabase/schema.sql):
catálogo de estaciones/observaciones/contexto, bitácora del collector,
ciclos, versiones de modelo, predicciones, submissions, evaluaciones,
métricas y señales de drift. RLS activo: lectura pública, escritura
solo con service_role key..github/workflows/collector.yml y .github/workflows/inference.yml):
el collector deja evidencia cada 30 minutos aunque no haya novedades; el
de inferencia consulta el ciclo vigente y termina sin error mientras no
haya uno abierto. Falta conectar el entrenamiento (hoy es manual, por
decisión: la guía dice explícitamente "no es obligatorio reentrenar
cada 30 minutos").TimeSeriesSplit (5 folds expandiendo la ventana de
entrenamiento). Mezclar aleatoriamente futuro y pasado da métricas
optimistas que no representan la competencia.lag_672 (demanda de hace exactamente 1 semana) es la feature más
fuerte (91.8% de importancia en el Random Forest de selección de
features) — hay estacionalidad semanal muy marcada en el histórico.
Esto encendió una alarma inicial sobre fragilidad ante drift, y resultó
ser la conclusión equivocada. Quitarle esa feature a Random Forest cuesta
2.11 puntos, pero quitársela a CatBoost lo mejora (86.29 → 86.76, ver
sección "Modelo"), y por eso el champion vigente no la usa. La importancia
que reporta un Random Forest mide cuánto usa una señal, no cuánto
ayuda.horizon_minutes como
feature) en vez de recursivo (encadenar 4 predicciones de un paso):
la API pide +15/+30/+45/+60 min en cada ciclo. Un modelo entrenado solo
para predecir su propia fila (esquema inicial, ver "Modelo") únicamente
generaliza a +15 min — usarlo en cascada para los demás horizontes
acumula error a cada paso y, peor, la primera versión ni siquiera podía
hacer eso sin usar datos del futuro que no existirían en data_cutoff.
Apilar 4 copias de cada fila ancla (una por horizonte) con el target
correctamente desplazado hacia adelante es más simple de mantener que 4
modelos separados y evita la fuga de futuro por diseño.service_role key para que el collector y las migraciones no dependan
de una key pública.upsert) en vez de SQL con
arrays gigantes: más simple de mantener y ya es idempotente por
diseño (mismas llaves únicas que usará el futuro collector
incremental).Requiere Python 3.11+.
python -m venv .venv
source .venv/Scripts/activate # Windows: .venv\Scripts\Activate.ps1
python -m pip install -e '.[ml]'
cp .env.example .env # completar SUPABASE_URL / SUPABASE_KEY
python examples/01_download.py # descarga el historico a data/
python examples/02_naive_baseline.py
python eda/eda.py # calidad de datos, graficos, feature
# engineering, seleccion de features,
# validacion cruzada temporal
Correr el collector incremental (requiere SUPABASE_SERVICE_ROLE_KEY):
python src/ingest.py
Migrar (o re-migrar; es idempotente) el histórico a Supabase requiere la
service_role key en SUPABASE_SERVICE_ROLE_KEY (ver .env.example):
python supabase/migrate_via_rest.py
Entrenar, comparar candidatos, y registrar el champion (requiere
SUPABASE_SERVICE_ROLE_KEY, sube el artefacto a Supabase Storage):
python src/train.py
Probar que el champion registrado se puede volver a cargar y pronosticar los 4 horizontes:
python src/predict.py
Correr la inferencia/submission (requiere SUPABASE_SERVICE_ROLE_KEY;
PULSO_API_KEY todavia no existe, asi que arma y valida el batch sin
enviarlo si no hay ciclo abierto o falta la key):
python src/infer.py
Lo que opera (corre solo, vía GitHub Actions)
src/ingest.py Collector incremental idempotente (Fase 2)
src/infer.py Inferencia + submission por ciclo (Fase 4)
src/evaluate.py Evaluacion, drift y decision de reentrenamiento (Fase 5)
src/check_contract.py Avisa si el docente cambia el contrato de la API
src/check_model.py Comprueba que el champion cargue y prediga
.github/workflows/ relay.yml · inference.yml · collector.yml · contract-watch.yml · retrain-watch.yml · ci.yml
Lo que se corre a mano
src/train.py Compara candidatos y promueve el champion (Fase 3)
src/rollback.py Vuelve a un champion anterior, verificandolo antes
src/sweep.py Barrido de experimentos con MLflow (bono)
src/revalidar.py Revalida los mejores del barrido con el protocolo oficial
src/predict.py Recarga el champion y pronostica (smoke test)
src/simulate_cycle.py Simulacro de un ciclo completo, y limpia lo que crea
src/stress_test.py Bateria de esfuerzo del sistema completo
Codigo compartido, datos y documentacion
src/features.py Feature engineering (EDA + entrenamiento + inferencia)
src/pulso_transmi/ SDK cliente (heredado del starter kit)
supabase/ Esquema de la base de datos y script de migracion
contract/expected.json Estado del contrato del docente que ya revisamos
dashboard/ Pagina estatica desplegada en Vercel (bono)
eda/ Analisis exploratorio, graficas, reportes de experimentos
docs/ Bitacora, informe final, runbook, entidad-relacion, traspaso
tests/ 76 tests: SDK, collector, inferencia, drift y adaptación
artifacts/ Modelos entrenados y cache local (ignorado por git)
Heredado del starter kit, conservado como referencia
docs/student-project.md Resumen de requisitos del docente
examples/ Descarga del historico y baseline ingenuo
templates/pipeline.yml Plantilla de workflow del starter kit. NO se usa:
referencia src/pipeline.py y requirements.txt, que
no existen aqui. Nuestros workflows reales estan en
.github/workflows/.
model_versionssrc/ingest.py), automatizado
vía GitHub Actions (.github/workflows/collector.yml) — probado con el
stream oficial; al corte de esta actualización conserva 63.660
observaciones y evidencia cada corrida en collector_runs.src/infer.py +
.github/workflows/relay.yml, con inference.yml de respaldo): consulta el ciclo vigente, arma las 48
predicciones y usa una llave de idempotencia estable. La auditoría del
1/10 confirma 234 ciclos oficiales, 234 submissions aceptadas, cero
faltantes y cero duplicados.src/evaluate.py):
une predicción con realidad, calcula accuracy por estación y ciclo,
vigila las tres señales de la guía y decide mantener/investigar/
reentrenar con umbrales justificados. Corre tras cada recolección.src/rollback.py, bono): vuelve a un champion
anterior verificando primero que cargue, y deja la decisión registrada.dashboard/).src/sweep.py explora al azar las tres familias de
boosting y registra cada intento; src/revalidar.py vuelve a medir los
mejores con el protocolo oficial antes de considerarlos promovibles.src/stress_test.py) y un simulacro de ciclo completo
(src/simulate_cycle.py).prediction_evaluations y
cycle_metrics alimentan el dashboard, el monitoreo de drift y el
reentrenamiento condicionado por los últimos seis ciclos.