A cheap weather station costs about thirty euro. Mine sits on a shelf and shows the temperature, the pressure, the humidity, and a guess at the next few hours of weather. That guess is not machine learning. It is a fixed rule from the 1920s called the Zambretti forecaster. It reads the barometer, checks whether the pressure is rising or falling, glances at the wind, and picks one of twenty six canned forecasts.
This project asks a simple question. Given the same kind of readings a home station has, can a model do better than that rule? The answer is yes, and by a clear margin.
It predicts the next few hours from the last few hours of readings. This is called nowcasting. The main target is rain: will it rain in the next N hours, three by default. The suite adds three more targets: temperature, fog, and frost.
Everything is trained on about ten years of hourly data and tested on the most recent two years, which the model never sees while training.
It does not forecast days ahead. A single station only sees one point on the map. To forecast well beyond a few hours you need to see weather systems coming from far off, and that means data from many places. So the aim is not to beat the national forecast. The aim is to beat the gadget on the shelf, and to find out how far one station, or a few, can actually go.
The model is a gradient boosted decision tree. I use scikit-learn's HistGradientBoosting, which is the same idea as LightGBM. It builds a lot of small decision trees one after another, and each new tree tries to fix the mistakes of the trees before it. For rain, fog and frost it predicts a probability. For temperature it predicts a number of degrees.
Here is the flow, from the readings to the prediction:
flowchart TB
A["Hourly readings<br/>temperature, humidity, dew point, pressure, wind"] --> B["About 40 features<br/>current values, values 1 to 6 hours ago,<br/>pressure and humidity trends,<br/>rolling summaries, time and season"]
subgraph GBT["Gradient boosted trees"]
direction LR
T1["tree 1"] -->|correct the error| T2["tree 2"] -->|correct the error| T3["tree 3"] --> TN["tree N"]
end
B --> GBT
GBT --> D["Prediction<br/>rain probability for the next few hours"]
Each tree in the chain works on what the trees before it got wrong. Add them all up and you get the prediction. That is what gradient boosting means.
I do not feed the raw readings straight in. For each hour I build about forty features from the readings up to that point:
- the current values: temperature, humidity, dew point, pressure, and wind.
- the recent past: those same values one, two, three, and six hours ago.
- the trends: how far the pressure has moved over the last one, three, and six hours, and the same for humidity and temperature. The trend is what the barometer is really telling you.
- rolling summaries: the lowest, highest, and spread of pressure over the last six hours.
- the time and the season, encoded so hour 23 sits next to hour 0.
Every feature only looks backwards. Nothing at time t uses a reading from after t. The label is the only thing that looks forward. A test checks this holds (see How I kept it honest).
To train, I sort by time and split into three parts. The oldest seventy percent is for training. The next ten percent is for picking the decision threshold. The most recent twenty percent is the test set, about two years the model never sees while training.
The model competes against three simple baselines: persistence (assume the next few hours look like right now), climatology (always guess the long run average), and Zambretti (the barometer rule the cheap station uses).
Rain, fog, and frost are yes or no questions, and the model answers each with a probability. Three scores grade those probabilities.
ROC-AUC is about ranking. Take one hour where it rained and one where it did not, at random. ROC-AUC is the chance the model scored the rainy hour higher. Half is a coin flip. One is perfect. It does not depend on where you set the cut off. Its weakness is that when an event is rare it can look flattering, because there are so many easy dry hours to get right.
PR-AUC is the honest score when the thing you predict is rare. It blends precision (of the hours you flagged, how many were right) and recall (of the hours that mattered, how many you caught). Its floor is not a half. Its floor is the base rate, the share of hours that actually rain. So a rain PR-AUC of 0.68 when one hour in five rains sits well above the 0.21 floor.
Brier skill is about whether the probabilities are honest, not just well ranked. The Brier score is the average squared gap between the probability and what happened. Say ninety percent and it rains: small gap, good. Say ninety percent and it stays dry: big gap, bad. Lower is better. Brier skill rescales that against always guessing the average. Zero means no better than that guess, higher is better, and a negative value means worse. Zambretti lands negative because it only gives a hard yes or no, so its probabilities are poorly calibrated.
Temperature is a number, not a yes or no, so it uses different scores. MAE is the average miss in degrees. R2 is the share of the ups and downs the model explains, where one is perfect. Skill versus persistence is how much smaller the error is than just assuming the temperature holds steady.
The numbers below are for Dublin Airport, using real Met Eireann station data, tested on the last two years.
| Method | PR-AUC | ROC-AUC | Brier |
|---|---|---|---|
| Model (gradient boosted trees) | 0.67 | 0.87 | 0.11 |
| Persistence (is it raining now) | 0.46 | 0.71 | 0.14 |
| Zambretti (the barometer rule) | 0.33 | 0.71 | 0.32 |
| Climatology (always guess the average) | 0.21 | 0.50 | 0.16 |
PR-AUC and ROC-AUC measure how well each method ranks rain risk. Neither depends on a chosen cut off, so they are the fair comparison. The model roughly doubles the ranking skill of the Zambretti rule. Its probabilities are honest too. When it says sixty percent, it rains about sixty percent of the time.
| Target | Task | Model score | Naive baseline | Grade |
|---|---|---|---|---|
| rain (next 3h) | classification | ROC-AUC 0.88, PR-AUC 0.68 | base rate 21% | Excellent |
| temperature (+3h) | regression | MAE 0.91 C, R2 0.94 | persistence 1.48 C | Excellent |
| fog (next 3h) | classification | ROC-AUC 0.95, PR-AUC 0.21 | base rate 1.2% | Good, but rare |
| frost (next 3h) | classification | ROC-AUC 1.00, PR-AUC 0.90 | base rate 2.9% | Easy target |
Temperature is the strongest result. The model predicts the temperature three hours out to within about one degree. The naive "same as now" guess is fine at midnight, but it falls apart at dawn and dusk when the temperature turns. The model learned that daily rhythm, so its error stays flat while the naive error more than doubles.
Fog is ranked well, but it is rare, so the precision you can reach is limited. I left that number honest rather than dress it up. Frost looks almost perfect, but that is mostly because freezing in three hours is already decided by how cold it is right now. It is the easy one.
Irish weather comes off the Atlantic from the west and south west. So I added recent readings from three stations upwind of Dublin: Valentia, Shannon, and Casement. A station upwind sees the weather before Dublin does.
| Horizon | Dublin only (PR-AUC / ROC) | With upwind (PR-AUC / ROC) |
|---|---|---|
| 1h | 0.62 / 0.90 | 0.71 / 0.93 |
| 3h | 0.67 / 0.87 | 0.75 / 0.91 |
| 6h | 0.73 / 0.86 | 0.80 / 0.90 |
| 12h | 0.80 / 0.85 | 0.84 / 0.88 |
It helps at every horizon. The gain is largest from one to six hours, then it fades by twelve. That matches the geography. Those stations are only about one and a half to six hours upwind, so that is the window where they carry the most information. By twelve hours the weather they saw has already arrived. I like this result because the model's skill lines up with real physics, not with noise.
I built an LSTM on the raw hourly sequence and put it head to head with the trees, on the same test rows.
| Model | PR-AUC | ROC-AUC | Brier skill |
|---|---|---|---|
| LSTM (24 hour sequence) | 0.65 | 0.87 | 0.14 |
| Gradient boosted trees | 0.68 | 0.88 | 0.36 |
The trees win. That fits what most people find on tabular data. The LSTM is close on ranking, but its probabilities are worse and it costs far more compute. Knowing when not to reach for deep learning is part of the job.
Setup:
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
Set your location in config.py (LATITUDE, LONGITUDE, LOCATION_NAME). Then:
.venv/bin/python train.py # rain model, benchmarked against the baselines
.venv/bin/python train_suite.py # all four targets, with a scorecard
.venv/bin/python train_spatial.py # the upwind experiment (needs the extra station files)
.venv/bin/python train_dl.py # LSTM against trees (needs torch)
.venv/bin/python predict.py # rain chance for the next few hours, right now
Charts and metrics land in reports/.
Two sources, set by DATA_SOURCE in config.py.
Open-Meteo downloads about ten years of hourly history for any coordinates, with no key. It is the easy way to get started anywhere.
Met Eireann gives real station readings, including a real rain gauge. Their bulk download host is gone, so you export the files from met.ie by hand (Climate, Available Data, Historical Data) and drop them in data/. See data/README.md for the station numbers and the steps.
- Split by time, not at random. Train on the oldest seventy percent, test on the most recent twenty. Shuffling a time series lets the future leak into training.
- No cheating inputs. By default the model only sees what a home station measures: temperature, humidity, dew point, pressure, and wind, plus their trends. Cloud cover and a rain gauge reading are off by default, behind flags in config.py.
- Real baselines. Persistence, climatology, and Zambretti itself. The model has to beat all three.
- Metrics that suit rare events. Rain is uncommon, so plain accuracy misleads. I lead with PR-AUC, Brier score, and recall.
- A test that checks for leakage. It perturbs the future and confirms that past features do not move.
Run the tests with:
.venv/bin/python -m pytest -q
| File | Role |
|---|---|
| config.py | location, targets, data source, and all the knobs |
| fetch_data.py | download and cache hourly history from Open-Meteo |
| fetch_meteireann.py | read real Met Eireann station files |
| features.py | build features with no leakage, and the labels |
| baselines.py | persistence, climatology, Zambretti |
| targets.py | the four target definitions (rain, temp, fog, frost) |
| train.py | train and benchmark the rain model |
| train_suite.py | train and score all four targets |
| spatial_features.py | build upwind station features and merge them |
| train_spatial.py | the upwind experiment across horizons |
| train_dl.py | the LSTM against trees benchmark |
| predict.py | score the current conditions with the trained model |
| tests/ | unit tests, including the leakage guard |
- Longer horizons, and predicting how much rain rather than just whether.
- More upwind stations, or data from further out over the Atlantic.
- A small ablation to see which upwind station carries the most signal.



