Telling people the air is bad right now is useful. Telling them it'll be bad tomorrow morning lets them act: shift a run, keep kids indoors, wear a mask, or for governments, restrict traffic.
After building Vayu, forecasting was the obvious next step. Here's how I'd approach it today.
Frame the problem
Target: PM2.5 average for each hour, 1–24 hours ahead, at a given station.
Baseline to beat: persistence, meaning "tomorrow at 8am = today at 8am." It's surprisingly hard to beat on calm days. If your model doesn't beat it, you don't have a model.
Features that matter
| Feature group | Examples |
|---|---|
| Lagged pollution | PM2.5 at t-1h, t-24h, rolling 6h mean |
| Weather | Wind speed and direction, temperature, humidity, rainfall, boundary layer height |
| Weather forecasts | The same variables for the target hour, from a forecast model |
| Time | Hour of day, day of week, holidays |
| Seasonal sources | Crop burning season, heating season, festivals |
Boundary layer height is the one people forget. When it's low (cold, calm nights), pollution is trapped near the ground.
Weather data sources include ERA5 reanalysis for training history and operational forecasts for live predictions. See Open Climate Datasets.
A baseline in Python
import pandas as pd from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.metrics import mean_absolute_error df = pd.read_parquet("station_hourly.parquet").sort_values("time") for lag in [1, 2, 3, 6, 12, 24]: df[f"pm25_lag_{lag}"] = df["pm25"].shift(lag) df["pm25_roll_6"] = df["pm25"].shift(1).rolling(6).mean() df["hour"] = df["time"].dt.hour df["dow"] = df["time"].dt.dayofweek # Target: PM2.5 24 hours ahead df["target"] = df["pm25"].shift(-24) df = df.dropna() features = [c for c in df.columns if c.startswith("pm25_")] + [ "hour", "dow", "wind_speed", "rh", "temp", "blh", ] split = df["time"] < "2026-01-01" model = HistGradientBoostingRegressor(max_iter=400, learning_rate=0.05) model.fit(df.loc[split, features], df.loc[split, "target"]) pred = model.predict(df.loc[~split, features]) print("MAE:", mean_absolute_error(df.loc[~split, "target"], pred)) persistence = df.loc[~split, "pm25"] # value now as forecast for +24h print("Persistence MAE:", mean_absolute_error(df.loc[~split, "target"], persistence))
Note: for a real deployment, the weather features for the target hour should come from a forecast, not observed values, or you're leaking the future.
Evaluate honestly
- Split by time, never randomly.
- Compare to persistence and climatology baselines.
- Report error on high-pollution days separately. Those are the days that matter.
- Check category accuracy (did you predict "Unhealthy" correctly?), not just MAE.
Deploying
- Retrain on a schedule. Sources and patterns change.
- Monitor drift: if errors grow, investigate.
- Show uncertainty. Quantile regression gives you a range instead of one number.
- Use calibrated sensor data if you rely on low-cost sensors.