← Retour à l'épreuve

India INAIO 2025 · Correction IOAI Training

SmogCast: Forecasting Air Pollution in Bangalore & Delhi

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
MAPE and MSE per city
Évaluée sur
official test
Notre score
MAPE 1.97 % BLR, 2.18 % DEL

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

You receive a weekly table for two Indian cities: PM2.5 pollution, average temperature and number of vehicles for Bangalore and for Delhi, plus a petrol price common to both. The training file covers 200 weeks. First you explore the data and answer three questions: are the two cities' pollution levels correlated, how does pollution relate to temperature and traffic, and does petrol price matter? Then you write two functions in the organisers' notebook: one that learns a forecasting model from the training file, and one that, given the history up to some week and the petrol prices of the coming weeks, predicts PM2.5 in both cities a few weeks ahead. Temperature and traffic for the future weeks are not given: only the week index and the petrol price are.

How you are scored

The notebook has a fixed evaluation cell. It takes a file, hides its last 12 weeks, gives your function everything before them, and asks for forecasts of the first lead_time = 6 hidden weeks. For each city it reports

MAPE=100n∑t=1n∣yt−y^tyt∣,MSE=1n∑t=1n(yt−y^t)2,n=6.\text{MAPE} = \frac{100}{n}\sum_{t=1}^{n}\left|\frac{y_t-\hat y_t}{y_t}\right| ,\qquad \text{MSE} = \frac{1}{n}\sum_{t=1}^{n}\left(y_t-\hat y_t\right)^2 ,\qquad n = 6 .

The cell is run on the training file and on a public test file; a private file is scored by the organisers. Points depend on "performance ranges" that were never published, so there is no official target number. The rest of the 14 points reward the exploration answers and a well-argued method.

Two details of the evaluation cell are worth noticing before writing any code:

  • The public test file is the training file plus 100 new weeks (its first 200 rows are identical to the training file). The scored weeks are 289 to 294, forecast from history up to week 288. So "forecasting" here means 1 to 6 steps ahead.
  • The model is trained with lead_time=3 but evaluated with lead_time=6. A model that only knows how to predict exactly 3 weeks ahead breaks. Our model must handle any horizon.
  • Six weeks per city is a tiny sample: one unlucky week can double the MSE. To compare ideas fairly we also run a rolling backtest: we repeat the official procedure from each of the 95 origins between weeks 200 and 294 (all in the period never used for fitting) and average.

First look at the data

Plot everything against time first. Three structures appear immediately.

Left: PM2.5 in Delhi over 300 weeks (red) and the temperature in Delhi on a reversed axis (blue): the two curves overlap almost perfectly, with a 52-week cycle. Right: PM2.5 against temperature for the 200 training weeks, coloured by traffic: the cloud falls from about 245 at 10 °C to 205 at 29 °C, and at equal temperature the brighter (busier) points sit higher.

  • Temperature is a clean yearly wave (period 52 weeks) plus small weekly noise of about 1 °C. Delhi swings between 10 °C and 29 °C, Bangalore between 21 °C and 29 °C.
  • Traffic grows slowly (about 100 extra vehicles per week, every week), with noise that persists for a few weeks (lag-1 autocorrelation of the detrended series about 0.45).
  • PM2.5 looks like the mirror image of temperature, plus noise. In Delhi, where the temperature swing is large, the seasonal wave dominates; in Bangalore it is weaker and the series looks noisier.

A linear regression of PM2.5 on the same week's temperature and traffic confirms this, and its residuals are pure white noise (lag-1 autocorrelation 0.01):

cityinterceptper °Cper 1000 vehiclesper INR of petrolR2R^2
Bangalore201.1−2.09-2.09+0.39+0.39+0.15+0.15 (p = 0.41)0.55
Delhi204.5−1.96-1.96+0.48+0.48−0.04-0.04 (p = 0.83)0.87

Q1 (a): are the two cities correlated?

The honest answer is "yes, but not causally". The levels have a correlation of r=0.55r = 0.55, which looks substantial. But both cities share the same calendar (winter is cold in both, r=0.88r = 0.88 between their temperatures) and both see traffic grow over the years. Remove these common slow movements by looking at week-to-week changes, and the correlation vanishes:

Left: PM2.5 Delhi against PM2.5 Bangalore, a clearly rising cloud, r = 0.55. Right: weekly change in Delhi against weekly change in Bangalore, a round shapeless cloud, r = 0.00.

So knowing that Bangalore's pollution jumped this week tells you nothing about Delhi. The correlation of levels is a common-cause correlation (season and trend), exactly the trap that makes two trending time series look related. Practical consequence: there is no point feeding one city's PM2.5 into the other city's model.

Q1 (b): pollution, temperature and traffic

  • PM2.5 decreases with temperature: about −2-2 units per °C in both cities (winter inversions trap particles near the ground; in this data the effect is very clean).
  • PM2.5 increases with traffic: about +0.4+0.4 to +0.5+0.5 units per 1000 extra vehicles per week.
  • Temperature and traffic are essentially uncorrelated with each other (r≈0.05r \approx 0.05): traffic follows the long-term trend, temperature the seasons. That is good news: the two effects can be estimated separately without confusion.

Q1 (c): petrol price

The raw correlation between petrol price and PM2.5 is tiny (0.090.09 and −0.05-0.05), and once temperature and traffic are in the regression its coefficient is indistinguishable from zero (p-values 0.41 and 0.83). So petrol has no direct effect. But it does act indirectly, through traffic: holding the trend and the season fixed, one extra rupee per litre removes about 1450 vehicles per week in Bangalore and 1690 in Delhi. Multiplied by the traffic effect, a 1 INR rise lowers PM2.5 by roughly 0.60.6 in Bangalore and 0.80.8 in Delhi. A careless reading of the raw correlation ("petrol rises over time and so does pollution, r=0.18r=0.18 in the full file") would get this backwards: price and pollution both trend upward, while the causal path goes the other way.

A first baseline

The simplest forecasts need no model at all:

  • last value: predict that every future week equals the last observed one;
  • mean of the last 4 weeks: the same idea with less noise;
  • same week last year: copy the value from 52 weeks earlier, to capture the season.

On the official public test window they give MAPE around 2.4 to 3.2 % and MSE 39 to 66, and on the rolling backtest MSE 46 to 107. They fail for clear reasons: the last value carries this week's random noise into the future, and last year's value carries last year's noise (and misses one year of traffic growth).

The notebook's helper code suggests XGBoost on lagged PM2.5 values, forecasting recursively. On the training file it looks brilliant (MSE 10.6 and 21.5), because the trees have memorised those very weeks. On the unseen weeks it is the worst model of all for Delhi (backtest MSE 122). Lags of PM2.5 carry little information here: the residual noise is white, so yesterday's value is mostly yesterday's noise, and recursive forecasting feeds predictions back in, compounding errors. This is the most useful failed attempt of the problem: always evaluate on weeks the model has not seen.

The key idea

Do not forecast pollution directly. Forecast its drivers, then use the physical relation.

The exploration shows that, week by week,

PMt=a+b Tt+g Vt+εt,εt white noise, sd ≈4.7,\text{PM}_t = a + b\,T_t + g\,V_t + \varepsilon_t ,\qquad \varepsilon_t \text{ white noise, sd } \approx 4.7 ,

with TT the temperature and VV the vehicle volume. The relation itself needs no forecasting; what is unknown at forecast time is Tt+hT_{t+h} and Vt+hV_{t+h}. But these two are far easier to predict than PM2.5:

  • Temperature is a seasonal curve. Fit it with yearly harmonics: Tt≈c0+c1sin⁡2πt52+c2cos⁡2πt52+c3sin⁡4πt52+c4cos⁡4πt52T_t \approx c_0 + c_1 \sin\frac{2\pi t}{52} + c_2 \cos\frac{2\pi t}{52} + c_3 \sin\frac{4\pi t}{52} + c_4 \cos\frac{4\pi t}{52} . The leftover noise is about 1 °C and does not persist, so this curve is the best possible forecast of Tt+hT_{t+h} for any horizon.
  • Traffic is trend + season + petrol effect + a persistent shock. Fit Vt=(linear in t, harmonics, petrolt)+rtV_t = \text{(linear in } t\text{, harmonics, petrol}_t) + r_t, where the residual follows rt+1≈φ rt+noiser_{t+1} \approx \varphi\, r_t + \text{noise} with φ≈0.4\varphi \approx 0.4. The best forecast of a future shock is then φhrlast\varphi^h r_{\text{last}}: if this week has unusually heavy traffic, the next week probably does too, and the effect fades geometrically.

The petrol price of the future weeks is given, so it enters exactly where it acts: in the traffic equation.

Why is this better than regressing PM2.5 on lags or on the calendar alone? Because it puts each piece of information in the right place. The calendar regression (PM2.5 directly on harmonics, trend and petrol) is already good, since it is almost the same model with the drivers substituted in. The structural version adds the persistence of traffic shocks, which the calendar cannot see, and it reads off interpretable coefficients that also answer Q1.

How good can anyone be? If we cheat and give the model the true future temperature and traffic, it still makes errors, because of εt\varepsilon_t: backtest MSE 23.6 (Bangalore) and 26.4 (Delhi). That is the noise floor of this data. Every forecast also pays for the unpredictable part of the drivers. Temperature noise (sd about 1 °C) adds about b2⋅12≈4b^2 \cdot 1^2 \approx 4. A traffic shock hh weeks ahead is unpredictable up to a variance σr2(1−φ2h)\sigma_r^2(1-\varphi^{2h}), which is almost σr2\sigma_r^2 after two weeks; with σr≈6000\sigma_r \approx 6000 vehicles in Delhi this adds (0.00048×6000)2≈8(0.00048 \times 6000)^2 \approx 8, and about (0.00039×4800)2≈3.5(0.00039 \times 4800)^2 \approx 3.5 in Bangalore. Floor plus these terms gives roughly 31 (Bangalore) and 38 (Delhi); our model sits at 32.7 and 40.0: there is very little left to gain.

The full solution

  1. Fit, on the 200 training weeks only, for each city:
    • the PM equation, linear regression of PM2.5 on (Tt,Vt)(T_t, V_t);
    • the temperature curve, linear regression of TtT_t on (t,harmonics,petrolt)(t, \text{harmonics}, \text{petrol}_t);
    • the traffic curve, the same regression for VtV_t, and φ\varphi = lag-1 autocorrelation of its residuals.
  2. To forecast weeks t+1,…,t+ht+1, \dots, t+h from a history ending at week tt:
    • compute the last traffic residual rt=Vt−V^tr_t = V_t - \hat V_t from the history;
    • T^t+k\hat T_{t+k} = temperature curve at week t+kt+k; V^t+k\hat V_{t+k} = traffic curve at week t+kt+k (with its known petrol price) + φkrt+\ \varphi^k r_t;
    • plug into the PM equation.
  3. Return a DataFrame with Week and Predicted_Particulate_Level_<city> columns, the names the evaluation cell merges on.

The heart of the forecasting function:

h = fut["Week"].to_numpy() - past_df["Week"].iloc[-1]            # 1..lead_time steps ahead
last_resid = (past_df[V] - veh_m.predict(calendar_features(past_df))).iloc[-1]
temp_f = temp_m.predict(X_fut)                                    # seasonal temperature
veh_f  = veh_m.predict(X_fut) + last_resid * phi ** h             # traffic, shock decays
pm_f   = pm_eq.predict(pd.DataFrame({T: temp_f, V: veh_f}))       # physical relation

Nothing is fitted on the test file: the history passed to the forecasting function is only used to read the last traffic shock, which any forecaster needs. (Refitting all equations on the longer history at forecast time, as the notebook's SARIMA helper does, changes the scores by less than 1 MSE unit: the 200 training weeks already pin the coefficients down.) Because the model forecasts any horizon, the mismatch between training (lead_time=3) and evaluation (lead_time=6) is harmless.

Results

Official evaluation cell on the public test file (weeks 289 to 294, 6 weeks), and rolling backtest over the 95 origins of the unseen period (weeks 200 to 294, horizons 1 to 6):

approachofficial MAPE BLRofficial MSE BLRofficial MAPE DELofficial MSE DELbacktest MSE BLRbacktest MSE DEL
last value3.23 %53.22.42 %38.769.7103.9
mean of last 4 weeks2.92 %45.42.49 %38.845.8107.4
same week last year2.40 %48.52.99 %65.668.572.7
XGBoost on 4 lags + petrol (helper code)1.78 %18.22.34 %50.651.4122.3
calendar regression (harmonics + trend + petrol)2.57 %36.32.08 %38.035.341.8
structural, no traffic persistence1.97 %21.12.13 %36.832.840.6
structural (final)1.97 %21.12.18 %36.932.740.0
noise floor: true future drivers given1.27 %10.12.27 %31.923.626.4

On the official cell run on the training file (weeks 189 to 194, which the model has seen), the final model scores MAPE 1.96 % and 3.09 %, MSE 30.9 and 61.7.

Bar chart of the backtest MSE for six forecasters and both cities. The naive methods and XGBoost on lags sit between 46 and 122; the calendar regression and the structural model sit near 33 to 42, just above dotted lines at 24 and 26 marking the noise floor.

Look at the official columns with care: on Delhi the naive "last value" scores 38.7, almost the same as our 36.9, and even the oracle that knows the true future drivers has a worse Delhi MAPE (2.27 %) than our model. Six weeks are too few to rank models. The backtest, with 570 forecasts per city, separates them clearly: the structural model is the best of all honest forecasters and is within about 10 MSE units of the noise floor in both cities.

What to remember

  • Forecast the drivers, not the target, when the target is a stable function of drivers that are themselves predictable (seasonal temperature, trending traffic). Put each known future input (here petrol price) in the equation where it really acts.
  • Correlation between two trending or seasonal series is usually a common cause. Check by correlating week-to-week changes, or by controlling for the calendar, before concluding anything (Q1 a and c).
  • Lags only help if the noise persists. Look at the autocorrelation of the residuals: white residuals mean past values of the target are mostly noise, and lag-based models (XGBoost, AR) will chase it.
  • A 6-point test set is a lottery. Rebuild the official procedure as a rolling backtest over many origins, on data the model never saw, and compare models there.
  • Estimate the noise floor (here: give the model the true future drivers). It tells you when to stop tuning.
Code complet (solution.py)
"""SmogCast (INAIO 2025 Stage 2, Problem 4): forecasting weekly PM2.5 in Bangalore and Delhi.

Structural ("forecast the drivers") model:
  1. PM2.5 is explained, week by week, by temperature and traffic:  PM = a + b*Temp + g*Vehicles + noise
  2. Temperature is a 52-week seasonal curve; vehicle volume is trend + season + petrol price + AR(1) noise.
  3. To forecast PM at week t+h we forecast the drivers at t+h, then plug them into step 1.
Trained on air_pollution_train.csv only; past_df is used only as conditioning (last observed residual).

Data: put air_pollution_train.csv and air_pollution_test_public.csv in ./data/
"""
from typing import Any
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_percentage_error, mean_squared_error

CITIES = ["Bangalore", "Delhi"]
PERIOD = 52  # weeks in a year


def calendar_features(df: pd.DataFrame) -> np.ndarray:
    """Week index (trend), two yearly harmonics (seasonality) and the petrol price."""
    w = df["Week"].to_numpy(float)
    ang = 2 * np.pi * w / PERIOD
    return np.c_[w, np.sin(ang), np.cos(ang), np.sin(2 * ang), np.cos(2 * ang),
                 df["Petrol_Price"].to_numpy(float)]


# ---------------------------------------------------------------- Q1: exploration
def explore(train_file_path: str) -> None:
    df = pd.read_csv(train_file_path)
    pm = df[["Particulate_Level_Bangalore", "Particulate_Level_Delhi"]]
    print("(a) corr(PM Bangalore, PM Delhi) levels: %.2f, week-to-week changes: %.2f"
          % (pm.corr().iloc[0, 1], pm.diff().corr().iloc[0, 1]))
    print("    corr(Temp Bangalore, Temp Delhi): %.2f"
          % df[["Temperature_Bangalore", "Temperature_Delhi"]].corr().iloc[0, 1])
    for c in CITIES:
        X = df[[f"Temperature_{c}", f"Avg_Vehicle_Volume_{c}", "Petrol_Price"]]
        m = LinearRegression().fit(X, df[f"Particulate_Level_{c}"])
        print(f"(b,c) {c}: PM = {m.intercept_:.1f} {m.coef_[0]:+.2f}*Temp "
              f"{m.coef_[1]*1000:+.2f}*Vehicles/1000 {m.coef_[2]:+.2f}*Petrol, "
              f"R2 = {m.score(X, df[f'Particulate_Level_{c}']):.2f}")
        mv = LinearRegression().fit(calendar_features(df), df[f"Avg_Vehicle_Volume_{c}"])
        print(f"      {c}: +1 INR petrol -> {mv.coef_[-1]:+.0f} vehicles/week (trend and season held fixed)")


# ---------------------------------------------------------------- Q2: model
def learn_air_pollution_forecast_model(train_file_path: str, lead_time: int) -> Any:
    """Fits, for each city, the PM equation and the two driver models.
    The structural model can forecast ANY horizon, so lead_time is not needed for fitting
    (useful: the official cell trains with lead_time=3 but evaluates with lead_time=6)."""
    df = pd.read_csv(train_file_path).sort_values("Week").reset_index(drop=True)
    X_cal = calendar_features(df)
    model = {}
    for c in CITIES:
        T, V, P = f"Temperature_{c}", f"Avg_Vehicle_Volume_{c}", f"Particulate_Level_{c}"
        pm_eq = LinearRegression().fit(df[[T, V]], df[P])          # PM from its physical drivers
        temp_m = LinearRegression().fit(X_cal, df[T])              # seasonal temperature curve
        veh_m = LinearRegression().fit(X_cal, df[V])               # traffic trend/season/petrol
        resid = df[V] - veh_m.predict(X_cal)
        phi = float(resid.autocorr(1))                             # traffic shocks persist (AR(1))
        model[c] = dict(pm_eq=pm_eq, temp_m=temp_m, veh_m=veh_m, phi=phi, T=T, V=V)
    return model


def forecast_air_pollution(model: Any, past_df: pd.DataFrame,
                           future_observed_df: pd.DataFrame, lead_time: int) -> pd.DataFrame:
    """Forecasts PM2.5 for the weeks listed in future_observed_df (Week + known Petrol_Price)."""
    past_df = past_df.sort_values("Week")
    fut = future_observed_df.sort_values("Week").head(lead_time).reset_index(drop=True)
    X_fut = calendar_features(fut)
    h = fut["Week"].to_numpy() - past_df["Week"].iloc[-1]          # steps ahead (1..lead_time)
    out = pd.DataFrame({"Week": fut["Week"]})
    for c, m in model.items():
        last_resid = (past_df[m["V"]] - m["veh_m"].predict(calendar_features(past_df))).iloc[-1]
        temp_f = m["temp_m"].predict(X_fut)
        veh_f = m["veh_m"].predict(X_fut) + last_resid * m["phi"] ** h   # shock decays geometrically
        drivers = pd.DataFrame({m["T"]: temp_f, m["V"]: veh_f})
        out[f"Predicted_Particulate_Level_{c}"] = m["pm_eq"].predict(drivers)
    return out


# ---------------------------------------------------------------- Q3: official evaluation (unchanged)
def evaluate_forecast(model: Any, test_file_path: str, lead_time: int, test_horizon: int) -> dict:
    test_df = pd.read_csv(test_file_path).sort_values(by="Week")
    past_df = test_df.iloc[:-test_horizon].reset_index(drop=True)
    test_df = test_df.iloc[-test_horizon:].reset_index(drop=True)
    future_observed_df = test_df[["Week", "Petrol_Price"]].head(lead_time)
    forecast_df = forecast_air_pollution(model, past_df, future_observed_df, lead_time)
    merged_df = forecast_df.merge(test_df, on="Week", how="left")
    res = {}
    for c in CITIES:
        y, p = merged_df[f"Particulate_Level_{c}"], merged_df[f"Predicted_Particulate_Level_{c}"]
        res[f"MAPE_{c}"] = mean_absolute_percentage_error(y, p) * 100
        res[f"MSE_{c}"] = mean_squared_error(y, p)
    return res


def rolling_backtest(model: Any, file_path: str, origins, lead_time: int = 6) -> dict:
    """Extra honesty check: average the official metrics over many forecast origins
    (the official cell scores only 6 weeks, which is very noisy)."""
    df = pd.read_csv(file_path).sort_values("Week")
    se, ape = {c: [] for c in CITIES}, {c: [] for c in CITIES}
    for o in origins:
        past = df[df.Week <= o]
        fut = df[(df.Week > o) & (df.Week <= o + lead_time)]
        f = forecast_air_pollution(model, past, fut[["Week", "Petrol_Price"]], lead_time)
        for c in CITIES:
            y = fut[f"Particulate_Level_{c}"].to_numpy()
            p = f[f"Predicted_Particulate_Level_{c}"].to_numpy()
            se[c] += list((y - p) ** 2)
            ape[c] += list(np.abs(y - p) / y)
    return {**{f"MAPE_{c}": 100 * np.mean(ape[c]) for c in CITIES},
            **{f"MSE_{c}": np.mean(se[c]) for c in CITIES}}


if __name__ == "__main__":
    train_path = "data/air_pollution_train.csv"
    test_path = "data/air_pollution_test_public.csv"
    explore(train_path)
    model = learn_air_pollution_forecast_model(train_path, lead_time=3)
    fmt = lambda d: {k: round(float(v), 2) for k, v in d.items()}
    print("official cell, train file :", fmt(evaluate_forecast(model, train_path, lead_time=6, test_horizon=12)))
    print("official cell, public test:", fmt(evaluate_forecast(model, test_path, lead_time=6, test_horizon=12)))
    # weeks 201-300 were never used for fitting: 95 forecast origins in the unseen period
    print("rolling backtest weeks 200-294:", fmt(rolling_backtest(model, test_path, range(200, 295))))