Predicting the Standardized Precipitation Evapotranspiration Index (SPEI) for cities in the Brazilian semi-arid region, one LSTM window at a time.
Code evolved from a split of LuizHduarte/Drought.
RedeNeuralSecas (literally "Neural Network Droughts") is a research project that trains Long Short-Term Memory (LSTM) networks to forecast the SPEI β a multi-scalar drought index widely used in climatology β for cities in the north of Minas Gerais, Brazil.
The twist that makes this repository interesting is the one-to-many learning strategy: instead of training a separate model for every city, the project groups municipalities into clusters of geographically close cities and trains one LSTM per cluster using the central city as training anchor. That central-city model is then reused to predict SPEI for every other (bordering) city in the same cluster, testing how well a drought signal learned in one location generalises to its neighbours.
To make the comparison fair and informative, the project runs every experiment under two windowing regimes side by side:
| Technique | Window length | Step | What it tests |
|---|---|---|---|
tumbling |
12 | 12 | Non-overlapping yearly chunks β strong generalisation. |
sliding |
18 | 2 | Heavily-overlapping windows β high resolution, harder. |
Both regimes use the same 6-month forecast horizon, but they differ in every other windowing knob β and that is on purpose, because tumbling and sliding fail and overfit in very different ways:
| Technique | Window length | Window step | Lookback | Horizon |
|---|---|---|---|---|
tumbling |
12 | 12 | 6 | 6 |
sliding |
18 | 2 | 12 | 6 |
The two heads also use independent hyperparameters (epochs, dense layers, units, dropout, optimiser LR) for the same reason.
- π§ Two-headed LSTM design β one model per cluster, two heads per model (tumbling / sliding), trained and evaluated in a single pass.
- ποΈ One-to-many transfer β train on a central city, predict for every bordering city in the cluster using the same normalisation parameters.
- π Rich, side-by-side metrics β MAE, RMSE, MSE and RΒ² computed twice for every city: once in raw NumPy and once through Keras, plus a 3-way equality check (sign / integer / first-4-decimals) between them.
- πΌοΈ Matplotlib visualisations everywhere β training curves, dataset overlays, and prediction-vs-real striped plots per (cluster, model, city, technique).
- π§ͺ Asserted sanity checks β the code refuses to save a model whose training-set RΒ² is β€ 0.
- πΎ Reusable model artefacts β every trained LSTM is saved to
.keras+.weights.h5underOutput/Models/. - π Excel reports β
metrics_central_cities_{tumbling,sliding}.xlsxandmetrics_bordering_cities_{tumbling,sliding}.xlsx.
RedeNeuralSecas/
βββ main.py # Orchestrator: load β train β apply β save
βββ requirements.txt
βββ README.md
β
βββ NeuralNetwork/ # ML core
β βββ config.json # All hyperparameters (tumbling_* + sliding_*)
β βββ classes/
β βββ __init__.py
β βββ neural_network.py # Two-headed LSTM wrapper
β βββ dataset.py # IO, normalisation, windowing
β βββ performance_evaluator.py # Metrics, equality checks, Excel export
β βββ plotter.py # Training-curve & prediction plots
β
βββ NeuralNetworkDriver/ # Data layer
β βββ classes/
β βββ __init__.py
β βββ input_data_loader.py # Walks ./Data/<CLUSTER>/<CITY>.xlsx
β
βββ Data/ # Input: one subfolder per cluster
β βββ ESPINOSA/ # Each folder keeps the '.xlsx' data files
β βββ LASSANCE/
β βββ RIO PARDO DE MINAS/
β βββ SΓO FRANCISCO/
β βββ SΓO JOΓO DA PONTE/
β
βββ Output/ # Auto-recreated on every run
βββ cluster <NAME>/model <NAME>/city <NAME>/ # PNGs, per city & technique
βββ metrics_central_cities_tumbling.xlsx
βββ metrics_central_cities_sliding.xlsx
βββ metrics_bordering_cities_tumbling.xlsx
βββ metrics_bordering_cities_sliding.xlsx
βββ Models/ # Trained LSTM heads, one pair per cluster/technique
βββ <CLUSTER>_tumbling.keras # Full Keras model (architecture + weights)
βββ <CLUSTER>_tumbling.weights.h5 # Weights-only companion to the .keras above
βββ <CLUSTER>_sliding.keras
βββ <CLUSTER>_sliding.weights.h5
Each city file is a plain .xlsx with two columns:
| Column 1 | Column 2 |
|---|---|
Dates |
Series 1 |
- Column 1 β month stamp (the index, parsed as
datetime). - Column 2 β the SPEI value for that month.
The header is read literally (Series 1) and the column is renamed to SPEI Real internally. Reference dataset generation lives in the companion repository JVSREco19/GenerateCitiesSPEI.
The folder name under Data/ is the cluster, and the file names inside are the cities. The cluster and one of the cities must share the same name β that city is treated as the central (training) city, all others as bordering (prediction) cities. In the tree below, each β central arrow points at the city that shares its name with the cluster folder.
Data/
ββββESPINOSA
β ESPINOSA.xlsx β central city (training anchor)
β GAMELEIRAS.xlsx
β MAMONAS.xlsx
β MONTE AZUL.xlsx
β MONTEZUMA.xlsx
β SANTO ANTΓNIO DO RETIRO.xlsx
β
ββββLASSANCE
β AUGUSTO DE LIMA.xlsx
β BUENΓPOLIS.xlsx
β β¦
β LASSANCE.xlsx β central
β β¦
β
ββββRIO PARDO DE MINAS
β β¦
β RIO PARDO DE MINAS.xlsx β central
β β¦
β
ββββSΓO FRANCISCO
β β¦
β SΓO FRANCISCO.xlsx β central
β
ββββSΓO JOΓO DA PONTE
β¦
SΓO JOΓO DA PONTE.xlsx β central
All hyperparameters live in NeuralNetwork/config.json. They are split per technique so each windowing regime can be tuned independently:
Defaults that are computed at runtime in NeuralNetwork._set_configs() and not exposed in the JSON:
| Knob | Tumbling | Sliding | Why |
|---|---|---|---|
| Optimiser | Adam | Adam | Adam performed best across many LR sweeps. |
| Learning rate | 0.0010 | 0.0015 | Sliding needs a bit more push. |
| Recurrent / dense dropout | 0.0 | 0.2 | Sliding overfits hard β needs regularisation. |
| Loss / metrics | MSE + MAE + RMSE + RΒ² | same | RΒ² is asserted > 0 on the training set. |
| Dense activation | tanh | tanh | ReLU on the 3 hidden layers helped sliding a bit (see code). |
| Output activation | linear | linear | Multi-step regression over 6 months. |
Each (cluster, technique) pair becomes a small LSTM built by NeuralNetwork._create_ml_model:
Input(window = lookback_len, 1)
β
LSTM(hidden_units, tanh, recurrent_dropout) # recurrent_dropout = 0.2 for sliding
β
Dropout(d) # d = 0.0 tumbling / 0.2 sliding
β
Dense(dense_units, tanh) Γ dense_layers
β
Dropout(d)
β
Dense(horizon_len = 6, linear) # 6-month forecast
- Input shape is
(lookback_len, 1)β per-technique (tumbling_lookback_lenorsliding_lookback_len). - Output shape is
(horizon_len,)β a 6-month vector. - Two models per cluster: one for tumbling, one for sliding. They share the same training/eval pipeline but never see each other's data.
# 1. Create a virtual environment and install dependencies
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install -r requirements.txt
# 2. Drop your city .xlsx files under Data/<CLUSTER>/ (see layout above)
# 3. (Optional) tweak hyperparameters in NeuralNetwork/config.json
# 4. Run the orchestrator
python main.pyThe pipeline is fully self-contained:
- PREPARATION β wipe and recreate
./Output/, walkData/, build aDatasetper city and aPlotter. - CREATION β instantiate one
NeuralNetworkper cluster, anchored on its central city. - TRAINING β train both heads (tumbling + sliding) on the central city's 80% split.
- APPLYING β apply the trained model to the central city (20% holdout) and to every bordering city in the cluster, reusing the central city's normalisation parameters.
- TERMINATION β write the two Excel reports per technique and persist the trained models under
Output/Models/.
Console output is grouped by phase and by city, e.g.:
PREPARATION: START
PREPARATION: END
CREATION: START
Created ML model(s) for ESPINOSA
Created ML model(s) for LASSANCE
β¦
CREATION: END
TRAINING: START
β¦
APPLYING: START
Model ESPINOSA:
City GAMELEIRAS
City MAMONAS
β¦
APPLYING: END
TERMINATION: START
TERMINATION: END
For every run you get, under Output/:
Output/cluster <NAME>/model <NAME>/city <CITY>/
βββ <CITY> training loss tumbling.png
βββ <CITY> training loss sliding.png
βββ <CITY> dataset plot.png
βββ <CITY> tumbling predictions.png
βββ <CITY> sliding predictions.png
- Training-loss plots β MSE / MAE / RMSE / RΒ² per epoch, for each technique.
- Dataset plot β the SPEI series with the 80/20 train-test split highlighted.
- Prediction plots β real vs. predicted SPEI, vertically striped between adjacent months (no fake filling lines). Latest fix-set: issues #40, #41, #42, #43.
| File | Rows | Sort key |
|---|---|---|
metrics_central_cities_tumbling.xlsx |
one row per cluster (the central city, 80% + 20%) | Agrupamento, city |
metrics_central_cities_sliding.xlsx |
one row per cluster (the central city, 80% + 20%) | Agrupamento, city |
metrics_bordering_cities_tumbling.xlsx |
one row per (cluster, bordering city) pair | Agrupamento, city |
metrics_bordering_cities_sliding.xlsx |
one row per (cluster, bordering city) pair | Agrupamento, city |
Each row contains, for both the 80% and 20% portions:
MAE / RMSE / MSE / R^2from raw NumPy computation.MAE / RMSE / MSE / R^2from Keras metric tracking.- Three equality flags (
sign_equal,integer_equal,first4_equal) confirming the two implementations agree.
Output/Models/
ESPINOSA_tumbling.keras + ESPINOSA_tumbling.weights.h5
ESPINOSA_sliding.keras + ESPINOSA_sliding.weights.h5
LASSANCE_tumbling.keras + LASSANCE_tumbling.weights.h5
β¦
Ready to be reloaded with tf.keras.models.load_model(...) for downstream analysis or transfer learning.
- Normalisation is shared between central and bordering cities β the (min, max) computed on the central city's training split is reused for every prediction in the same cluster, so the model never sees out-of-distribution inputs.
- Two windowing strategies are kept in lock-step by
Dataset.format_data_for_model(), which returns paralleltumbling/slidingdictionaries for both inputs and month stamps, plus a synthesized'100%'portion (concatenation of the 80% + 20% splits). - The model refuses to ship a broken result: after training,
NeuralNetwork.use_neural_networkasserts that the RΒ² on the 80% training split is strictly positive, for both techniques. A failed assertion aborts the run with a descriptive error pointing at the offending city. - Tumbling vs. Sliding tuning is asymmetric on purpose. The source code is annotated with the values that failed (batch sizes, learning rates, dropout, dense layer counts, epoch counts), so future tuners know what has already been tried. For example:
Adamwith LRs in{0.0001, 0.0002, 0.0003, 0.0005, 0.0010, 0.0020}was tried for sliding and kept failing;0.0015is the surviving choice.
The SPEI series consumed by this project are produced by JVSREco19/GenerateCitiesSPEI.
If you use this project in academic work, please reference the original dissertation it was built for.
No LICENSE file is currently included in this repository, so the licensing terms are unspecified. If you plan to reuse, redistribute, or build on this code, please contact the repository owner first to clarify the intended terms.
Data attribution belongs to the SPEI dataset produced by the companion repository JVSREco19/GenerateCitiesSPEI.
{ // Fraction of each city's series used to fit the LSTM (rest is holdout): "parcelDataTrain" : 0.8, "tumbling_epochs" : 150, // how many full passes over the (non-overlapping) training set "tumbling_dense_layers": 3, // 3 tanh Dense layers stacked after the LSTM cell "tumbling_dense_units" : 6, // units per dense layer "tumbling_hidden_units": 9, // LSTM cell width "tumbling_window_len" : 12, // total months packed into each input window "tumbling_window_step" : 12, // step between windows (12 = non-overlapping yearly chunks) "tumbling_lookback_len": 6, // how many past months the LSTM actually sees "tumbling_horizon_len" : 6, // how many future months the LSTM predicts "sliding_epochs" : 150, // 150 was the sweet spot β overfits at 200/400/800, underfits at 100 "sliding_dense_layers" : 2, // 5, 4, 3, 1 were tried and lost to overfitting "sliding_dense_units" : 12, "sliding_hidden_units" : 18, "sliding_window_len" : 18, // longer window than tumbling β more temporal context "sliding_window_step" : 2, // heavy overlap (step = 2 vs window = 18) "sliding_lookback_len" : 12, // lookback is 2Γ tumbling's "sliding_horizon_len" : 6 // same horizon as tumbling β direct comparison target }