Wrocław, al. Wiśniowa · rebuilt daily
A portfolio data project, published live: each morning hourly GIOŚ measurements and Open-Meteo weather land in a SQLite store, a PM2.5 forecast is retrained on the rolling year behind them, and this page is rebuilt from that run. The model it selected, the error it measured and the hours each predictor earned are recomputed every time — as are the checks that decide what may be published at all, which is why some sections below report a measurement that did not ship. Code, tests and the reasoning behind each decision are on GitHub.
Current air-quality index: Bardzo dobryPM2.5
GIOŚ's overall index for this station is Bardzo dobry; the badge above is its PM2.5 component, the pollutant this page forecasts.
Anchored on the 2026-09-01 11:00 CEST reading, 1 h before this page was built.
Always trained on earlier hours and scored on later ones (1,675 held-out hours) — a chronological split, never a random one. The model serving the chart above uses these settings but is refitted on all available data, so the figures here describe the method rather than that exact artefact.
Averaged over 5 rolling folds spanning the whole year, the forecast lands 6.89 ± 2.98 µg/m³ from what was actually measured. On the most recent held-out window alone (2026-06-14 to 2026-09-01) it does better — 3.50 µg/m³ — because that window is summer, and summer air is far easier to predict than a winter smog episode. The table below uses that window; this sentence is the number to trust.
| Predictor | MAE ↓ | RMSE ↓ | R² ↑ | Bias →0 |
|---|---|---|---|---|
| HistGradientBoosting (this project) | 3.50 | 4.38 | 0.16 | +1.29 |
| Naive rule — “same hour, yesterday” | 3.91 | 5.20 | -0.17 | -0.15 |
| Flat line — “the training-period average, every hour” | 10.90 | 11.80 | -5.05 | +10.78 |
All 3 are scored on the same held-out window — 2026-06-14 to 2026-09-01. MAE, RMSE and bias are in µg/m³ (↓ lower is better); R² is a ratio (↑ higher is better). Bias is the signed average error — positive means the forecast runs high — so zero, not lower, is its target.
Scored on the same rolling folds as the headline above, the model's average miss is 17.5% smaller than the naive rule's — 6.89 against 8.35 µg/m³. On the single held-out window in the table below, that gap is 10.4% and the model removes 29% of that rule's squared error. Against a flat line drawn at the window's own average — a reference only available with hindsight — it removes just 16%, and that second number is exactly what R² reports.
The main model is trained on one task — predict 24 hours ahead — and the lead time is not one of its inputs, so its line is flat: the same error at every hour of the chart above. Neither of the other two is. “The air will stay as it is now” is strong one hour out and weak a day out, and a predictor fitted for a single lead works from a fresher reading the closer that lead is. The error this page headlines describes the 24-hour task; the chart is why one figure could not describe the rest.
| Lead | Model MAE ↓ | Specialist MAE ↓ | Naive MAE ↓ | Paired Δ | Folds won | Served by |
|---|---|---|---|---|---|---|
| +1 h | 6.89 | 4.18 | 3.47 | -3.42 | 0/5 | naive |
| +3 h | 6.88 | 5.49 | 5.21 | -1.67 | 0/5 | naive |
| +5 h | 6.89 | 6.11 | 6.43 | -0.46 | 2/5 | naive |
| +6 h | 6.89 | 6.42 | 6.89 | +0.00 | 3/5 | model |
| +12 h | 6.89 | 6.57 | 8.27 | +1.38 | 4/5 | model |
| +24 h | 6.89 | 6.89 | 8.35 | +1.46 | 5/5 | model |
Every predictor scored on the same rolling folds and the same rows. “Paired Δ” is the naive rule's error minus the model's — positive means the model is ahead — and “folds won” counts the folds it actually won; both columns compare the first and third only. A gap that does not hold fold by fold is noise, however large its average looks. Where the model's column still moves by a hundredth, that is the scoring set rather than the model: a lead whose origin reading is missing drops that hour from every predictor at once, so the average is taken over slightly different hours.
The naive rule on this chart is the reading in hand at the moment of issue. At +24 h that is the same prediction as “same hour, yesterday”, the rule quoted in the headline table, so the two necessarily come out at the same number there.
For the first 5 hours the published forecast is the naive rule itself — “the reading at the moment the forecast is issued”. Over that range the model is measurably worse than repeating the current reading, so serving a model there would look more sophisticated and be less accurate.
The specialist column is a measurement that did not ship on this run. Only 11 of the 24 leads beat both other predictors by enough to earn their hours, against the 13 the decision required — so this run publishes the null and the two predictors above serve the chart. The bar is fixed and re-measured on every retrain, which is what a bar set in advance is for.
A single value invites belief in its second digit; an interval says something a reader can check instead: an 80% band claims that 80% of measured hours land inside it. That claim was tested on rolling held-out folds the bands were not fitted on, and a band is drawn here only if it came back close to what it promises — on the average and in every period separately. The nearest miss is the band from its own held-out errors, at 82% on average — and its coverage still ranges from 68% to 87% across the folds, so it is not an 80% interval in any single period. Measured on the same folds, the naive rule's band does widen with the lead — the air drifts further from the current reading in a day than in an hour, and the range says so.
| Interval | Measured coverage | Typical width (µg/m³) | On the chart |
|---|---|---|---|
| Model — quantile regression | 56% | 11.80 | withheld |
| Model — band from its own held-out errors | 82% | 24.22 | withheld |
| Naive rule — per lead, from the record | 79% | 23.75 | drawn |
The band on the chart above is drawn over the naive rule's hours, and only there. Where its lower edge would fall below zero the chart draws it at zero, since a negative concentration is not a reading; the width in the table is the unclipped one. The other constructions are listed because they were measured and did not pass; an interval nobody can see the check for is an interval a reader has to take on trust.
337 hours the model had never seen — from the chronologically-trained model, not the one serving the chart at the top. That one is refitted on all available data, so plotting its fit over recent days would be showing it hours it learned from.
| Hours | MAE ↓ | Bias →0 | Naive MAE | Naive bias |
|---|---|---|---|---|
| Below 15 µg/m³ 1,385 hours |
3.25 | +2.36 | 3.50 | +0.83 |
| At or above 15 µg/m³ 290 hours |
4.71 | -3.81 | 5.89 | -4.86 |
Split by what was measured, not by what was predicted. 15 µg/m³ is the WHO 24-hour guideline level, used here as a reference for hourly readings — not as a compliance test, which would apply to daily means.
Of the 290 hours that actually reached 15 µg/m³, the forecast flagged 37% (the naive rule: 32%). The warnings are not free: 54% of the hours it flagged turned out to be below the line, against 66% for the naive rule.
Each change below was argued for, measured on the same rolling folds as the headline error, and dropped. Publishing them is not modesty: measuring them is what caught two defects this page was shipping at the time — an improvement figure that paired an all-seasons error with a summer gain, and one error figure standing in for twenty-four different tasks.
A bearing breaks at north — 359° and 1° are nearly the same wind, at opposite ends of the number the model reads. Splitting it into components is the textbook fix.
Measured effect: nothing consistent for the deployed model; 0.10 µg/m³ to a candidate that starts more than a µg/m³ behind it.
Three encodings were scored on identical folds — the raw bearing, u/v components, and sine/cosine — then re-scored one test period at a time when this project replaced its test for what counts as a real gain. For the deployed model neither re-encoding wins consistently: whichever way the average leans, some periods go the other way. A gradient-boosted tree already carves the circle with a second split, so the break at north costs it essentially nothing.
One result did point the other way, on the candidate the physical argument named in advance. The linear model — the single one that structurally cannot read a bearing — came out ahead under sine/cosine in four of the five test periods, with the fifth too close to call and none against. That is about as much as five periods can show, and it still does not reach this page: the linear model trails the deployed one by more than a full µg/m³, and winning back a tenth of one does not close a gap that size.
The station reports NO₂ and CO at full coverage, so adding them costs no training rows — and unlike a re-encoding, they carry genuinely new information.
Measured effect: nothing consistent for the deployed model.
Both were tried at a 24-hour lag and with the full treatment PM2.5's own history gets. The argument was right in kind and wrong in magnitude: NO₂ and PM2.5 in a city share their sources — traffic, heating, the same boundary layer — so a day-old NO₂ reading mostly repeats what the weather columns and yesterday's PM2.5 already carry. For the deployed model neither variant wins consistently; the test periods disagree in both directions.
One result did come out ahead in every period — a candidate that is not the one deployed, gaining four hundredths of a µg/m³ from NO₂ and CO. Nothing predicted it in advance, which is the whole of what can be said for it; see the note below the list.
The model cannot see its own lead time, which is why its error is flat across the chart above. Adding the lead as a column would have been one model instead of many.
Measured effect: +2.4 µg/m³ worse than the naive rule at the lead it was meant to fix.
Trained across all 24 leads at once it reached 6.12–6.25 µg/m³ at the +1 h lead, against 3.75 for simply repeating the current reading — worse than doing nothing, on the hour the change was meant to rescue. Capacity was the obvious objection and was tested: a far larger model was worse at every lead, so this is overfitting, not underfitting. The weakness is structural. At +1 h the answer is nearly “copy the reading in hand”; at +24 h that same input is almost noise, and one extra column cannot express a relationship that inverts across the range.
Two results above came out ahead in every period that separated them, and neither belongs to the deployed model. Weigh them against the number of chances: this run made 12 comparisons, and with 8 of its 60 periods too close to call, a change that did nothing at all would be expected to produce about 1.4 such clean results anyway. The count is what chance gives, so neither is evidence on its own. The one that carries any weight is the one a physical argument named before it was measured — and it belongs to a model this page does not serve.
Measured on hours from 2025-07-24 → 2026-07-17, when the deployed model's year-round error was 6.97 µg/m³, against 6.89 µg/m³ on the run this page describes. Hence differences rather than errors: the level moves with the window, the difference is what was learned.
Take every forecast, measure how far it landed from what was actually recorded (ignoring whether it was too high or too low), and average those distances. That is the MAE: the size of a typical miss.
Range: 0 and upwards. 0 is a flawless forecast; there is no upper limit, because errors are expressed in the same unit as the pollutant itself. For scale: over the scored window PM2.5 averaged 10.2 µg/m³, and the WHO 24-hour guideline is 15 µg/m³. A miss of 3.50 is therefore about 34% of a typical reading from that same period.
Also an average error, but each miss is squared before averaging, so one badly wrong hour counts for far more than several slightly wrong ones. It is the metric to watch when rare, large mistakes are the ones that matter — and for air quality they are, because those are the smog episodes people need warning about.
Range: 0 and upwards, and always at least as large as the MAE. The gap between the two is the interesting part. Here RMSE is 1.25× the MAE, close to the 1.25 you would get from evenly scattered errors — no small group of disastrous hours is carrying the average.
How much of the movement in PM2.5 the model reproduces, rather than how far off it is. It answers a different question from MAE: not “by how much am I wrong?” but “am I tracking the ups and downs at all?”.
Range: 1.0 reproduces every wiggle perfectly. 0.0 means doing no better than predicting the average of the very window being scored and never moving. That average can only be known after the fact, which is why it is not a row in the table: the flat line there uses the training average, the only one available in advance, and on a summer window that average is far too high — hence its deeply negative score. Negative values are possible for any predictor, and mean it does worse than the hindsight average. This model scores 0.16, and the window is much of the reason: PM2.5 varied by only 4.8 µg/m³ (standard deviation) across the scored hours, against 14.6 over the training period. With less variation available to explain, every predictor's R² is squeezed toward zero, and the naive rule manages only -0.17 on the same hours. Read alongside the error figures rather than on its own — R² is the metric here that moves most with the season.
Why there are three rows, not one. Air pollution is persistent, so “the same as yesterday at this hour” is already a decent guess, and a model can post respectable-looking numbers while adding almost nothing. R² and the skill figure are the same calculation against two different references — a flat line and the naive rule. A forecast has to beat both to be worth running, so both are shown.
How this model was chosen. Three candidates (Ridge, HistGradientBoosting, RandomForest) were scored by rolling-origin cross-validation and HistGradientBoosting came out ahead. The choice deliberately runs on cross-validation rather than on the window in the table above: picking a winner on the very rows that are then published would make those figures a best-of-three rather than an honest estimate. It came out 0.49 µg/m³ ahead of RandomForest — 6.89 against 7.38 — winning 5 of 5 folds, and it never came out behind on a fold. The pipeline retrains daily on a rolling year, so the winner can change between runs — this page always names the one it actually used.
Why a chronological split, and why two error figures. The test set is always strictly later in time than the training set; shuffling the hours at random would let the model peek at the future while learning the past. One split still only covers one season, so the headline figure comes from rolling-origin cross-validation, where every fold trains on the past and is tested on the future that follows it.