A standalone PyTorch LSTM system for direct, multi-horizon forecasting of water temperature and dissolved oxygen at USGS station 02334500. The model uses 48 hours of 15-minute observations to forecast the next 24 hours, or 96 steps, in one forward pass.
Unlike the main spatiotemporal model, this experiment is a single-site deterministic LSTM. It does not use PatchTST, graph attention, Koopman dynamics, physics-informed losses, or diffusion sampling.
- Problem Statement
- Results
- Architecture Overview
- Feature Engineering
- Repository Structure
- Installation
- Usage
- Limitations
Dissolved oxygen and water temperature are important indicators of river health. The LSTM forecasts both variables from historical observations and calendar-derived features at USGS station 02334500:
- Water temperature in degrees Celsius.
- Dissolved oxygen in mg/L.
The validation experiment used 17,472 observations from January 1 through June 30, 2024. Sliding windows contained 192 historical steps and a 96-step forecast horizon. Scalers were fitted on training rows only, and forecast targets were kept within chronological split boundaries.
| Split | Forecast windows | Purpose |
|---|---|---|
| Training | 11,943 | Fit model parameters |
| Validation | 2,525 | Select the best checkpoint |
| Test | 2,527 | Report held-out performance |
| Total | 16,995 |
The table below reports the saved held-out test metrics for the best validation checkpoint. Persistence skill compares the LSTM with repeating the last observed value:
skill = 1 - model MSE / persistence MSE
| Target | RMSE | MAE | R-squared | Persistence skill |
|---|---|---|---|---|
| Temperature | 0.776 C | 0.604 C | 0.227 | +0.603 |
| Dissolved oxygen | 1.408 mg/L | 0.990 mg/L | 0.263 | -0.021 |
The LSTM reduced temperature mean-squared error by about 60% relative to persistence. Dissolved-oxygen mean-squared error was about 2.1% worse than persistence in this bounded run.
The test split contained no dissolved-oxygen values below the configured 2 mg/L threshold. Hypoxia precision, recall, and F1 are therefore not measurable for this run and are not interpreted as model skill.
The inference script exported a complete 96-step forecast for a historical 24-hour window.
This figure is a forecast profile, not a forecast-versus-observed accuracy plot, because the exported inference CSV contains predictions and timestamps but no paired observations for that window. Aggregate accuracy is reported above.
The bounded validation run used a one-layer LSTM with hidden size 64. The best checkpoint was selected at epoch 4 using validation physical-unit mean RMSE.
| Setting | Value |
|---|---|
| LSTM layers | 1 |
| Hidden size | 64 |
| Trainable parameters | 38,114 |
| Epoch budget | 8 |
| Batch size | 256 |
| Optimizer | AdamW |
| Loss | Weighted Huber |
| Temperature loss weight | 1.0 |
| Dissolved-oxygen loss weight | 2.0 |
| Gradient clipping | 1.0 |
Training loss continued to decrease after epoch 4 while validation error worsened, indicating early overfitting. The saved best checkpoint prevents later epochs from replacing the better validation model.
The following figure reports target-specific RMSE at each of the 96 forecast leads. The panels use separate physical units.
The dissolved-oxygen error is larger than the temperature error and rises substantially across the horizon. The non-monotonic temperature curve reflects the daily cycle and the specific held-out test period.
Input: [batch, 192, 15]
|
v
One-layer LSTM
hidden size 64
|
v
Final hidden state: [batch, 64]
|
+-----------------------------+
| |
v v
96 learned horizon embeddings Future calendar features
| |
+-------------+---------------+
|
v
Horizon states: [batch, 96, 64]
|
v
Shared MLP head
/ \
v v
Temperature head DO head conditioned
on temperature
\ /
v v
Direct output: [batch, 96, 2]
|
v
Add last observed target values
The model predicts all 96 future steps directly and is not autoregressive. By default, each target is predicted as a residual from its last observed scaled value, providing a persistence-like starting point.
The validation run used 15 historical features:
- Historical temperature and dissolved oxygen.
- Hour-of-day, weekday, and annual-cycle sine/cosine features.
- Six-hour temperature mean and 24-hour temperature mean.
- Six-hour dissolved-oxygen mean.
- One-hour temperature and dissolved-oxygen changes.
- 24-hour temperature range.
- Gage height.
Six known future calendar features were supplied over the forecast horizon. No future meteorological file was available during this run, so future air temperature, radiation, wind, cloud cover, and humidity were not included.
.
├── data/raw/02334500/iv.parquet # Included USGS observations
├── figures/lstm/ # README figures
├── lstm_model/
│ ├── model.py # Direct multi-horizon LSTM
│ ├── data.py # Loading, features, scaling, windows
│ ├── train.py # Training and validation
│ ├── evaluate.py # Test and lead-time metrics
│ ├── predict.py # Timestamped forecast export
│ ├── config.yaml # Main experiment configuration
│ ├── config_validation.yaml # Bounded validation configuration
│ ├── checkpoints_validation/best.pt
│ ├── evaluation_metrics_validation.json
│ ├── evaluation_metrics_validation_by_lead.csv
│ ├── training_log_validation.csv
│ └── create_readme_figures.py # Rebuilds all four figures
├── output/pdf/ # Validation and explainer reports
├── DATASET.md
├── requirements.txt
├── verify.py
└── README.md
Python 3.10 or newer is recommended.
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements.txtOn macOS or Linux:
source .venv/bin/activateVerify the included data, model, checkpoint, and forecast path:
python verify.pyRun the LSTM smoke test:
python -m lstm_model.smoke_test --config lstm_model/config.yamlTrain the bounded validation configuration:
python -m lstm_model.train --config lstm_model/config_validation.yamlTrain the full configuration:
python -m lstm_model.train --config lstm_model/config.yamlEvaluate a checkpoint:
python -m lstm_model.evaluate `
--checkpoint lstm_model/checkpoints_validation/best.ptGenerate a 24-hour forecast:
python -m lstm_model.predict `
--checkpoint lstm_model/checkpoints_validation/best.pt `
--output lstm_model/latest_forecast.csvRegenerate all README figures from the saved metrics and logs:
python lstm_model/create_readme_figures.pyThis is a bounded CPU verification experiment using six months from one station and a smaller one-layer LSTM. The test period contains no hypoxic events, dissolved-oxygen performance does not beat persistence, and the exported forecast profile has no paired observations. A publication-quality comparison should use longer multi-season data, multiple stations, genuine future weather forecasts, hypoxia-containing test periods, identical leakage-safe splits, and multiple random seeds.
For the full saved artifact details, see lstm_model/RESULTS.md, the validation report, and the beginner LSTM explanation.



