← all work

Air Quality Forecasting Platform

forecasts the next 24 hours from live government data every morning, then scores itself against what actually happened.

  • Python
  • XGBoost
  • Evidently
  • FastAPI
  • GitHub Actions
  • Hugging Face
the live forecast page: a station map of Provence-Alpes-Côte d'Azur and a chart of measured vs. forecast NO₂
the live forecast page: a station map of Provence-Alpes-Côte d'Azur and a chart of measured vs. forecast NO₂
why
a model trained once and left alone quietly goes stale. i wanted a forecaster that keeps running on fresh data and reports when it stops being trustworthy.
how it works
a daily GitHub Actions job pulls Geod'Air measurements for ~90 station/pollutant series in PACA, XGBoost forecasts 24 h ahead recursively, every past forecast is scored against the real measurements, Evidently checks for drift and opens an issue, and a static page goes out to Hugging Face. 41 pytest tests run on every push.
what i learned
two numbers misled me. my first score was too good: the 3-hour average included the hour being predicted. using only past hours raised the 1-hour error (MAE 3.42 → 3.69) but made training match live forecasting. then the drift alarm fired every day, because live data was compared with the whole year, including stations that weren't reporting. comparing the same season and stations cut the drift share from 0.67 to 0.56.
results
in all 19 daily checks (17 Oct – 4 Nov 2026), its 24-hour forecasts beat persistence, by 22–56%. over the latest week: 26% lower error on O₃ and NO₂ and 34% on PM10, across 14,468 scored values.

PM2.5 and CO still only match persistence, and the README says so.

architecture

daily · GitHub Actions, 04:30 UTClocal · Docker ComposedriftGeod'Airhourly measurementsfetchdownload + cleanforecastXGBoost, 24 hmonitordrift + skillbuild pagestatic HTMLHugging Facelive pageGitHub issueon driftgit historydata + monitoringtrainMLflow runsmodelXGBoost, 4.8 MBFastAPI/forecast/24hStreamlitdashboard
top: the daily pipeline on GitHub Actions. bottom: training and serving, run locally with Docker Compose. hover or tab through the steps for details.
air-quality / airquality/features.py
past = v.shift(1)
g["lag_1"] = past
g["lag_24"] = v.shift(24)
g["rolling_3"] = past.rolling(3, min_periods=3).mean()