← Back to the problem

Peru OPEIA 2026 · Correction by IOAI Training

Sentinel of the Metropolis

This page gives the full approach. Read it once you have searched.

Metric
RMSE
Evaluated on
held-out validation (official test labels are private)
Our score
RMSE 120.9 (5-fold CV)
Expected level
organisers' baseline RMSE 166.7 (only LB entry)

Expected level reached

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

A carbon-monoxide sensor is expensive and wears out quickly, so the task is to build a soft sensor: predict the hourly response of a tin-oxide CO sensor (the column PT08.S1(CO)) from four cheaper measurements, namely temperature, relative humidity, NO₂ and total NOx. You get 6 549 labelled hours, then 1 872 "validation" hours (public leaderboard) and 936 "test" hours (private leaderboard) without labels. Broken readings are stored as -200, in the features and in the target.

The data comes from the Kaggle competition archive. The labels of the validation and test hours are private and we never submitted anything, so all scores below are 5-fold cross-validation on the 6 549 training hours.

How you are scored

Root mean squared error:

RMSE=1n∑i=1n(yi−y^i)2.\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat y_i)^2}.

Lower is better. Squaring makes big misses very costly, which matters a lot here, as we will see.

What to aim for. The only public-leaderboard entry is the organisers' baseline, 166.7 on the validation hours.

First look at the data

Failure code in the target, the main relationship, and the scores

The target is a sensor resistance around 1 050 (most values between 650 and 2 000), except for 251 rows equal to -200 (left panel). Those are not real CO levels; they are failure codes. Looking at when they happen gives the first insight:

temperature = -200temperature valid
target = -2002510
target valid06 298

The target is -200 exactly when the temperature is -200, on every single training row. Temperature, humidity and the CO sensor sit in the same device: when it fails, all three report -200. NO₂ and NOx come from other analysers, are missing on 18 % of the rows, and their failures are unrelated to the target's.

Why does this matter so much? A model that ignores it predicts about 1 050 on those rows, an error of 1 250. On 3.8 % of the rows, that alone adds

0.038×12502≈240\sqrt{0.038 \times 1250^2} \approx 240

to the RMSE, more than the whole baseline score. The validation and test files have the same share of failed temperatures (4.4 % and 3.4 %), so the same rows will appear there.

On the valid rows, the CO sensor follows the traffic pollution: its correlation with log⁡(NOx)\log(\text{NOx}) is 0.72 (middle panel). Cars emit CO and NOx together, so NOx is a good proxy for CO.

A first baseline

approachCV RMSE
constant mean327.7
constant mean, but -200 when the temperature is -200212.4
linear regression (missing values imputed by the median) + the same rule140.5

The rule alone takes the constant predictor from 328 to 212. A linear model on the four features then reaches 140.5, already better than the published baseline.

The key idea

Treat -200 as information, not as a number. There are two kinds of -200 and they need different treatments:

  • In the target, it is a deterministic consequence of a feature: predict it with the rule Temp = -200 → -200, and train the regression only on the rows with a real reading. Otherwise these 251 outliers dominate a squared-error fit and bias every other prediction.
  • In the features, it is a missing value. Replacing it by NaN and using gradient-boosted trees, which learn a separate direction for missing values at each split, lets the model use "NOx analyser offline" as a signal instead of reading -200 as an extremely clean hour.

A tree model can also find the rule by itself, because a missing temperature is a perfect split. That is exactly what happens: LightGBM without the explicit rule scores 122.9, with the rule 122.8. The rule is crucial for a linear model and harmless for trees, so we keep it explicitly: it documents the physics and protects against any model that might smooth over it.

Two physics-inspired features add a little:

  • Absolute humidity. Tin-oxide sensors react to water vapour, and the amount of water in the air depends on both temperature and relative humidity: AH=6.112 e17.67T/(T+243.5)⋅RH⋅2.1674/(273.15+T)\text{AH} = 6.112\,e^{17.67T/(T+243.5)} \cdot \text{RH} \cdot 2.1674/(273.15+T) grams per cubic metre (Magnus formula).
  • The NOx / NO₂ ratio, which changes with the age of the emissions and the type of vehicles.

The full solution

  1. Predict -200 wherever the temperature is -200.
  2. On all other rows, replace -200 by NaN, add absolute humidity and the NOx / NO₂ ratio.
  3. Fit LightGBM (1 500 trees, learning rate 0.01, 15 leaves, at least 30 rows per leaf, row and column subsampling 0.8, L2 = 3) on the training rows with a valid target.
ok = Xtr.Temp_Lima.values != -200                           # train on real readings only
m = lgb.LGBMRegressor(**PARAMS).fit(features(Xtr)[ok], ytr[ok])
pred = np.where(Xte.Temp_Lima.values == -200, -200.0, m.predict(features(Xte)))
  1. Predict the validation and test hours and write both into the submission file.

Results

5-fold cross-validated RMSE on the training hours (the official labels are private).

approachRMSE, all rowsRMSE, valid rows only
constant mean327.7222.3
constant mean + failure rule212.4216.6
linear regression + failure rule140.5143.3
LightGBM, raw features, no rule122.9125.3
LightGBM + failure rule122.8125.2
LightGBM + rule + humidity and ratio features121.1123.5
same, tuned (final)120.9123.4

The final model reaches RMSE 120.9 in cross-validation, against 166.7 for the organisers' baseline on the public leaderboard. The remaining error is large in absolute terms (about 10 % of the typical value), and that is expected: four features cannot tell rush hour from night, wind from calm air, or a fresh sensor from an aged one. Without the time of day the model has no way to recover more, and the gains from tuning are already tiny.

What to remember

  • Look at the special values before modelling. A sentinel code like -200 is not a measurement; count where it appears and in which combinations.
  • A deterministic rule beats any model. "Target is -200 exactly when the temperature is -200" holds on all 6 549 rows; hard-code it, and train the model on the rest.
  • Missing is a value. Trees with native missing-value handling use "sensor offline" as information; median imputation throws it away.
  • RMSE punishes outliers quadratically. 4 % of rows wrong by 1 250 cost more than everything else together, so they deserve the first look.
  • Domain knowledge gives features. Absolute humidity is what a gas sensor physically responds to; relative humidity alone is only half of it.
Full code (solution.py)
"""Sentinel of the Metropolis (Peru OPEIA 2026, ML): soft sensor for the PT08.S1(CO) response.

1. -200 is a failure code. The target is -200 exactly when the temperature is -200 (the
   same multisensor device fails), so those rows are predicted as -200.
2. Other -200 values become missing values (LightGBM handles NaN natively, and a missing
   NOx reading is itself informative).
3. Two physics-inspired features: absolute humidity from T and RH (tin-oxide sensors react to
   water vapour) and the NOx / NO2 ratio.
4. LightGBM regression on valid rows, scored by 5-fold cross-validated RMSE.

Data: kaggle competition "ioai-machine-learning-challenge-2026".
Usage: python solution.py   -> CV RMSE + submission.csv (val_* and test_* rows, not submitted)
Runtime: ~20 s on 2 CPU threads.
"""
import numpy as np, pandas as pd, lightgbm as lgb
from sklearn.model_selection import KFold

PARAMS = dict(n_estimators=1500, learning_rate=0.01, num_leaves=15, min_child_samples=30, subsample=0.8,
              subsample_freq=1, colsample_bytree=0.8, reg_lambda=3, verbose=-1, n_jobs=2)


def features(X):
    D = X.replace(-200, np.nan)
    T, RH = D.Temp_Lima, D.Humedad_Relativa
    # absolute humidity (g/m3): saturation vapour pressure (Magnus formula) x relative humidity
    D['abs_hum'] = 6.112 * np.exp(17.67 * T / (T + 243.5)) * RH * 2.1674 / (273.15 + T)
    D['ratio'] = D['NOx(GT)'] / D['Emisiones_Diesel']
    return D


def fit_predict(Xtr, ytr, Xte):
    ok = Xtr.Temp_Lima.values != -200                       # train only on rows with a real reading
    m = lgb.LGBMRegressor(**PARAMS).fit(features(Xtr)[ok], ytr[ok])
    return np.where(Xte.Temp_Lima.values == -200, -200.0, m.predict(features(Xte)))   # failure rule


if __name__ == '__main__':
    X = pd.read_csv('X_train.csv'); y = pd.read_csv('y_train.csv').iloc[:, 0].values
    assert ((y == -200) == (X.Temp_Lima.values == -200)).all()    # the failure rule holds on every training row
    pred = np.zeros(len(y))
    for tr, va in KFold(5, shuffle=True, random_state=0).split(X):
        pred[va] = fit_predict(X.iloc[tr], y[tr], X.iloc[va])
    print(f'5-fold CV RMSE: {np.sqrt(np.mean((pred - y) ** 2)):.2f}')
    Xv, Xt = pd.read_csv('X_val.csv'), pd.read_csv('X_test.csv')
    p = fit_predict(X, y, pd.concat([Xv, Xt], ignore_index=True))
    ids = [f'val_{i}' for i in range(len(Xv))] + [f'test_{i}' for i in range(len(Xt))]
    pd.DataFrame({'Id': ids, 'y': np.round(p, 1)}).to_csv('submission.csv', index=False)
    print('wrote submission.csv (not submitted)')