Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub (part 2)
Browse files- .gitattributes +6 -0
- datasets/spatiotemporal_gnn/archive/model_20260916_230515.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260917_045239.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260917_061838.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260917_150452.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260917_232412.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260918_111742.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260918_141053.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260918_181508.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260919_152545.pt +3 -0
- datasets/spatiotemporal_gnn/archive/model_20260920_082410.pt +3 -0
- datasets/spatiotemporal_gnn/cnn/checkpoint.pt +3 -0
- datasets/spatiotemporal_gnn/cnn/logs.txt +89 -0
- datasets/spatiotemporal_gnn/cnn/model.pt +3 -0
- datasets/spatiotemporal_gnn/cnn/selected_subgraph.png +3 -0
- datasets/spatiotemporal_gnn/cnn/training_curves.png +3 -0
- datasets/spatiotemporal_gnn/gru/logs.txt +51 -0
- datasets/spatiotemporal_gnn/gru/model.pt +3 -0
- datasets/spatiotemporal_gnn/gru/selected_subgraph.png +3 -0
- datasets/spatiotemporal_gnn/gru/training_curves.png +0 -0
- datasets/spatiotemporal_gnn/lstm/logs.txt +50 -0
- datasets/spatiotemporal_gnn/lstm/model.pt +3 -0
- datasets/spatiotemporal_gnn/lstm/selected_subgraph.png +3 -0
- datasets/spatiotemporal_gnn/lstm/training_curves.png +3 -0
- datasets/spatiotemporal_gnn/model.pt +1 -1
- requirements.txt +2 -0
- scripts/analysis/__pycache__/check_discharge_quantile_distribution.cpython-311.pyc +0 -0
- scripts/analysis/__pycache__/check_dry_valleys.cpython-311.pyc +0 -0
- scripts/analysis/__pycache__/check_routing_lag_distribution.cpython-311.pyc +0 -0
- scripts/analysis/check_discharge_quantile_distribution.py +104 -0
- scripts/analysis/check_dry_valleys.py +115 -0
- scripts/analysis/check_routing_lag_distribution.py +112 -0
- scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc +0 -0
- scripts/evaluation/evaluate_flood_detection.py +0 -0
- scripts/evaluation/logs.txt +9 -0
- scripts/training/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc +2 -2
- scripts/training/train_spatiotemporal_gnn.py +563 -25
- src/graph/__pycache__/dynamic_features.cpython-311.pyc +0 -0
- src/graph/__pycache__/flood_events.cpython-311.pyc +0 -0
- src/graph/__pycache__/physics_losses.cpython-311.pyc +0 -0
- src/graph/__pycache__/pinball_loss.cpython-311.pyc +0 -0
- src/graph/__pycache__/spatiotemporal_gnn.cpython-311.pyc +0 -0
- src/graph/dynamic_features.py +85 -1
- src/graph/flood_events.py +170 -0
- src/graph/physics_losses.py +154 -0
- src/graph/pinball_loss.py +265 -39
- src/graph/spatiotemporal_gnn.py +192 -7
.gitattributes
CHANGED
|
@@ -226,3 +226,9 @@ datasets/evaluation/station_timeseries_H420021012_h1.png filter=lfs diff=lfs mer
|
|
| 226 |
datasets/evaluation/station_timeseries_H605641101_h1.png filter=lfs diff=lfs merge=lfs -text
|
| 227 |
datasets/evaluation/station_timeseries_H605641201_h1.png filter=lfs diff=lfs merge=lfs -text
|
| 228 |
datasets/evaluation/station_timeseries_H605641401_h1.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
datasets/evaluation/station_timeseries_H605641101_h1.png filter=lfs diff=lfs merge=lfs -text
|
| 227 |
datasets/evaluation/station_timeseries_H605641201_h1.png filter=lfs diff=lfs merge=lfs -text
|
| 228 |
datasets/evaluation/station_timeseries_H605641401_h1.png filter=lfs diff=lfs merge=lfs -text
|
| 229 |
+
datasets/spatiotemporal_gnn/cnn/selected_subgraph.png filter=lfs diff=lfs merge=lfs -text
|
| 230 |
+
datasets/spatiotemporal_gnn/cnn/training_curves.png filter=lfs diff=lfs merge=lfs -text
|
| 231 |
+
datasets/spatiotemporal_gnn/gru/selected_subgraph.png filter=lfs diff=lfs merge=lfs -text
|
| 232 |
+
datasets/spatiotemporal_gnn/lstm/selected_subgraph.png filter=lfs diff=lfs merge=lfs -text
|
| 233 |
+
datasets/spatiotemporal_gnn/lstm/training_curves.png filter=lfs diff=lfs merge=lfs -text
|
| 234 |
+
scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
datasets/spatiotemporal_gnn/archive/model_20260916_230515.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aefbe28c1a48651f1773b0515175c49b9d98206c2e9151daba890638d067c29b
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260917_045239.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3ce381b9a9c4367df807e4c97a546517e646a3770046d035469fd964cf8cc02a
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260917_061838.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b202f12046c22c586fb9147fcb3b268e676a57d635601445290463c3079c7987
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260917_150452.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:94f750c2f01878db314152abbd73efa710284fe2a2696876b2c478543609e3c9
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260917_232412.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f1445a59ebcf57c85fcf355d069fb20ba87dcc3f50cb8cba0b69efbc6d3c179a
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260918_111742.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a84de0eb7b83e12f27ad27d3f853cebc0a5da4c28b04d31857e1785dec1b01e
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260918_141053.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:135216ca94621a2c139d322d60c01b72aec6cc70bce0fe2b1c71db774586a8a2
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260918_181508.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f9d965937473c6a53193e92c65520d48d2bcc247c99f554c1375ef63c5298102
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260919_152545.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c3f083196600d57d2bb047106f193ee4cca4c738f540bf6258eaa1797f62f670
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/archive/model_20260920_082410.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9b74ebe9100364b358a0feeb49749becf925fcc194cf79a4785d5e616b5e2998
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/cnn/checkpoint.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:341a9946c45601c85f8959aee8c6656e33475c4c4c26f553a012d1bfe7635d4b
|
| 3 |
+
size 976650
|
datasets/spatiotemporal_gnn/cnn/logs.txt
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
epoch 1/100: train loss = 0.2286, val Q loss = 0.0920, val H loss = 0.0663, val combined = 0.1584 (best)
|
| 2 |
+
saved real best checkpoint (epoch 1, val_loss=0.1584) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 3 |
+
[debug] raw accounting: n_batches=562, total_loss=92.2145, len(train_loader)=562
|
| 4 |
+
epoch 2/100: train loss = 0.1641, val Q loss = 0.0478, val H loss = 0.0481, val combined = 0.0958 (best)
|
| 5 |
+
saved real best checkpoint (epoch 2, val_loss=0.0958) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 6 |
+
[debug] raw accounting: n_batches=562, total_loss=81.9266, len(train_loader)=562
|
| 7 |
+
epoch 3/100: train loss = 0.1458, val Q loss = 0.0433, val H loss = 0.0471, val combined = 0.0904 (best)
|
| 8 |
+
saved real best checkpoint (epoch 3, val_loss=0.0904) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 9 |
+
[debug] raw accounting: n_batches=562, total_loss=76.7376, len(train_loader)=562
|
| 10 |
+
epoch 4/100: train loss = 0.1365, val Q loss = 0.0373, val H loss = 0.0398, val combined = 0.0771 (best)
|
| 11 |
+
saved real best checkpoint (epoch 4, val_loss=0.0771) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 12 |
+
[debug] raw accounting: n_batches=562, total_loss=74.5746, len(train_loader)=562
|
| 13 |
+
epoch 5/100: train loss = 0.1327, val Q loss = 0.0527, val H loss = 0.0468, val combined = 0.0995
|
| 14 |
+
[debug] raw accounting: n_batches=562, total_loss=72.7644, len(train_loader)=562
|
| 15 |
+
epoch 6/100: train loss = 0.1295, val Q loss = 0.0404, val H loss = 0.0400, val combined = 0.0804
|
| 16 |
+
[debug] raw accounting: n_batches=562, total_loss=71.7557, len(train_loader)=562
|
| 17 |
+
epoch 7/100: train loss = 0.1277, val Q loss = 0.0422, val H loss = 0.0432, val combined = 0.0854
|
| 18 |
+
[debug] raw accounting: n_batches=562, total_loss=70.6966, len(train_loader)=562
|
| 19 |
+
epoch 8/100: train loss = 0.1258, val Q loss = 0.0424, val H loss = 0.0399, val combined = 0.0823
|
| 20 |
+
[debug] raw accounting: n_batches=562, total_loss=70.5859, len(train_loader)=562
|
| 21 |
+
epoch 9/100: train loss = 0.1256, val Q loss = 0.0362, val H loss = 0.0393, val combined = 0.0755 (best)
|
| 22 |
+
saved real best checkpoint (epoch 9, val_loss=0.0755) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 23 |
+
[debug] raw accounting: n_batches=562, total_loss=70.4025, len(train_loader)=562
|
| 24 |
+
epoch 10/100: train loss = 0.1253, val Q loss = 0.0472, val H loss = 0.0420, val combined = 0.0892
|
| 25 |
+
[debug] raw accounting: n_batches=562, total_loss=69.8427, len(train_loader)=562
|
| 26 |
+
epoch 11/100: train loss = 0.1243, val Q loss = 0.0462, val H loss = 0.0410, val combined = 0.0872
|
| 27 |
+
[debug] raw accounting: n_batches=562, total_loss=69.2181, len(train_loader)=562
|
| 28 |
+
epoch 12/100: train loss = 0.1232, val Q loss = 0.0441, val H loss = 0.0470, val combined = 0.0911
|
| 29 |
+
[debug] raw accounting: n_batches=562, total_loss=68.5817, len(train_loader)=562
|
| 30 |
+
epoch 13/100: train loss = 0.1220, val Q loss = 0.0476, val H loss = 0.0404, val combined = 0.0880
|
| 31 |
+
[debug] raw accounting: n_batches=562, total_loss=68.7422, len(train_loader)=562
|
| 32 |
+
epoch 14/100: train loss = 0.1223, val Q loss = 0.0411, val H loss = 0.0375, val combined = 0.0785
|
| 33 |
+
[debug] raw accounting: n_batches=562, total_loss=68.5472, len(train_loader)=562
|
| 34 |
+
epoch 15/100: train loss = 0.1220, val Q loss = 0.0415, val H loss = 0.0397, val combined = 0.0812
|
| 35 |
+
[debug] raw accounting: n_batches=562, total_loss=68.0892, len(train_loader)=562
|
| 36 |
+
epoch 16/100: train loss = 0.1212, val Q loss = 0.0400, val H loss = 0.0363, val combined = 0.0763
|
| 37 |
+
[debug] raw accounting: n_batches=562, total_loss=67.2499, len(train_loader)=562
|
| 38 |
+
epoch 17/100: train loss = 0.1197, val Q loss = 0.0419, val H loss = 0.0393, val combined = 0.0812
|
| 39 |
+
[debug] raw accounting: n_batches=562, total_loss=67.1504, len(train_loader)=562
|
| 40 |
+
epoch 18/100: train loss = 0.1195, val Q loss = 0.0373, val H loss = 0.0377, val combined = 0.0749 (best)
|
| 41 |
+
saved real best checkpoint (epoch 18, val_loss=0.0749) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 42 |
+
[debug] raw accounting: n_batches=562, total_loss=67.1872, len(train_loader)=562
|
| 43 |
+
epoch 19/100: train loss = 0.1196, val Q loss = 0.0407, val H loss = 0.0414, val combined = 0.0821
|
| 44 |
+
[debug] raw accounting: n_batches=562, total_loss=67.6086, len(train_loader)=562
|
| 45 |
+
epoch 20/100: train loss = 0.1203, val Q loss = 0.0381, val H loss = 0.0419, val combined = 0.0800
|
| 46 |
+
[debug] raw accounting: n_batches=562, total_loss=66.7986, len(train_loader)=562
|
| 47 |
+
epoch 21/100: train loss = 0.1189, val Q loss = 0.0531, val H loss = 0.0470, val combined = 0.1001
|
| 48 |
+
[debug] raw accounting: n_batches=562, total_loss=66.389, len(train_loader)=562
|
| 49 |
+
epoch 22/100: train loss = 0.1181, val Q loss = 0.0378, val H loss = 0.0347, val combined = 0.0725 (best)
|
| 50 |
+
saved real best checkpoint (epoch 22, val_loss=0.0725) to datasets/spatiotemporal_gnn/cnn/checkpoint.pt
|
| 51 |
+
[debug] raw accounting: n_batches=562, total_loss=66.3914, len(train_loader)=562
|
| 52 |
+
epoch 23/100: train loss = 0.1181, val Q loss = 0.0518, val H loss = 0.0401, val combined = 0.0919
|
| 53 |
+
[debug] raw accounting: n_batches=562, total_loss=65.8369, len(train_loader)=562
|
| 54 |
+
epoch 24/100: train loss = 0.1171, val Q loss = 0.0398, val H loss = 0.0377, val combined = 0.0775
|
| 55 |
+
[debug] raw accounting: n_batches=562, total_loss=65.9454, len(train_loader)=562
|
| 56 |
+
epoch 25/100: train loss = 0.1173, val Q loss = 0.0406, val H loss = 0.0383, val combined = 0.0789
|
| 57 |
+
[debug] raw accounting: n_batches=562, total_loss=65.8238, len(train_loader)=562
|
| 58 |
+
epoch 26/100: train loss = 0.1171, val Q loss = 0.0478, val H loss = 0.0458, val combined = 0.0936
|
| 59 |
+
[debug] raw accounting: n_batches=562, total_loss=65.5497, len(train_loader)=562
|
| 60 |
+
epoch 27/100: train loss = 0.1166, val Q loss = 0.0399, val H loss = 0.0385, val combined = 0.0785
|
| 61 |
+
[debug] raw accounting: n_batches=562, total_loss=65.4061, len(train_loader)=562
|
| 62 |
+
epoch 28/100: train loss = 0.1164, val Q loss = 0.0427, val H loss = 0.0373, val combined = 0.0800
|
| 63 |
+
[debug] raw accounting: n_batches=562, total_loss=65.1854, len(train_loader)=562
|
| 64 |
+
epoch 29/100: train loss = 0.1160, val Q loss = 0.0461, val H loss = 0.0400, val combined = 0.0861
|
| 65 |
+
[debug] raw accounting: n_batches=562, total_loss=64.7025, len(train_loader)=562
|
| 66 |
+
epoch 30/100: train loss = 0.1151, val Q loss = 0.0430, val H loss = 0.0394, val combined = 0.0825
|
| 67 |
+
[debug] raw accounting: n_batches=562, total_loss=64.3632, len(train_loader)=562
|
| 68 |
+
epoch 31/100: train loss = 0.1145, val Q loss = 0.0382, val H loss = 0.0356, val combined = 0.0738
|
| 69 |
+
[debug] raw accounting: n_batches=562, total_loss=64.5384, len(train_loader)=562
|
| 70 |
+
epoch 32/100: train loss = 0.1148, val Q loss = 0.0372, val H loss = 0.0355, val combined = 0.0728
|
| 71 |
+
stopped early at epoch 32
|
| 72 |
+
Saved final real model (bare state_dict, for evaluate_flood_detection.py etc.) to datasets/spatiotemporal_gnn/cnn/model.pt
|
| 73 |
+
(The real resumable checkpoint at datasets/spatiotemporal_gnn/cnn/checkpoint.pt -- model+optimizer+epoch+history -- already held these exact same best real weights since epoch 22; this final save is a convenience copy in the plain format the evaluation scripts expect.)
|
| 74 |
+
|
| 75 |
+
Best validation loss: 0.0725
|
| 76 |
+
Learned Manning's roughness n = 0.1574 (0.03 was the initialization, real natural streams typically fall in ~0.025-0.1; now sigmoid-bounded to [0.01, 0.3] by construction, so it can no longer drift outside that range the way it did before -- but landing at or very near either boundary (0.01 or 0.3) still means it saturated rather than converged to a real interior value, and is still worth investigating, not just noting)
|
| 77 |
+
Learned bank height = 0.10m (2.5m was the initialization, an assumption with no real measured data behind it -- compare this against real rating-curve data for the gauges with one, if the model's learned value looks implausible for these specific rivers)
|
| 78 |
+
Saved model to datasets/spatiotemporal_gnn/cnn/model.pt
|
| 79 |
+
|
| 80 |
+
Naive baseline, discharge (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1298
|
| 81 |
+
Naive baseline, water level (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1239
|
| 82 |
+
Discharge: model beats the naive baseline by 70.9% -- real, not just baseline-matching skill
|
| 83 |
+
Water level: model beats the naive baseline by 72.0% -- real, not just baseline-matching skill
|
| 84 |
+
|
| 85 |
+
python -m scripts.training.train_spatiotemporal_gnn \
|
| 86 |
+
--data-root datasets \
|
| 87 |
+
--train-end 2025-06-30 --val-end 2025-12-31 \
|
| 88 |
+
--log-space-discharge-loss --nse-weight 0 --water-balance-weight 0 \
|
| 89 |
+
--event-reweight --temporal-type cnn
|
datasets/spatiotemporal_gnn/cnn/model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2820c042b1a741443bc6595021c4d2f5b76ce3357a96dd5d60bca32651ac694
|
| 3 |
+
size 322010
|
datasets/spatiotemporal_gnn/cnn/selected_subgraph.png
ADDED
|
Git LFS Details
|
datasets/spatiotemporal_gnn/cnn/training_curves.png
ADDED
|
Git LFS Details
|
datasets/spatiotemporal_gnn/gru/logs.txt
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
epoch 1/100: train loss = 0.2174, val Q loss = 0.0584, val H loss = 0.0508, val combined = 0.1091 (best)
|
| 2 |
+
[debug] raw accounting: n_batches=562, total_loss=78.6866, len(train_loader)=562
|
| 3 |
+
epoch 2/100: train loss = 0.1400, val Q loss = 0.0398, val H loss = 0.0456, val combined = 0.0853 (best)
|
| 4 |
+
[debug] raw accounting: n_batches=562, total_loss=75.0667, len(train_loader)=562
|
| 5 |
+
epoch 3/100: train loss = 0.1336, val Q loss = 0.0539, val H loss = 0.0490, val combined = 0.1029
|
| 6 |
+
[debug] raw accounting: n_batches=562, total_loss=72.6642, len(train_loader)=562
|
| 7 |
+
epoch 4/100: train loss = 0.1293, val Q loss = 0.0416, val H loss = 0.0480, val combined = 0.0896
|
| 8 |
+
[debug] raw accounting: n_batches=562, total_loss=71.4249, len(train_loader)=562
|
| 9 |
+
epoch 5/100: train loss = 0.1271, val Q loss = 0.0384, val H loss = 0.0389, val combined = 0.0773 (best)
|
| 10 |
+
[debug] raw accounting: n_batches=562, total_loss=69.9505, len(train_loader)=562
|
| 11 |
+
epoch 6/100: train loss = 0.1245, val Q loss = 0.0347, val H loss = 0.0421, val combined = 0.0768 (best)
|
| 12 |
+
[debug] raw accounting: n_batches=562, total_loss=69.2856, len(train_loader)=562
|
| 13 |
+
epoch 7/100: train loss = 0.1233, val Q loss = 0.0376, val H loss = 0.0430, val combined = 0.0806
|
| 14 |
+
[debug] raw accounting: n_batches=562, total_loss=68.9054, len(train_loader)=562
|
| 15 |
+
epoch 8/100: train loss = 0.1226, val Q loss = 0.0378, val H loss = 0.0428, val combined = 0.0806
|
| 16 |
+
[debug] raw accounting: n_batches=562, total_loss=67.9286, len(train_loader)=562
|
| 17 |
+
epoch 9/100: train loss = 0.1209, val Q loss = 0.0405, val H loss = 0.0400, val combined = 0.0805
|
| 18 |
+
[debug] raw accounting: n_batches=562, total_loss=67.6105, len(train_loader)=562
|
| 19 |
+
epoch 10/100: train loss = 0.1203, val Q loss = 0.0343, val H loss = 0.0369, val combined = 0.0711 (best)
|
| 20 |
+
[debug] raw accounting: n_batches=562, total_loss=67.4021, len(train_loader)=562
|
| 21 |
+
epoch 11/100: train loss = 0.1199, val Q loss = 0.0355, val H loss = 0.0364, val combined = 0.0719
|
| 22 |
+
[debug] raw accounting: n_batches=562, total_loss=67.1944, len(train_loader)=562
|
| 23 |
+
epoch 12/100: train loss = 0.1196, val Q loss = 0.0451, val H loss = 0.0403, val combined = 0.0855
|
| 24 |
+
[debug] raw accounting: n_batches=562, total_loss=66.9262, len(train_loader)=562
|
| 25 |
+
epoch 13/100: train loss = 0.1191, val Q loss = 0.0358, val H loss = 0.0387, val combined = 0.0746
|
| 26 |
+
[debug] raw accounting: n_batches=562, total_loss=66.9341, len(train_loader)=562
|
| 27 |
+
epoch 14/100: train loss = 0.1191, val Q loss = 0.0395, val H loss = 0.0451, val combined = 0.0846
|
| 28 |
+
[debug] raw accounting: n_batches=562, total_loss=66.25, len(train_loader)=562
|
| 29 |
+
epoch 15/100: train loss = 0.1179, val Q loss = 0.0374, val H loss = 0.0422, val combined = 0.0796
|
| 30 |
+
[debug] raw accounting: n_batches=562, total_loss=65.8047, len(train_loader)=562
|
| 31 |
+
epoch 16/100: train loss = 0.1171, val Q loss = 0.0347, val H loss = 0.0370, val combined = 0.0717
|
| 32 |
+
[debug] raw accounting: n_batches=562, total_loss=65.8151, len(train_loader)=562
|
| 33 |
+
epoch 17/100: train loss = 0.1171, val Q loss = 0.0430, val H loss = 0.0439, val combined = 0.0870
|
| 34 |
+
[debug] raw accounting: n_batches=562, total_loss=65.825, len(train_loader)=562
|
| 35 |
+
epoch 18/100: train loss = 0.1171, val Q loss = 0.0352, val H loss = 0.0387, val combined = 0.0738
|
| 36 |
+
[debug] raw accounting: n_batches=562, total_loss=65.1963, len(train_loader)=562
|
| 37 |
+
epoch 19/100: train loss = 0.1160, val Q loss = 0.0393, val H loss = 0.0394, val combined = 0.0787
|
| 38 |
+
[debug] raw accounting: n_batches=562, total_loss=65.3031, len(train_loader)=562
|
| 39 |
+
epoch 20/100: train loss = 0.1162, val Q loss = 0.0381, val H loss = 0.0407, val combined = 0.0788
|
| 40 |
+
stopped early at epoch 20
|
| 41 |
+
Archived previous checkpoint to datasets/spatiotemporal_gnn/archive/model_20260920_082410.pt
|
| 42 |
+
|
| 43 |
+
Best validation loss: 0.0711
|
| 44 |
+
Learned Manning's roughness n = 0.1566 (0.03 was the initialization, real natural streams typically fall in ~0.025-0.1; now sigmoid-bounded to [0.01, 0.3] by construction, so it can no longer drift outside that range the way it did before -- but landing at or very near either boundary (0.01 or 0.3) still means it saturated rather than converged to a real interior value, and is still worth investigating, not just noting)
|
| 45 |
+
Learned bank height = 0.10m (2.5m was the initialization, an assumption with no real measured data behind it -- compare this against real rating-curve data for the gauges with one, if the model's learned value looks implausible for these specific rivers)
|
| 46 |
+
Saved model to datasets/spatiotemporal_gnn/model.pt
|
| 47 |
+
|
| 48 |
+
Naive baseline, discharge (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1298
|
| 49 |
+
Naive baseline, water level (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1239
|
| 50 |
+
Discharge: model beats the naive baseline by 73.6% -- real, not just baseline-matching skill
|
| 51 |
+
Water level: model beats the naive baseline by 70.2% -- real, not just baseline-matching skill
|
datasets/spatiotemporal_gnn/gru/model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3c5e4425095867eb29f471bf1f8e26837e106085e38ae0423b8b5991664c5f53
|
| 3 |
+
size 223022
|
datasets/spatiotemporal_gnn/gru/selected_subgraph.png
ADDED
|
Git LFS Details
|
datasets/spatiotemporal_gnn/gru/training_curves.png
ADDED
|
datasets/spatiotemporal_gnn/lstm/logs.txt
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
epoch 1/100: train loss = 0.2043, val Q loss = 0.0629, val H loss = 0.0504, val combined = 0.1133 (best)
|
| 2 |
+
[debug] raw accounting: n_batches=562, total_loss=81.4646, len(train_loader)=562
|
| 3 |
+
epoch 2/100: train loss = 0.1450, val Q loss = 0.0450, val H loss = 0.0408, val combined = 0.0858 (best)
|
| 4 |
+
[debug] raw accounting: n_batches=562, total_loss=76.7782, len(train_loader)=562
|
| 5 |
+
epoch 3/100: train loss = 0.1366, val Q loss = 0.0678, val H loss = 0.0544, val combined = 0.1222
|
| 6 |
+
[debug] raw accounting: n_batches=562, total_loss=73.7316, len(train_loader)=562
|
| 7 |
+
epoch 4/100: train loss = 0.1312, val Q loss = 0.0410, val H loss = 0.0371, val combined = 0.0781 (best)
|
| 8 |
+
[debug] raw accounting: n_batches=562, total_loss=71.938, len(train_loader)=562
|
| 9 |
+
epoch 5/100: train loss = 0.1280, val Q loss = 0.0672, val H loss = 0.0421, val combined = 0.1093
|
| 10 |
+
[debug] raw accounting: n_batches=562, total_loss=70.4825, len(train_loader)=562
|
| 11 |
+
epoch 6/100: train loss = 0.1254, val Q loss = 0.0424, val H loss = 0.0446, val combined = 0.0871
|
| 12 |
+
[debug] raw accounting: n_batches=562, total_loss=69.2004, len(train_loader)=562
|
| 13 |
+
epoch 7/100: train loss = 0.1231, val Q loss = 0.0370, val H loss = 0.0382, val combined = 0.0752 (best)
|
| 14 |
+
[debug] raw accounting: n_batches=562, total_loss=68.1995, len(train_loader)=562
|
| 15 |
+
epoch 8/100: train loss = 0.1214, val Q loss = 0.0450, val H loss = 0.0431, val combined = 0.0882
|
| 16 |
+
[debug] raw accounting: n_batches=562, total_loss=68.2369, len(train_loader)=562
|
| 17 |
+
epoch 9/100: train loss = 0.1214, val Q loss = 0.0394, val H loss = 0.0332, val combined = 0.0726 (best)
|
| 18 |
+
[debug] raw accounting: n_batches=562, total_loss=67.716, len(train_loader)=562
|
| 19 |
+
epoch 10/100: train loss = 0.1205, val Q loss = 0.0345, val H loss = 0.0351, val combined = 0.0696 (best)
|
| 20 |
+
[debug] raw accounting: n_batches=562, total_loss=67.628, len(train_loader)=562
|
| 21 |
+
epoch 11/100: train loss = 0.1203, val Q loss = 0.0489, val H loss = 0.0428, val combined = 0.0918
|
| 22 |
+
[debug] raw accounting: n_batches=562, total_loss=66.7925, len(train_loader)=562
|
| 23 |
+
epoch 12/100: train loss = 0.1188, val Q loss = 0.0365, val H loss = 0.0343, val combined = 0.0708
|
| 24 |
+
[debug] raw accounting: n_batches=562, total_loss=66.7536, len(train_loader)=562
|
| 25 |
+
epoch 13/100: train loss = 0.1188, val Q loss = 0.0439, val H loss = 0.0429, val combined = 0.0868
|
| 26 |
+
[debug] raw accounting: n_batches=562, total_loss=65.9348, len(train_loader)=562
|
| 27 |
+
epoch 14/100: train loss = 0.1173, val Q loss = 0.0369, val H loss = 0.0366, val combined = 0.0734
|
| 28 |
+
[debug] raw accounting: n_batches=562, total_loss=66.2568, len(train_loader)=562
|
| 29 |
+
epoch 15/100: train loss = 0.1179, val Q loss = 0.0414, val H loss = 0.0416, val combined = 0.0830
|
| 30 |
+
[debug] raw accounting: n_batches=562, total_loss=65.7003, len(train_loader)=562
|
| 31 |
+
epoch 16/100: train loss = 0.1169, val Q loss = 0.0366, val H loss = 0.0365, val combined = 0.0731
|
| 32 |
+
[debug] raw accounting: n_batches=562, total_loss=65.4524, len(train_loader)=562
|
| 33 |
+
epoch 17/100: train loss = 0.1165, val Q loss = 0.0353, val H loss = 0.0361, val combined = 0.0714
|
| 34 |
+
[debug] raw accounting: n_batches=562, total_loss=64.6876, len(train_loader)=562
|
| 35 |
+
epoch 18/100: train loss = 0.1151, val Q loss = 0.0357, val H loss = 0.0373, val combined = 0.0730
|
| 36 |
+
[debug] raw accounting: n_batches=562, total_loss=64.794, len(train_loader)=562
|
| 37 |
+
epoch 19/100: train loss = 0.1153, val Q loss = 0.0454, val H loss = 0.0451, val combined = 0.0904
|
| 38 |
+
[debug] raw accounting: n_batches=562, total_loss=64.9283, len(train_loader)=562
|
| 39 |
+
epoch 20/100: train loss = 0.1155, val Q loss = 0.0380, val H loss = 0.0408, val combined = 0.0788
|
| 40 |
+
stopped early at epoch 20
|
| 41 |
+
|
| 42 |
+
Best validation loss: 0.0696
|
| 43 |
+
Learned Manning's roughness n = 0.1566 (0.03 was the initialization, real natural streams typically fall in ~0.025-0.1; now sigmoid-bounded to [0.01, 0.3] by construction, so it can no longer drift outside that range the way it did before -- but landing at or very near either boundary (0.01 or 0.3) still means it saturated rather than converged to a real interior value, and is still worth investigating, not just noting)
|
| 44 |
+
Learned bank height = 0.10m (2.5m was the initialization, an assumption with no real measured data behind it -- compare this against real rating-curve data for the gauges with one, if the model's learned value looks implausible for these specific rivers)
|
| 45 |
+
Saved model to datasets/spatiotemporal_gnn/lstm/model.pt
|
| 46 |
+
|
| 47 |
+
Naive baseline, discharge (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1298
|
| 48 |
+
Naive baseline, water level (real training-period empirical quantiles [0.5, 0.9, 0.95, 0.99]): 0.1239
|
| 49 |
+
Discharge: model beats the naive baseline by 73.4% -- real, not just baseline-matching skill
|
| 50 |
+
Water level: model beats the naive baseline by 71.7% -- real, not just baseline-matching skill
|
datasets/spatiotemporal_gnn/lstm/model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f3fa27cdd263f5dcb1b7a140a24b01621344283703c0fe091717d043b96a4924
|
| 3 |
+
size 256366
|
datasets/spatiotemporal_gnn/lstm/selected_subgraph.png
ADDED
|
Git LFS Details
|
datasets/spatiotemporal_gnn/lstm/training_curves.png
ADDED
|
Git LFS Details
|
datasets/spatiotemporal_gnn/model.pt
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 223022
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3c5e4425095867eb29f471bf1f8e26837e106085e38ae0423b8b5991664c5f53
|
| 3 |
size 223022
|
requirements.txt
CHANGED
|
@@ -44,3 +44,5 @@ pytest-cov>=4.1.0
|
|
| 44 |
# GIS processing tools (optional but useful)
|
| 45 |
# GDAL binaries should be installed separately
|
| 46 |
# pyproj>=3.5.0
|
|
|
|
|
|
|
|
|
| 44 |
# GIS processing tools (optional but useful)
|
| 45 |
# GDAL binaries should be installed separately
|
| 46 |
# pyproj>=3.5.0
|
| 47 |
+
#0761197809 - Celine
|
| 48 |
+
#0722645224 - Shem
|
scripts/analysis/__pycache__/check_discharge_quantile_distribution.cpython-311.pyc
ADDED
|
Binary file (6.71 kB). View file
|
|
|
scripts/analysis/__pycache__/check_dry_valleys.cpython-311.pyc
ADDED
|
Binary file (7.34 kB). View file
|
|
|
scripts/analysis/__pycache__/check_routing_lag_distribution.cpython-311.pyc
ADDED
|
Binary file (8.42 kB). View file
|
|
|
scripts/analysis/check_discharge_quantile_distribution.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Computes the real empirical quantile distribution of discharge for
|
| 3 |
+
every real gauged station -- built to answer a real, specific question
|
| 4 |
+
before deciding how to re-weight or restructure the model's predicted
|
| 5 |
+
quantiles: where do real near-zero/low-flow, normal-flow, and peak
|
| 6 |
+
events actually fall for each real station, rather than assuming the
|
| 7 |
+
current quantile set [0.5, 0.9, 0.95, 0.99] is well-matched to the
|
| 8 |
+
real data's actual shape.
|
| 9 |
+
|
| 10 |
+
Reuses build_discharge_timeseries directly (the same real, established
|
| 11 |
+
loading path train_spatiotemporal_gnn.py itself uses) and the same
|
| 12 |
+
real per-basin filtering fix check_dry_valleys.py needed --
|
| 13 |
+
build_discharge_timeseries returns every real gauged station across
|
| 14 |
+
BOTH basins regardless of which nodes_df is passed in, confirmed
|
| 15 |
+
directly in that earlier diagnostic.
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python -m scripts.analysis.check_discharge_quantile_distribution --data-root datasets
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
QUANTILE_LEVELS = [0.01, 0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
parser = argparse.ArgumentParser(description="Check the real empirical discharge quantile distribution per station")
|
| 33 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 34 |
+
parser.add_argument("--start-date", type=str, default="2000-01-01",
|
| 35 |
+
help="As wide a real historical window as available.")
|
| 36 |
+
parser.add_argument("--end-date", type=str, default="2026-12-31")
|
| 37 |
+
args = parser.parse_args()
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from src.graph.dynamic_features import build_discharge_timeseries
|
| 41 |
+
except ImportError:
|
| 42 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 43 |
+
from src.graph.dynamic_features import build_discharge_timeseries
|
| 44 |
+
|
| 45 |
+
date_range = (args.start_date, args.end_date)
|
| 46 |
+
rows = []
|
| 47 |
+
|
| 48 |
+
for basin in ["eure", "risle"]:
|
| 49 |
+
nodes_path = args.data_root / "reach_graph" / f"{basin}_nodes_enriched.csv"
|
| 50 |
+
if not nodes_path.exists():
|
| 51 |
+
print(f"{basin}: no real nodes file at {nodes_path}, skipping.")
|
| 52 |
+
continue
|
| 53 |
+
nodes_df = pd.read_csv(nodes_path)
|
| 54 |
+
|
| 55 |
+
wide = build_discharge_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
|
| 56 |
+
# Real per-basin filtering, same fix check_dry_valleys.py needed:
|
| 57 |
+
# build_discharge_timeseries returns every real gauged station
|
| 58 |
+
# across BOTH basins, not filtered by the nodes_df passed in.
|
| 59 |
+
this_basin_codes = set(nodes_df["station_code"]) & set(wide.columns)
|
| 60 |
+
wide = wide[sorted(this_basin_codes)]
|
| 61 |
+
|
| 62 |
+
for station_code in wide.columns:
|
| 63 |
+
series = wide[station_code].dropna()
|
| 64 |
+
n_real = len(series)
|
| 65 |
+
if n_real == 0:
|
| 66 |
+
continue
|
| 67 |
+
real_quantiles = series.quantile(QUANTILE_LEVELS)
|
| 68 |
+
row = {"basin": basin, "station_code": station_code, "n_real": n_real}
|
| 69 |
+
for q_level, q_value in zip(QUANTILE_LEVELS, real_quantiles):
|
| 70 |
+
row[f"Q{int(q_level*100):02d}"] = float(q_value)
|
| 71 |
+
rows.append(row)
|
| 72 |
+
|
| 73 |
+
if not rows:
|
| 74 |
+
print("No real stations found in either basin -- nothing to report.")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
result_df = pd.DataFrame(rows)
|
| 78 |
+
print("=" * 100)
|
| 79 |
+
print("Real empirical discharge quantile distribution, per real gauged station (m3/s)")
|
| 80 |
+
print("=" * 100)
|
| 81 |
+
print(result_df.to_string(index=False))
|
| 82 |
+
|
| 83 |
+
print()
|
| 84 |
+
print("=" * 100)
|
| 85 |
+
print("Real skew/spread ratios -- how far the tails sit from the real median, per station")
|
| 86 |
+
print("=" * 100)
|
| 87 |
+
for _, row in result_df.iterrows():
|
| 88 |
+
q01, q50, q99 = row["Q01"], row["Q50"], row["Q99"]
|
| 89 |
+
low_ratio = q50 / q01 if q01 > 0 else float("inf")
|
| 90 |
+
high_ratio = q99 / q50 if q50 > 0 else float("inf")
|
| 91 |
+
print(f"{row['station_code']} ({row['basin']}): Q50/Q01={low_ratio:>8.1f}x, "
|
| 92 |
+
f"Q99/Q50={high_ratio:>8.1f}x (real Q01={q01:.2f}, Q50={q50:.2f}, Q99={q99:.2f})")
|
| 93 |
+
|
| 94 |
+
print()
|
| 95 |
+
print("=" * 100)
|
| 96 |
+
print("Where does this project's CURRENT model quantile set [0.5, 0.9, 0.95, 0.99] actually land?")
|
| 97 |
+
print("=" * 100)
|
| 98 |
+
print("No real LOW-tail quantile exists in the current model output at all -- Q50 is the lowest "
|
| 99 |
+
"quantile predicted, meaning there is no dedicated, explicit low-flow signal in the model's "
|
| 100 |
+
"own output structure, only whatever Q50 happens to learn implicitly.")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
scripts/analysis/check_dry_valleys.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Checks whether any real gauged station in this project's actual data
|
| 3 |
+
ever shows true dry-valley (zero or near-zero) discharge -- a direct,
|
| 4 |
+
factual data check, built to resolve whether zero-inflation-specific
|
| 5 |
+
loss functions (Tweedie, dual-head zero-inflated models, etc.) are
|
| 6 |
+
actually relevant here, rather than assuming either way from generic
|
| 7 |
+
hydrology advice that wasn't written for this specific river system.
|
| 8 |
+
|
| 9 |
+
Reuses build_discharge_timeseries directly (the same real, established
|
| 10 |
+
loading path train_spatiotemporal_gnn.py itself uses), not a
|
| 11 |
+
re-derived reading of the raw hydrometric files -- avoids the exact
|
| 12 |
+
mistake the first version of check_routing_lag_distribution.py made by
|
| 13 |
+
not reusing the real, existing function.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python -m scripts.analysis.check_dry_valleys --data-root datasets
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
parser = argparse.ArgumentParser(description="Check for real dry-valley (zero/near-zero discharge) conditions")
|
| 28 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 29 |
+
parser.add_argument("--start-date", type=str, default="2000-01-01",
|
| 30 |
+
help="As wide a real historical window as available -- a dry-valley check "
|
| 31 |
+
"benefits from more real history, not just the training window.")
|
| 32 |
+
parser.add_argument("--end-date", type=str, default="2026-12-31")
|
| 33 |
+
parser.add_argument("--near-zero-threshold-m3s", type=float, default=0.01,
|
| 34 |
+
help="A real, small discharge (L/s equivalent: 10 L/s) below which flow is "
|
| 35 |
+
"treated as 'near-zero' for reporting purposes -- exact zero is checked "
|
| 36 |
+
"separately and explicitly, this threshold is only for the 'near' case.")
|
| 37 |
+
args = parser.parse_args()
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from src.graph.dynamic_features import build_discharge_timeseries
|
| 41 |
+
except ImportError:
|
| 42 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 43 |
+
from src.graph.dynamic_features import build_discharge_timeseries
|
| 44 |
+
|
| 45 |
+
date_range = (args.start_date, args.end_date)
|
| 46 |
+
rows = []
|
| 47 |
+
|
| 48 |
+
for basin in ["eure", "risle"]:
|
| 49 |
+
nodes_path = args.data_root / "reach_graph" / f"{basin}_nodes_enriched.csv"
|
| 50 |
+
if not nodes_path.exists():
|
| 51 |
+
print(f"{basin}: no real nodes file at {nodes_path}, skipping.")
|
| 52 |
+
continue
|
| 53 |
+
nodes_df = pd.read_csv(nodes_path)
|
| 54 |
+
|
| 55 |
+
wide = build_discharge_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
|
| 56 |
+
# build_discharge_timeseries returns EVERY real gauged station
|
| 57 |
+
# across BOTH basins (confirmed directly -- it never filters by
|
| 58 |
+
# the nodes_df passed in; that filtering happens later, inside
|
| 59 |
+
# assemble_dynamic_tensor, which this script doesn't call) --
|
| 60 |
+
# restricting to this basin's own real station_codes here is
|
| 61 |
+
# required, or every station gets reported twice, once per
|
| 62 |
+
# basin, with identical values. A real, caught mistake in an
|
| 63 |
+
# earlier version of this exact script.
|
| 64 |
+
this_basin_codes = set(nodes_df["station_code"]) & set(wide.columns)
|
| 65 |
+
wide = wide[sorted(this_basin_codes)]
|
| 66 |
+
print(f"{basin}: real discharge data for {wide.shape[1]} real gauged station(s), "
|
| 67 |
+
f"{wide.shape[0]} real date(s) in range")
|
| 68 |
+
|
| 69 |
+
for station_code in wide.columns:
|
| 70 |
+
series = wide[station_code].dropna()
|
| 71 |
+
n_real = len(series)
|
| 72 |
+
if n_real == 0:
|
| 73 |
+
rows.append({"basin": basin, "station_code": station_code, "n_real": 0,
|
| 74 |
+
"min_m3s": np.nan, "pct_exact_zero": np.nan,
|
| 75 |
+
"pct_near_zero": np.nan, "p01_m3s": np.nan, "p05_m3s": np.nan})
|
| 76 |
+
continue
|
| 77 |
+
n_zero = int((series == 0).sum())
|
| 78 |
+
n_near_zero = int((series <= args.near_zero_threshold_m3s).sum())
|
| 79 |
+
rows.append({
|
| 80 |
+
"basin": basin, "station_code": station_code, "n_real": n_real,
|
| 81 |
+
"min_m3s": float(series.min()),
|
| 82 |
+
"pct_exact_zero": 100.0 * n_zero / n_real,
|
| 83 |
+
"pct_near_zero": 100.0 * n_near_zero / n_real,
|
| 84 |
+
"p01_m3s": float(series.quantile(0.01)),
|
| 85 |
+
"p05_m3s": float(series.quantile(0.05)),
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
if not rows:
|
| 89 |
+
print("No real stations found in either basin -- nothing to report.")
|
| 90 |
+
return
|
| 91 |
+
|
| 92 |
+
result_df = pd.DataFrame(rows).sort_values("min_m3s")
|
| 93 |
+
print()
|
| 94 |
+
print("=" * 70)
|
| 95 |
+
print(f"Real per-station discharge floor (near-zero threshold: {args.near_zero_threshold_m3s} m3/s)")
|
| 96 |
+
print("=" * 70)
|
| 97 |
+
print(result_df.to_string(index=False))
|
| 98 |
+
|
| 99 |
+
n_ever_exact_zero = int((result_df["pct_exact_zero"] > 0).sum())
|
| 100 |
+
n_ever_near_zero = int((result_df["pct_near_zero"] > 0).sum())
|
| 101 |
+
print()
|
| 102 |
+
if n_ever_exact_zero == 0:
|
| 103 |
+
print("No real station in this dataset ever recorded an exact-zero discharge day -- "
|
| 104 |
+
"zero-inflation-specific losses (Tweedie, dual-head zero-inflated models) do not "
|
| 105 |
+
"appear to match this project's actual real data, based on real evidence.")
|
| 106 |
+
else:
|
| 107 |
+
print(f"{n_ever_exact_zero} real station(s) recorded at least one exact-zero discharge day -- "
|
| 108 |
+
f"real, direct evidence a zero-inflation-aware approach could be worth investigating "
|
| 109 |
+
f"further for those specific stations.")
|
| 110 |
+
print(f"{n_ever_near_zero} real station(s) recorded at least one real day at or below "
|
| 111 |
+
f"{args.near_zero_threshold_m3s} m3/s.")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
scripts/analysis/check_routing_lag_distribution.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Computes the real travel-time lag for every real edge in the ACTUAL
|
| 3 |
+
collapsed training subgraph (the ~150-edge, 100-node graph
|
| 4 |
+
train_spatiotemporal_gnn.py really trains on), not the full,
|
| 5 |
+
fine-grained reach graph.
|
| 6 |
+
|
| 7 |
+
CORRECTED VERSION: an earlier version of this script read the raw
|
| 8 |
+
reach_graph/{basin}_edges.csv files directly -- real, but the WRONG
|
| 9 |
+
edges. Those are the full, uncollapsed tronçon-level graph (~3300-5200
|
| 10 |
+
edges per basin), where most real distances between adjacent nodes are
|
| 11 |
+
tiny (a few hundred meters), giving travel times of minutes to hours.
|
| 12 |
+
The graph actually used for training is the COLLAPSED subgraph
|
| 13 |
+
(build_collapsed_subgraph), where chains through dropped virtual nodes
|
| 14 |
+
are merged into single, real, much longer edges (summed distance_km/
|
| 15 |
+
elevation_drop_m) -- confirmed as the real, meaningful distinction: a
|
| 16 |
+
first run against the wrong edges showed 100% of edges at zero real
|
| 17 |
+
lag days, which would have been a real, actionable, and WRONG
|
| 18 |
+
conclusion about this project's actual routing physics.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python -m scripts.analysis.check_routing_lag_distribution --data-root datasets
|
| 22 |
+
"""
|
| 23 |
+
import argparse
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def estimate_travel_time_hours(distance_km, elevation_drop_m, min_velocity_ms=0.1, max_velocity_ms=3.0):
|
| 32 |
+
"""Exact real formula from src/graph/physics_losses.py's own
|
| 33 |
+
estimate_travel_time_hours -- kept in sync manually since this is a
|
| 34 |
+
read-only diagnostic script, not meant to import the training
|
| 35 |
+
pipeline's own torch-dependent modules."""
|
| 36 |
+
distance_m = distance_km * 1000.0
|
| 37 |
+
slope = np.clip(elevation_drop_m / np.maximum(distance_m, 1.0), 1e-6, None)
|
| 38 |
+
velocity = np.clip(np.sqrt(slope) * 10.0, min_velocity_ms, max_velocity_ms)
|
| 39 |
+
return distance_m / velocity / 3600.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def main() -> None:
|
| 43 |
+
parser = argparse.ArgumentParser(description="Check the real routing lag distribution on the actual collapsed training subgraph")
|
| 44 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 45 |
+
parser.add_argument("--max-nodes", type=int, default=100,
|
| 46 |
+
help="Must match train_spatiotemporal_gnn.py's own --max-nodes for this to "
|
| 47 |
+
"reflect the real, actual training graph.")
|
| 48 |
+
parser.add_argument("--timestep-hours", type=float, default=24.0)
|
| 49 |
+
args = parser.parse_args()
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 53 |
+
except ImportError:
|
| 54 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 55 |
+
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 56 |
+
|
| 57 |
+
max_nodes_per_basin = args.max_nodes // 2
|
| 58 |
+
all_lags_days = []
|
| 59 |
+
|
| 60 |
+
for basin in ["eure", "risle"]:
|
| 61 |
+
nodes_path = args.data_root / "reach_graph" / f"{basin}_nodes_enriched.csv"
|
| 62 |
+
edges_path = args.data_root / "reach_graph" / f"{basin}_edges.csv"
|
| 63 |
+
if not nodes_path.exists() or not edges_path.exists():
|
| 64 |
+
print(f"{basin}: missing {nodes_path} or {edges_path}, skipping.")
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
full_nodes_df = pd.read_csv(nodes_path)
|
| 68 |
+
full_edges_df = pd.read_csv(edges_path)
|
| 69 |
+
print(f"{basin}: {len(full_nodes_df)} real node(s), {len(full_edges_df)} real edge(s) "
|
| 70 |
+
f"(full reach graph)")
|
| 71 |
+
|
| 72 |
+
small_nodes_df, small_edges_df = build_collapsed_subgraph(
|
| 73 |
+
full_nodes_df, full_edges_df, max_nodes=max_nodes_per_basin,
|
| 74 |
+
)
|
| 75 |
+
n_edges = len(small_edges_df)
|
| 76 |
+
print(f"{basin}: collapsed to {len(small_nodes_df)} real node(s), {n_edges} real "
|
| 77 |
+
f"collapsed edge(s) -- THIS is the real graph training actually uses")
|
| 78 |
+
|
| 79 |
+
if n_edges == 0:
|
| 80 |
+
print(f"{basin}: zero real collapsed edges -- nothing to report.")
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
drop = small_edges_df["elevation_drop_m"].fillna(0.1).clip(lower=0.1)
|
| 84 |
+
hours = estimate_travel_time_hours(small_edges_df["distance_km"].values, drop.values)
|
| 85 |
+
days = hours / 24.0
|
| 86 |
+
lag_steps = np.round(hours / args.timestep_hours).astype(int)
|
| 87 |
+
|
| 88 |
+
print(f" Real collapsed-edge distance (km): min={small_edges_df['distance_km'].min():.2f}, "
|
| 89 |
+
f"median={small_edges_df['distance_km'].median():.2f}, max={small_edges_df['distance_km'].max():.2f}")
|
| 90 |
+
print(f" Real travel time (days): min={days.min():.3f}, median={np.median(days):.3f}, "
|
| 91 |
+
f"mean={days.mean():.3f}, max={days.max():.3f}, p90={np.percentile(days, 90):.3f}, "
|
| 92 |
+
f"p99={np.percentile(days, 99):.3f}")
|
| 93 |
+
print(f" Real lag (whole {args.timestep_hours:.0f}h timesteps): "
|
| 94 |
+
f"min={lag_steps.min()}, median={int(np.median(lag_steps))}, max={lag_steps.max()}")
|
| 95 |
+
all_lags_days.extend(days.tolist())
|
| 96 |
+
|
| 97 |
+
if not all_lags_days:
|
| 98 |
+
print("No real collapsed edges found in either basin -- nothing to report.")
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
all_lags_days = np.array(all_lags_days)
|
| 102 |
+
print()
|
| 103 |
+
print("=" * 70)
|
| 104 |
+
print("Real combined lag distribution, both basins (real collapsed training graph)")
|
| 105 |
+
print("=" * 70)
|
| 106 |
+
for count in [1, 2, 3, 5, 7, 10, 14, 21]:
|
| 107 |
+
coverage = float((all_lags_days <= count).mean() * 100)
|
| 108 |
+
print(f"ROUTING_HORIZON_COUNT={count:>2}: covers {coverage:.1f}% of real collapsed edges' own real travel time")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
main()
|
scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc
CHANGED
|
Binary files a/scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc and b/scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc differ
|
|
|
scripts/evaluation/evaluate_flood_detection.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/evaluation/logs.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python -m scripts.evaluation.evaluate_flood_detection \
|
| 2 |
+
--train-end 2025-06-30 \
|
| 3 |
+
--val-end 2025-12-31 \
|
| 4 |
+
--plot-stations H404021101,H420021012 \
|
| 5 |
+
--blend-quantiles \
|
| 6 |
+
--blend-quantiles-max-horizon 5 \
|
| 7 |
+
--compare-all-quantiles \
|
| 8 |
+
--trace-fp-inputs 5 \
|
| 9 |
+
--dump-fp-details
|
scripts/training/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a660ca66432e728a84b0c4fb9e408937a04476c458b247f26c715874c1946e1e
|
| 3 |
+
size 130424
|
scripts/training/train_spatiotemporal_gnn.py
CHANGED
|
@@ -26,37 +26,49 @@ from torch.utils.data import Dataset, DataLoader
|
|
| 26 |
|
| 27 |
try:
|
| 28 |
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN, masked_mse_graph
|
| 29 |
-
from src.graph.pinball_loss import
|
|
|
|
|
|
|
|
|
|
| 30 |
from src.graph.dynamic_features import (
|
| 31 |
build_discharge_timeseries, build_waterlevel_timeseries, build_groundwater_timeseries,
|
| 32 |
build_precipitation_timeseries, assemble_dynamic_tensor, compute_rolling_precip_sum,
|
| 33 |
fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 34 |
build_forecast_tensor, FORECAST_LEAD_TIMES, compute_days_since_last_real, compress_days_since_last_real,
|
|
|
|
| 35 |
)
|
| 36 |
from src.graph.physics_losses import (
|
| 37 |
build_confluence_index, build_braid_index, build_routing_index,
|
| 38 |
confluence_mass_balance_loss, split_rejoin_conservation_loss, routing_consistency_loss,
|
| 39 |
manning_consistency_loss, water_balance_loss, routing_soft_dtw_loss,
|
| 40 |
-
nse_loss, compute_per_node_historical_mean,
|
|
|
|
| 41 |
)
|
|
|
|
| 42 |
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 43 |
except ImportError:
|
| 44 |
import sys
|
| 45 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 46 |
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN, masked_mse_graph
|
| 47 |
-
from src.graph.pinball_loss import
|
|
|
|
|
|
|
|
|
|
| 48 |
from src.graph.dynamic_features import (
|
| 49 |
build_discharge_timeseries, build_waterlevel_timeseries, build_groundwater_timeseries,
|
| 50 |
build_precipitation_timeseries, assemble_dynamic_tensor, compute_rolling_precip_sum,
|
| 51 |
fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 52 |
build_forecast_tensor, FORECAST_LEAD_TIMES, compute_days_since_last_real, compress_days_since_last_real,
|
|
|
|
| 53 |
)
|
| 54 |
from src.graph.physics_losses import (
|
| 55 |
build_confluence_index, build_braid_index, build_routing_index,
|
| 56 |
confluence_mass_balance_loss, split_rejoin_conservation_loss, routing_consistency_loss,
|
| 57 |
manning_consistency_loss, water_balance_loss, routing_soft_dtw_loss,
|
| 58 |
-
nse_loss, compute_per_node_historical_mean,
|
|
|
|
| 59 |
)
|
|
|
|
| 60 |
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 61 |
|
| 62 |
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
|
|
@@ -80,14 +92,15 @@ QUANTILES = [0.5, 0.9, 0.95, 0.99]
|
|
| 80 |
# deliberate ceiling matching this project's planned future real
|
| 81 |
# Météo-France forecast horizon, not an arbitrary round number.
|
| 82 |
HORIZONS = [1, 2, 3, 4, 5, 7, 10, 14, 21]
|
| 83 |
-
ROUTING_HORIZON_COUNT =
|
| 84 |
-
#
|
| 85 |
-
#
|
| 86 |
-
#
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
#
|
| 90 |
-
#
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
def get_dynamic_channel_index(var_names: List[str], target_var: str, channels_per_var: int = 2) -> Tuple[int, int]:
|
|
@@ -788,6 +801,7 @@ class GraphWindowDataset(Dataset):
|
|
| 788 |
def __init__(
|
| 789 |
self, X: np.ndarray, Y: np.ndarray, Y_mask: np.ndarray, Y_H: np.ndarray, Y_H_mask: np.ndarray,
|
| 790 |
forecast_precip: np.ndarray, forecast_missing: np.ndarray,
|
|
|
|
| 791 |
):
|
| 792 |
self.X = torch.tensor(X, dtype=torch.float32)
|
| 793 |
self.Y = torch.tensor(Y, dtype=torch.float32)
|
|
@@ -796,13 +810,21 @@ class GraphWindowDataset(Dataset):
|
|
| 796 |
self.Y_H_mask = torch.tensor(Y_H_mask, dtype=torch.bool)
|
| 797 |
self.forecast_precip = torch.tensor(forecast_precip, dtype=torch.float32)
|
| 798 |
self.forecast_missing = torch.tensor(forecast_missing, dtype=torch.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
|
| 800 |
def __len__(self) -> int:
|
| 801 |
return self.X.shape[0]
|
| 802 |
|
| 803 |
def __getitem__(self, idx: int):
|
| 804 |
return (self.X[idx], self.Y[idx], self.Y_mask[idx], self.Y_H[idx], self.Y_H_mask[idx],
|
| 805 |
-
self.forecast_precip[idx], self.forecast_missing[idx])
|
| 806 |
|
| 807 |
|
| 808 |
def plot_selected_subgraph(basin_data: List[Tuple[pd.DataFrame, pd.DataFrame, int]], combined: Dict, output_path: Path) -> None:
|
|
@@ -942,7 +964,7 @@ def compute_naive_baseline_loss(
|
|
| 942 |
"""
|
| 943 |
naive_quantiles_t = torch.tensor(naive_quantiles, dtype=torch.float32)
|
| 944 |
total, n = 0.0, 0
|
| 945 |
-
for X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, _, _ in val_loader:
|
| 946 |
target_batch = Y_batch if target == "Q" else Y_H_batch
|
| 947 |
for i in range(X_batch.shape[0]):
|
| 948 |
Y_i = target_batch[i]
|
|
@@ -969,6 +991,16 @@ def train_spatiotemporal_model(
|
|
| 969 |
soft_dtw_gamma: float = 1.0,
|
| 970 |
discharge_per_node_mean: Optional["torch.Tensor"] = None,
|
| 971 |
waterlevel_per_node_mean: Optional["torch.Tensor"] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
) -> Dict:
|
| 973 |
"""
|
| 974 |
One real graph shared across every example in a batch -- looping
|
|
@@ -1013,15 +1045,54 @@ def train_spatiotemporal_model(
|
|
| 1013 |
# wait for completion or early stopping.
|
| 1014 |
history = {"train_loss": [], "val_Q_loss": [], "val_H_loss": [], "val_combined": [], "best_epoch": None}
|
| 1015 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1016 |
debug_examples_remaining = 25 # widened from 3 -- the first 3 examples all looked fine, need more coverage to catch whatever's different about the examples that actually break
|
| 1017 |
skipped_batches_to_report = 10 # report the actual batch_loss value for the first several skipped batches, not just that it happened
|
| 1018 |
|
| 1019 |
-
for epoch in range(max_epochs):
|
| 1020 |
model.train()
|
| 1021 |
total_loss, n_batches = 0.0, 0
|
| 1022 |
-
for X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, forecast_precip_batch, forecast_missing_batch
|
|
|
|
| 1023 |
optimizer.zero_grad()
|
| 1024 |
batch_loss = X_batch.sum() * 0.0 # zero, correct dtype/device
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
for i in range(X_batch.shape[0]):
|
| 1026 |
x_dynamic_seq = X_batch[i].to(device)
|
| 1027 |
Y_i = Y_batch[i].to(device)
|
|
@@ -1030,6 +1101,7 @@ def train_spatiotemporal_model(
|
|
| 1030 |
Y_H_mask_i = Y_H_mask_batch[i].to(device)
|
| 1031 |
forecast_precip_i = forecast_precip_batch[i].to(device)
|
| 1032 |
forecast_missing_i = forecast_missing_batch[i].to(device)
|
|
|
|
| 1033 |
|
| 1034 |
pred = model(x_shared_static, x_gauge_static, x_dynamic_seq, is_gauged, edge_index, edge_attr, basin_id, horizons_t,
|
| 1035 |
forecast_precip_i, forecast_missing_i)
|
|
@@ -1038,6 +1110,45 @@ def train_spatiotemporal_model(
|
|
| 1038 |
Q_pred_quantiles = pred[:, :, 0, :]
|
| 1039 |
H_pred_quantiles = pred[:, :, 1, :]
|
| 1040 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1041 |
# Pinball loss across every predicted quantile -- Y_i/Y_H_i
|
| 1042 |
# are 2D [n_nodes, n_horizons] real targets, broadcast
|
| 1043 |
# against each quantile prediction internally by
|
|
@@ -1047,7 +1158,48 @@ def train_spatiotemporal_model(
|
|
| 1047 |
# from the same real target tensor), so pinball_loss's own
|
| 1048 |
# NaN-masking already does the right thing without a
|
| 1049 |
# separate mask argument.
|
| 1050 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
supervised_H = multi_quantile_loss(Y_H_i, H_pred_quantiles, QUANTILES)
|
| 1052 |
|
| 1053 |
# NSE: a real, different signal from pinball loss above --
|
|
@@ -1168,6 +1320,65 @@ def train_spatiotemporal_model(
|
|
| 1168 |
batch_loss = batch_loss + example_loss
|
| 1169 |
|
| 1170 |
batch_loss = batch_loss / X_batch.shape[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
if not torch.isfinite(batch_loss):
|
| 1172 |
if skipped_batches_to_report > 0:
|
| 1173 |
skipped_batches_to_report -= 1
|
|
@@ -1217,7 +1428,8 @@ def train_spatiotemporal_model(
|
|
| 1217 |
val_Q_total, val_Q_batches = 0.0, 0
|
| 1218 |
val_H_total, val_H_batches = 0.0, 0
|
| 1219 |
with torch.no_grad():
|
| 1220 |
-
for X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, forecast_precip_batch, forecast_missing_batch
|
|
|
|
| 1221 |
for i in range(X_batch.shape[0]):
|
| 1222 |
x_dynamic_seq = X_batch[i].to(device)
|
| 1223 |
Y_i = Y_batch[i].to(device)
|
|
@@ -1226,12 +1438,30 @@ def train_spatiotemporal_model(
|
|
| 1226 |
Y_H_mask_i = Y_H_mask_batch[i].to(device)
|
| 1227 |
forecast_precip_i = forecast_precip_batch[i].to(device)
|
| 1228 |
forecast_missing_i = forecast_missing_batch[i].to(device)
|
|
|
|
| 1229 |
|
| 1230 |
pred = model(x_shared_static, x_gauge_static, x_dynamic_seq, is_gauged, edge_index, edge_attr, basin_id, horizons_t,
|
| 1231 |
forecast_precip_i, forecast_missing_i)
|
| 1232 |
Q_pred_quantiles = pred[:, :, 0, :]
|
| 1233 |
H_pred_quantiles = pred[:, :, 1, :]
|
| 1234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
supervised_H = multi_quantile_loss(Y_H_i, H_pred_quantiles, QUANTILES)
|
| 1236 |
# isfinite, not isnan -- confirmed as a real bug against
|
| 1237 |
# real training: an untrained model produced an
|
|
@@ -1275,6 +1505,35 @@ def train_spatiotemporal_model(
|
|
| 1275 |
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
| 1276 |
epochs_without_improvement = 0
|
| 1277 |
history["best_epoch"] = epoch + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
else:
|
| 1279 |
epochs_without_improvement += 1
|
| 1280 |
|
|
@@ -1291,7 +1550,8 @@ def train_spatiotemporal_model(
|
|
| 1291 |
if best_state is not None:
|
| 1292 |
model.load_state_dict(best_state)
|
| 1293 |
return {"best_val_loss": best_val_loss, "best_val_Q_loss": best_val_Q_loss,
|
| 1294 |
-
"best_val_H_loss": best_val_H_loss, "model_state": model.state_dict()
|
|
|
|
| 1295 |
|
| 1296 |
|
| 1297 |
def main() -> None:
|
|
@@ -1353,6 +1613,126 @@ def main() -> None:
|
|
| 1353 |
"Applied to the median prediction only, same convention as every other "
|
| 1354 |
"physics-adjacent loss in this file. Real per-node historical mean is "
|
| 1355 |
"fit on the training period only, same discipline as discharge_mean/std.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
parser.add_argument("--batch-size", type=int, default=8,
|
| 1357 |
help="Small default -- each example is a full graph forward pass looped "
|
| 1358 |
"internally (see train_spatiotemporal_model's docstring), so this is "
|
|
@@ -1361,8 +1741,35 @@ def main() -> None:
|
|
| 1361 |
parser.add_argument("--patience", type=int, default=10)
|
| 1362 |
parser.add_argument("--lr", type=float, default=1e-3)
|
| 1363 |
parser.add_argument("--weight-decay", type=float, default=1e-4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1364 |
args = parser.parse_args()
|
| 1365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
shared_static_cols = ["elevation_m", "catchment_area_km2", "idpr_value",
|
| 1367 |
"avg_groundwater_level_m", "distance_to_nearest_cavity_km", "n_cavities_within_20km"]
|
| 1368 |
# Dropped raw latitude/longitude -- real risk of the model memorizing
|
|
@@ -1394,7 +1801,16 @@ def main() -> None:
|
|
| 1394 |
combined = combine_basins(basin_data, shared_static_cols, gauge_static_cols)
|
| 1395 |
print(f"Combined graph: {combined['n_nodes']} nodes, {combined['edge_index'].shape[1]} edges")
|
| 1396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
output_dir = args.data_root / "spatiotemporal_gnn"
|
|
|
|
|
|
|
| 1398 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1399 |
try:
|
| 1400 |
plot_selected_subgraph(basin_data, combined, output_dir / "selected_subgraph.png")
|
|
@@ -1481,12 +1897,95 @@ def main() -> None:
|
|
| 1481 |
}
|
| 1482 |
print(f"Real days-since-last-observation channel added for: {list(dt_tensors.keys())}")
|
| 1483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1484 |
X, Y, Y_mask, anchor_dates = prepare_graph_training_windows(
|
| 1485 |
combined["x_shared_static"], dynamic_tensors, target_tensor, dates, args.lookback_days, HORIZONS,
|
| 1486 |
-
dt_tensors=dt_tensors,
|
|
|
|
| 1487 |
)
|
| 1488 |
print(f"Training windows: X={X.shape}, Y={Y.shape}")
|
| 1489 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1490 |
Y_H = extract_targets_at_anchor_dates(waterlevel_target_tensor, dates, anchor_dates, HORIZONS)
|
| 1491 |
Y_H_mask = ~np.isnan(Y_H)
|
| 1492 |
n_real_wl = int(Y_H_mask.sum())
|
|
@@ -1553,9 +2052,9 @@ def main() -> None:
|
|
| 1553 |
return
|
| 1554 |
|
| 1555 |
train_ds = GraphWindowDataset(X[train_mask], Y[train_mask], Y_mask[train_mask], Y_H[train_mask], Y_H_mask[train_mask],
|
| 1556 |
-
forecast_precip[train_mask], forecast_missing[train_mask])
|
| 1557 |
val_ds = GraphWindowDataset(X[val_mask], Y[val_mask], Y_mask[val_mask], Y_H[val_mask], Y_H_mask[val_mask],
|
| 1558 |
-
forecast_precip[val_mask], forecast_missing[val_mask])
|
| 1559 |
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
|
| 1560 |
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
| 1561 |
|
|
@@ -1584,17 +2083,25 @@ def main() -> None:
|
|
| 1584 |
shared_static_dim=x_shared_static_t.shape[1], gauge_static_dim=x_gauge_static_t.shape[1],
|
| 1585 |
gauge_dynamic_dim=n_gauge_dynamic_channels, edge_dim=edge_attr_t.shape[1],
|
| 1586 |
n_basins=len(BASIN_FILE_NAMES), n_targets=2, # [discharge, water_level]
|
| 1587 |
-
quantiles=QUANTILES,
|
| 1588 |
)
|
| 1589 |
|
| 1590 |
physics_weights = {"confluence": args.confluence_weight, "split_rejoin": args.split_rejoin_weight,
|
| 1591 |
"routing": args.routing_weight, "manning": args.manning_weight,
|
| 1592 |
"water_balance": args.water_balance_weight,
|
| 1593 |
"routing_soft_dtw": args.routing_soft_dtw_weight,
|
| 1594 |
-
"nse": args.nse_weight}
|
| 1595 |
print(f"Physics loss weights: {physics_weights}")
|
| 1596 |
print()
|
| 1597 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1598 |
# Real precipitation channel index -- None if the real precipitation
|
| 1599 |
# CSV wasn't fetched for this run, in which case water balance is
|
| 1600 |
# skipped entirely inside compute_physics_loss, not silently faked.
|
|
@@ -1614,6 +2121,10 @@ def main() -> None:
|
|
| 1614 |
|
| 1615 |
discharge_mean, discharge_std = standardization_stats["discharge"]
|
| 1616 |
precip_mean, precip_std = standardization_stats.get("precipitation", (0.0, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1617 |
|
| 1618 |
# Real per-node historical mean, for NSE -- fit on the training
|
| 1619 |
# period only (same discipline as discharge_mean/std above), from
|
|
@@ -1628,6 +2139,19 @@ def main() -> None:
|
|
| 1628 |
waterlevel_per_node_mean_t = torch.tensor(
|
| 1629 |
compute_per_node_historical_mean(waterlevel_target_tensor[:, train_date_mask]), dtype=torch.float32,
|
| 1630 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1631 |
|
| 1632 |
result = train_spatiotemporal_model(
|
| 1633 |
model, train_loader, val_loader, x_shared_static_t, x_gauge_static_t, is_gauged_t,
|
|
@@ -1640,6 +2164,16 @@ def main() -> None:
|
|
| 1640 |
soft_dtw_gamma=args.routing_soft_dtw_gamma,
|
| 1641 |
discharge_per_node_mean=discharge_per_node_mean_t,
|
| 1642 |
waterlevel_per_node_mean=waterlevel_per_node_mean_t,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1643 |
)
|
| 1644 |
|
| 1645 |
model_path = output_dir / "model.pt"
|
|
@@ -1658,6 +2192,10 @@ def main() -> None:
|
|
| 1658 |
shutil.move(str(model_path), str(archived_path))
|
| 1659 |
print(f"Archived previous checkpoint to {archived_path}")
|
| 1660 |
torch.save(result["model_state"], model_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1661 |
print(f"\nBest validation loss: {result['best_val_loss']:.4f}")
|
| 1662 |
learned_n = 0.01 + 0.29 * torch.sigmoid(model.manning_n_logit).item()
|
| 1663 |
print(f"Learned Manning's roughness n = {learned_n:.4f} "
|
|
|
|
| 26 |
|
| 27 |
try:
|
| 28 |
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN, masked_mse_graph
|
| 29 |
+
from src.graph.pinball_loss import (
|
| 30 |
+
multi_quantile_loss, log_space_multi_quantile_loss, per_station_relative_multi_quantile_loss,
|
| 31 |
+
weighted_multi_quantile_loss,
|
| 32 |
+
)
|
| 33 |
from src.graph.dynamic_features import (
|
| 34 |
build_discharge_timeseries, build_waterlevel_timeseries, build_groundwater_timeseries,
|
| 35 |
build_precipitation_timeseries, assemble_dynamic_tensor, compute_rolling_precip_sum,
|
| 36 |
fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 37 |
build_forecast_tensor, FORECAST_LEAD_TIMES, compute_days_since_last_real, compress_days_since_last_real,
|
| 38 |
+
compute_forward_filled_tensor,
|
| 39 |
)
|
| 40 |
from src.graph.physics_losses import (
|
| 41 |
build_confluence_index, build_braid_index, build_routing_index,
|
| 42 |
confluence_mass_balance_loss, split_rejoin_conservation_loss, routing_consistency_loss,
|
| 43 |
manning_consistency_loss, water_balance_loss, routing_soft_dtw_loss,
|
| 44 |
+
nse_loss, compute_per_node_historical_mean, kge_loss_from_pooled_pairs,
|
| 45 |
+
compute_per_node_historical_std, compute_per_node_historical_median,
|
| 46 |
)
|
| 47 |
+
from src.graph.flood_events import build_event_lookup, build_event_weight_tensor
|
| 48 |
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 49 |
except ImportError:
|
| 50 |
import sys
|
| 51 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 52 |
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN, masked_mse_graph
|
| 53 |
+
from src.graph.pinball_loss import (
|
| 54 |
+
multi_quantile_loss, log_space_multi_quantile_loss, per_station_relative_multi_quantile_loss,
|
| 55 |
+
weighted_multi_quantile_loss,
|
| 56 |
+
)
|
| 57 |
from src.graph.dynamic_features import (
|
| 58 |
build_discharge_timeseries, build_waterlevel_timeseries, build_groundwater_timeseries,
|
| 59 |
build_precipitation_timeseries, assemble_dynamic_tensor, compute_rolling_precip_sum,
|
| 60 |
fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 61 |
build_forecast_tensor, FORECAST_LEAD_TIMES, compute_days_since_last_real, compress_days_since_last_real,
|
| 62 |
+
compute_forward_filled_tensor,
|
| 63 |
)
|
| 64 |
from src.graph.physics_losses import (
|
| 65 |
build_confluence_index, build_braid_index, build_routing_index,
|
| 66 |
confluence_mass_balance_loss, split_rejoin_conservation_loss, routing_consistency_loss,
|
| 67 |
manning_consistency_loss, water_balance_loss, routing_soft_dtw_loss,
|
| 68 |
+
nse_loss, compute_per_node_historical_mean, kge_loss_from_pooled_pairs,
|
| 69 |
+
compute_per_node_historical_std, compute_per_node_historical_median,
|
| 70 |
)
|
| 71 |
+
from src.graph.flood_events import build_event_lookup, build_event_weight_tensor
|
| 72 |
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 73 |
|
| 74 |
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
|
|
|
|
| 92 |
# deliberate ceiling matching this project's planned future real
|
| 93 |
# Météo-France forecast horizon, not an arbitrary round number.
|
| 94 |
HORIZONS = [1, 2, 3, 4, 5, 7, 10, 14, 21]
|
| 95 |
+
ROUTING_HORIZON_COUNT = 3 # real, measured lag distribution on the ACTUAL collapsed
|
| 96 |
+
# training subgraph (check_routing_lag_distribution.py) showed 100% real edge coverage
|
| 97 |
+
# already at 3 days, with the longest real edge (Eure, 38.87km) rounding to a 2-day lag
|
| 98 |
+
# -- 7 was based on an incorrect assumption (checked against the raw, uncollapsed reach
|
| 99 |
+
# graph, where distances are misleadingly tiny) and provided zero real routing benefit,
|
| 100 |
+
# while needlessly extending water_balance_loss's reach to days where its own real
|
| 101 |
+
# physical justification (recent rainfall explaining discharge) is weaker -- a real,
|
| 102 |
+
# measured precision regression at every short horizon followed that change. 3 is the
|
| 103 |
+
# real, evidence-based minimum with a genuine one-day margin, not a boundary-hugging value.
|
| 104 |
|
| 105 |
|
| 106 |
def get_dynamic_channel_index(var_names: List[str], target_var: str, channels_per_var: int = 2) -> Tuple[int, int]:
|
|
|
|
| 801 |
def __init__(
|
| 802 |
self, X: np.ndarray, Y: np.ndarray, Y_mask: np.ndarray, Y_H: np.ndarray, Y_H_mask: np.ndarray,
|
| 803 |
forecast_precip: np.ndarray, forecast_missing: np.ndarray,
|
| 804 |
+
event_weight: Optional[np.ndarray] = None,
|
| 805 |
):
|
| 806 |
self.X = torch.tensor(X, dtype=torch.float32)
|
| 807 |
self.Y = torch.tensor(Y, dtype=torch.float32)
|
|
|
|
| 810 |
self.Y_H_mask = torch.tensor(Y_H_mask, dtype=torch.bool)
|
| 811 |
self.forecast_precip = torch.tensor(forecast_precip, dtype=torch.float32)
|
| 812 |
self.forecast_missing = torch.tensor(forecast_missing, dtype=torch.float32)
|
| 813 |
+
# --event-reweight's per-(example, node, horizon) discharge loss
|
| 814 |
+
# weight -- defaults to an all-1.0 array (an exact no-op, see
|
| 815 |
+
# main()'s own comment on why this is safe) when the caller
|
| 816 |
+
# doesn't have real event data to build a real one from.
|
| 817 |
+
self.event_weight = torch.tensor(
|
| 818 |
+
event_weight if event_weight is not None else np.ones_like(Y, dtype=np.float32),
|
| 819 |
+
dtype=torch.float32,
|
| 820 |
+
)
|
| 821 |
|
| 822 |
def __len__(self) -> int:
|
| 823 |
return self.X.shape[0]
|
| 824 |
|
| 825 |
def __getitem__(self, idx: int):
|
| 826 |
return (self.X[idx], self.Y[idx], self.Y_mask[idx], self.Y_H[idx], self.Y_H_mask[idx],
|
| 827 |
+
self.forecast_precip[idx], self.forecast_missing[idx], self.event_weight[idx])
|
| 828 |
|
| 829 |
|
| 830 |
def plot_selected_subgraph(basin_data: List[Tuple[pd.DataFrame, pd.DataFrame, int]], combined: Dict, output_path: Path) -> None:
|
|
|
|
| 964 |
"""
|
| 965 |
naive_quantiles_t = torch.tensor(naive_quantiles, dtype=torch.float32)
|
| 966 |
total, n = 0.0, 0
|
| 967 |
+
for X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, _, _, _ in val_loader:
|
| 968 |
target_batch = Y_batch if target == "Q" else Y_H_batch
|
| 969 |
for i in range(X_batch.shape[0]):
|
| 970 |
Y_i = target_batch[i]
|
|
|
|
| 991 |
soft_dtw_gamma: float = 1.0,
|
| 992 |
discharge_per_node_mean: Optional["torch.Tensor"] = None,
|
| 993 |
waterlevel_per_node_mean: Optional["torch.Tensor"] = None,
|
| 994 |
+
use_log_space_discharge_loss: bool = False,
|
| 995 |
+
log_discharge_offset: Optional[float] = None,
|
| 996 |
+
use_per_station_relative_loss: bool = False,
|
| 997 |
+
discharge_per_node_std: Optional["torch.Tensor"] = None,
|
| 998 |
+
use_kge_loss: bool = False,
|
| 999 |
+
kge_min_points: int = 5,
|
| 1000 |
+
checkpoint_path: Optional[Path] = None,
|
| 1001 |
+
resume_from: Optional[Path] = None,
|
| 1002 |
+
waterlevel_mean: float = 0.0,
|
| 1003 |
+
waterlevel_std: float = 1.0,
|
| 1004 |
) -> Dict:
|
| 1005 |
"""
|
| 1006 |
One real graph shared across every example in a batch -- looping
|
|
|
|
| 1045 |
# wait for completion or early stopping.
|
| 1046 |
history = {"train_loss": [], "val_Q_loss": [], "val_H_loss": [], "val_combined": [], "best_epoch": None}
|
| 1047 |
|
| 1048 |
+
# --resume: restores real model/optimizer state, the epoch counter,
|
| 1049 |
+
# the real best-loss-so-far, and real loss history from a real
|
| 1050 |
+
# checkpoint saved by an earlier (possibly interrupted) run of THIS
|
| 1051 |
+
# function, so training continues from the next real epoch instead
|
| 1052 |
+
# of starting over from freshly initialized weights. Silently starts
|
| 1053 |
+
# fresh if resume_from is None or the real file doesn't exist yet --
|
| 1054 |
+
# this is expected on a normal first run, not an error.
|
| 1055 |
+
start_epoch = 0
|
| 1056 |
+
if resume_from is not None and Path(resume_from).exists():
|
| 1057 |
+
checkpoint = torch.load(resume_from, map_location=device)
|
| 1058 |
+
model.load_state_dict(checkpoint["model_state"])
|
| 1059 |
+
optimizer.load_state_dict(checkpoint["optimizer_state"])
|
| 1060 |
+
start_epoch = checkpoint["epoch"] + 1
|
| 1061 |
+
best_val_loss = checkpoint["best_val_loss"]
|
| 1062 |
+
best_val_Q_loss = checkpoint["best_val_Q_loss"]
|
| 1063 |
+
best_val_H_loss = checkpoint["best_val_H_loss"]
|
| 1064 |
+
epochs_without_improvement = checkpoint.get("epochs_without_improvement", 0)
|
| 1065 |
+
history = checkpoint.get("history", history)
|
| 1066 |
+
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
| 1067 |
+
print(f"Resumed from real checkpoint {resume_from}: continuing at epoch {start_epoch+1}, "
|
| 1068 |
+
f"real best_val_loss so far = {best_val_loss:.4f}, "
|
| 1069 |
+
f"epochs_without_improvement = {epochs_without_improvement}")
|
| 1070 |
+
|
| 1071 |
debug_examples_remaining = 25 # widened from 3 -- the first 3 examples all looked fine, need more coverage to catch whatever's different about the examples that actually break
|
| 1072 |
skipped_batches_to_report = 10 # report the actual batch_loss value for the first several skipped batches, not just that it happened
|
| 1073 |
|
| 1074 |
+
for epoch in range(start_epoch, max_epochs):
|
| 1075 |
model.train()
|
| 1076 |
total_loss, n_batches = 0.0, 0
|
| 1077 |
+
for (X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, forecast_precip_batch, forecast_missing_batch,
|
| 1078 |
+
event_weight_batch) in train_loader:
|
| 1079 |
optimizer.zero_grad()
|
| 1080 |
batch_loss = X_batch.sum() * 0.0 # zero, correct dtype/device
|
| 1081 |
+
# Real, per-node pooling for KGE's correlation term -- unlike
|
| 1082 |
+
# every other loss in this loop, real KGE needs multiple
|
| 1083 |
+
# real, PAIRED (observed, predicted) points for the SAME
|
| 1084 |
+
# node to mean anything (a correlation from one example's
|
| 1085 |
+
# sparse ground truth alone is not real evidence of timing
|
| 1086 |
+
# skill). Pooled across every real example in this BATCH,
|
| 1087 |
+
# per node, then KGE is computed once per node AFTER the
|
| 1088 |
+
# example loop completes -- a genuinely different
|
| 1089 |
+
# accumulation pattern from every other per-example loss
|
| 1090 |
+
# term here, not a bug.
|
| 1091 |
+
n_nodes_batch = x_shared_static.shape[0]
|
| 1092 |
+
pooled_obs_Q_by_node = [[] for _ in range(n_nodes_batch)]
|
| 1093 |
+
pooled_pred_Q_by_node = [[] for _ in range(n_nodes_batch)]
|
| 1094 |
+
pooled_obs_H_by_node = [[] for _ in range(n_nodes_batch)]
|
| 1095 |
+
pooled_pred_H_by_node = [[] for _ in range(n_nodes_batch)]
|
| 1096 |
for i in range(X_batch.shape[0]):
|
| 1097 |
x_dynamic_seq = X_batch[i].to(device)
|
| 1098 |
Y_i = Y_batch[i].to(device)
|
|
|
|
| 1101 |
Y_H_mask_i = Y_H_mask_batch[i].to(device)
|
| 1102 |
forecast_precip_i = forecast_precip_batch[i].to(device)
|
| 1103 |
forecast_missing_i = forecast_missing_batch[i].to(device)
|
| 1104 |
+
event_weight_i = event_weight_batch[i].to(device)
|
| 1105 |
|
| 1106 |
pred = model(x_shared_static, x_gauge_static, x_dynamic_seq, is_gauged, edge_index, edge_attr, basin_id, horizons_t,
|
| 1107 |
forecast_precip_i, forecast_missing_i)
|
|
|
|
| 1110 |
Q_pred_quantiles = pred[:, :, 0, :]
|
| 1111 |
H_pred_quantiles = pred[:, :, 1, :]
|
| 1112 |
|
| 1113 |
+
if use_kge_loss:
|
| 1114 |
+
# Gather this example's real (non-NaN) (observed,
|
| 1115 |
+
# predicted-median) pairs into the batch-level pool,
|
| 1116 |
+
# per node -- real ground truth is sparse per
|
| 1117 |
+
# example, so a real, meaningful correlation for a
|
| 1118 |
+
# given node only emerges once several real anchor
|
| 1119 |
+
# dates' worth of pairs accumulate across the batch.
|
| 1120 |
+
#
|
| 1121 |
+
# CRITICAL, CONFIRMED FIX: un-standardize to REAL,
|
| 1122 |
+
# physical units (real L/s, real cm) HERE, before
|
| 1123 |
+
# pooling -- confirmed as a real, structural bug,
|
| 1124 |
+
# not a rare edge case: standardized (z-scored) data
|
| 1125 |
+
# is deliberately centered at mean~0, which makes
|
| 1126 |
+
# KGE's bias-ratio term (pred_mean/obs_mean) blow up
|
| 1127 |
+
# whenever a small, sparse pooled sample's obs_mean
|
| 1128 |
+
# lands near zero, which is EXPECTED to happen
|
| 1129 |
+
# regularly for standardized data, not rarely.
|
| 1130 |
+
# Measured directly: worst-case loss 8,260 on
|
| 1131 |
+
# standardized-space-like pooled samples, only 4.07
|
| 1132 |
+
# on real, physical-unit values (see
|
| 1133 |
+
# kge_loss_from_pooled_pairs' own docstring).
|
| 1134 |
+
Q_median_i = Q_pred_quantiles[:, :, 0]
|
| 1135 |
+
H_median_i = H_pred_quantiles[:, :, 0]
|
| 1136 |
+
Y_i_real = Y_i * discharge_std + discharge_mean
|
| 1137 |
+
Q_median_i_real = Q_median_i * discharge_std + discharge_mean
|
| 1138 |
+
Y_H_i_real = Y_H_i * waterlevel_std + waterlevel_mean
|
| 1139 |
+
H_median_i_real = H_median_i * waterlevel_std + waterlevel_mean
|
| 1140 |
+
real_mask_Q = ~torch.isnan(Y_i)
|
| 1141 |
+
real_mask_H = ~torch.isnan(Y_H_i)
|
| 1142 |
+
for node_idx in range(n_nodes_batch):
|
| 1143 |
+
node_mask_q = real_mask_Q[node_idx]
|
| 1144 |
+
if node_mask_q.any():
|
| 1145 |
+
pooled_obs_Q_by_node[node_idx].append(Y_i_real[node_idx][node_mask_q])
|
| 1146 |
+
pooled_pred_Q_by_node[node_idx].append(Q_median_i_real[node_idx][node_mask_q])
|
| 1147 |
+
node_mask_h = real_mask_H[node_idx]
|
| 1148 |
+
if node_mask_h.any():
|
| 1149 |
+
pooled_obs_H_by_node[node_idx].append(Y_H_i_real[node_idx][node_mask_h])
|
| 1150 |
+
pooled_pred_H_by_node[node_idx].append(H_median_i_real[node_idx][node_mask_h])
|
| 1151 |
+
|
| 1152 |
# Pinball loss across every predicted quantile -- Y_i/Y_H_i
|
| 1153 |
# are 2D [n_nodes, n_horizons] real targets, broadcast
|
| 1154 |
# against each quantile prediction internally by
|
|
|
|
| 1158 |
# from the same real target tensor), so pinball_loss's own
|
| 1159 |
# NaN-masking already does the right thing without a
|
| 1160 |
# separate mask argument.
|
| 1161 |
+
# event_weight_i (see --event-reweight) is ALWAYS passed
|
| 1162 |
+
# into the discharge supervised term below -- an all-1.0
|
| 1163 |
+
# tensor when the flag is off, which reproduces each
|
| 1164 |
+
# branch's original unweighted behavior exactly (see
|
| 1165 |
+
# main()'s own comment on why this is a safe no-op).
|
| 1166 |
+
# Deliberately NOT mutually exclusive with log-space or
|
| 1167 |
+
# per-station-relative -- event-reweighting addresses a
|
| 1168 |
+
# different axis (how many gradient updates a flood-
|
| 1169 |
+
# adjacent example gets), not value scale, so it composes
|
| 1170 |
+
# with either of the two scale-focused losses below.
|
| 1171 |
+
if use_log_space_discharge_loss:
|
| 1172 |
+
# Scoped to discharge only -- real, confirmed
|
| 1173 |
+
# peak-specific bias (25.0% PBIAS at high flow vs.
|
| 1174 |
+
# -1.8% at low flow, real station evaluation) is a
|
| 1175 |
+
# discharge-specific finding; water level doesn't
|
| 1176 |
+
# share discharge's orders-of-magnitude real skew,
|
| 1177 |
+
# so it stays on the original, linear-space loss.
|
| 1178 |
+
supervised_Q = log_space_multi_quantile_loss(
|
| 1179 |
+
Y_i, Q_pred_quantiles, QUANTILES, discharge_mean, discharge_std,
|
| 1180 |
+
log_offset=log_discharge_offset, sample_weight=event_weight_i,
|
| 1181 |
+
)
|
| 1182 |
+
elif use_per_station_relative_loss:
|
| 1183 |
+
# A genuinely different dimension of the same real
|
| 1184 |
+
# scale problem log-space addresses: log-space
|
| 1185 |
+
# compresses the gap between a real low-flow error
|
| 1186 |
+
# and a real peak error WITHIN one station; this
|
| 1187 |
+
# compresses the gap between a real error at a
|
| 1188 |
+
# small station and a real error at a large
|
| 1189 |
+
# station, ACROSS stations -- real, direct evidence
|
| 1190 |
+
# (check_discharge_quantile_distribution.py) showed
|
| 1191 |
+
# this project's 6 real gauges span real Q50 values
|
| 1192 |
+
# from ~868 to ~9323 L/s. Mutually exclusive with
|
| 1193 |
+
# log-space by design here, not stacked -- the same
|
| 1194 |
+
# real lesson from log-space+KGE both independently
|
| 1195 |
+
# causing the same regression applies: isolate one
|
| 1196 |
+
# new mechanism at a time before combining any.
|
| 1197 |
+
supervised_Q = per_station_relative_multi_quantile_loss(
|
| 1198 |
+
Y_i, Q_pred_quantiles, QUANTILES, discharge_mean, discharge_std,
|
| 1199 |
+
discharge_per_node_std, sample_weight=event_weight_i,
|
| 1200 |
+
)
|
| 1201 |
+
else:
|
| 1202 |
+
supervised_Q = weighted_multi_quantile_loss(Y_i, Q_pred_quantiles, QUANTILES, event_weight_i)
|
| 1203 |
supervised_H = multi_quantile_loss(Y_H_i, H_pred_quantiles, QUANTILES)
|
| 1204 |
|
| 1205 |
# NSE: a real, different signal from pinball loss above --
|
|
|
|
| 1320 |
batch_loss = batch_loss + example_loss
|
| 1321 |
|
| 1322 |
batch_loss = batch_loss / X_batch.shape[0]
|
| 1323 |
+
|
| 1324 |
+
if use_kge_loss:
|
| 1325 |
+
# A real, BATCH-level term -- added AFTER the per-example
|
| 1326 |
+
# division above, not before: kge_loss_total is computed
|
| 1327 |
+
# once per node from pooled real pairs across the whole
|
| 1328 |
+
# batch, not once per example, so it must not get divided
|
| 1329 |
+
# by the per-example count the way example_loss terms are.
|
| 1330 |
+
kge_loss_total = X_batch.sum() * 0.0
|
| 1331 |
+
n_nodes_with_kge = 0
|
| 1332 |
+
for node_idx in range(n_nodes_batch):
|
| 1333 |
+
if pooled_obs_Q_by_node[node_idx]:
|
| 1334 |
+
obs_cat = torch.cat(pooled_obs_Q_by_node[node_idx])
|
| 1335 |
+
pred_cat = torch.cat(pooled_pred_Q_by_node[node_idx])
|
| 1336 |
+
if len(obs_cat) >= kge_min_points:
|
| 1337 |
+
kge_loss_total = kge_loss_total + kge_loss_from_pooled_pairs(obs_cat, pred_cat)
|
| 1338 |
+
n_nodes_with_kge += 1
|
| 1339 |
+
if pooled_obs_H_by_node[node_idx]:
|
| 1340 |
+
obs_cat_h = torch.cat(pooled_obs_H_by_node[node_idx])
|
| 1341 |
+
pred_cat_h = torch.cat(pooled_pred_H_by_node[node_idx])
|
| 1342 |
+
if len(obs_cat_h) >= kge_min_points:
|
| 1343 |
+
kge_loss_total = kge_loss_total + kge_loss_from_pooled_pairs(obs_cat_h, pred_cat_h)
|
| 1344 |
+
n_nodes_with_kge += 1
|
| 1345 |
+
if n_nodes_with_kge > 0:
|
| 1346 |
+
kge_contribution = physics_weights.get("kge", 1.0) * (kge_loss_total / n_nodes_with_kge)
|
| 1347 |
+
batch_loss = batch_loss + kge_contribution
|
| 1348 |
+
if debug_examples_remaining > 0:
|
| 1349 |
+
# Real, BATCH-level debug visibility -- this term
|
| 1350 |
+
# doesn't fit the existing per-example debug
|
| 1351 |
+
# block at all (it isn't computed until after
|
| 1352 |
+
# that whole loop finishes), so it needs its own
|
| 1353 |
+
# print, not a line inside the per-example block.
|
| 1354 |
+
# A real, previously-caught gap: NSE got this
|
| 1355 |
+
# same fix after being wired in without it; KGE
|
| 1356 |
+
# needs the same discipline from the start.
|
| 1357 |
+
#
|
| 1358 |
+
# requires_grad, not a per-tensor autograd.grad
|
| 1359 |
+
# check, is the real, correct thing to verify
|
| 1360 |
+
# here: kge_contribution depends on POOLED
|
| 1361 |
+
# tensors from potentially several different
|
| 1362 |
+
# examples' Q_pred_quantiles/H_pred_quantiles,
|
| 1363 |
+
# not just the last example's from the loop --
|
| 1364 |
+
# checking gradient against that one specific,
|
| 1365 |
+
# likely-uninvolved tensor would silently report
|
| 1366 |
+
# "finite" via allow_unused=True even if the
|
| 1367 |
+
# real gradient path were broken. The finite-
|
| 1368 |
+
# gradient check across every model parameter
|
| 1369 |
+
# together already happens later, at
|
| 1370 |
+
# clip_grad_norm_, after backward() -- this
|
| 1371 |
+
# print confirms the term is even part of the
|
| 1372 |
+
# real, differentiable graph at all.
|
| 1373 |
+
print(f" [debug] kge_loss_total: {kge_loss_total.item():.6g}, "
|
| 1374 |
+
f"n_nodes_with_kge={n_nodes_with_kge}/{n_nodes_batch}, "
|
| 1375 |
+
f"weighted_contribution={kge_contribution.item():.6g}, "
|
| 1376 |
+
f"finite={torch.isfinite(kge_contribution).item()}, "
|
| 1377 |
+
f"requires_grad={kge_contribution.requires_grad}")
|
| 1378 |
+
elif debug_examples_remaining > 0:
|
| 1379 |
+
print(f" [debug] kge_loss_total: NO node cleared kge_min_points={kge_min_points} "
|
| 1380 |
+
f"this batch -- KGE contributed nothing this step.")
|
| 1381 |
+
|
| 1382 |
if not torch.isfinite(batch_loss):
|
| 1383 |
if skipped_batches_to_report > 0:
|
| 1384 |
skipped_batches_to_report -= 1
|
|
|
|
| 1428 |
val_Q_total, val_Q_batches = 0.0, 0
|
| 1429 |
val_H_total, val_H_batches = 0.0, 0
|
| 1430 |
with torch.no_grad():
|
| 1431 |
+
for (X_batch, Y_batch, mask_batch, Y_H_batch, Y_H_mask_batch, forecast_precip_batch, forecast_missing_batch,
|
| 1432 |
+
event_weight_batch) in val_loader:
|
| 1433 |
for i in range(X_batch.shape[0]):
|
| 1434 |
x_dynamic_seq = X_batch[i].to(device)
|
| 1435 |
Y_i = Y_batch[i].to(device)
|
|
|
|
| 1438 |
Y_H_mask_i = Y_H_mask_batch[i].to(device)
|
| 1439 |
forecast_precip_i = forecast_precip_batch[i].to(device)
|
| 1440 |
forecast_missing_i = forecast_missing_batch[i].to(device)
|
| 1441 |
+
event_weight_i = event_weight_batch[i].to(device)
|
| 1442 |
|
| 1443 |
pred = model(x_shared_static, x_gauge_static, x_dynamic_seq, is_gauged, edge_index, edge_attr, basin_id, horizons_t,
|
| 1444 |
forecast_precip_i, forecast_missing_i)
|
| 1445 |
Q_pred_quantiles = pred[:, :, 0, :]
|
| 1446 |
H_pred_quantiles = pred[:, :, 1, :]
|
| 1447 |
+
# Same event-reweighted loss branch as the train loop
|
| 1448 |
+
# (kept identical on purpose) -- val loss should measure
|
| 1449 |
+
# the model against the SAME objective it was optimized
|
| 1450 |
+
# for, so early stopping/model selection stays consistent
|
| 1451 |
+
# with what training loss actually minimized, rather than
|
| 1452 |
+
# silently reverting to the unweighted loss here.
|
| 1453 |
+
if use_log_space_discharge_loss:
|
| 1454 |
+
supervised_Q = log_space_multi_quantile_loss(
|
| 1455 |
+
Y_i, Q_pred_quantiles, QUANTILES, discharge_mean, discharge_std,
|
| 1456 |
+
log_offset=log_discharge_offset, sample_weight=event_weight_i,
|
| 1457 |
+
)
|
| 1458 |
+
elif use_per_station_relative_loss:
|
| 1459 |
+
supervised_Q = per_station_relative_multi_quantile_loss(
|
| 1460 |
+
Y_i, Q_pred_quantiles, QUANTILES, discharge_mean, discharge_std,
|
| 1461 |
+
discharge_per_node_std, sample_weight=event_weight_i,
|
| 1462 |
+
)
|
| 1463 |
+
else:
|
| 1464 |
+
supervised_Q = weighted_multi_quantile_loss(Y_i, Q_pred_quantiles, QUANTILES, event_weight_i)
|
| 1465 |
supervised_H = multi_quantile_loss(Y_H_i, H_pred_quantiles, QUANTILES)
|
| 1466 |
# isfinite, not isnan -- confirmed as a real bug against
|
| 1467 |
# real training: an untrained model produced an
|
|
|
|
| 1505 |
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
| 1506 |
epochs_without_improvement = 0
|
| 1507 |
history["best_epoch"] = epoch + 1
|
| 1508 |
+
# Real, immediate on-disk save of the best weights -- not
|
| 1509 |
+
# just held in best_state until this whole function returns.
|
| 1510 |
+
# A real crash/interruption any time after this line still
|
| 1511 |
+
# leaves the best real weights (plus optimizer state/epoch/
|
| 1512 |
+
# history, for --resume) safely on disk from THIS epoch,
|
| 1513 |
+
# rather than losing everything back to the last full
|
| 1514 |
+
# training run's own final save.
|
| 1515 |
+
if checkpoint_path is not None:
|
| 1516 |
+
try:
|
| 1517 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
| 1518 |
+
tmp_checkpoint_path = checkpoint_path.with_suffix(checkpoint_path.suffix + ".tmp")
|
| 1519 |
+
torch.save({
|
| 1520 |
+
"model_state": best_state,
|
| 1521 |
+
"optimizer_state": optimizer.state_dict(),
|
| 1522 |
+
"epoch": epoch,
|
| 1523 |
+
"best_val_loss": best_val_loss,
|
| 1524 |
+
"best_val_Q_loss": best_val_Q_loss,
|
| 1525 |
+
"best_val_H_loss": best_val_H_loss,
|
| 1526 |
+
"epochs_without_improvement": epochs_without_improvement,
|
| 1527 |
+
"history": history,
|
| 1528 |
+
}, tmp_checkpoint_path)
|
| 1529 |
+
# Atomic rename -- so a real crash mid-write never
|
| 1530 |
+
# leaves a real half-written, unloadable checkpoint
|
| 1531 |
+
# file at checkpoint_path itself.
|
| 1532 |
+
tmp_checkpoint_path.replace(checkpoint_path)
|
| 1533 |
+
print(f" saved real best checkpoint (epoch {epoch+1}, val_loss={val_loss:.4f}) "
|
| 1534 |
+
f"to {checkpoint_path}")
|
| 1535 |
+
except Exception as e:
|
| 1536 |
+
print(f" [warning] failed to save real checkpoint this epoch: {e}")
|
| 1537 |
else:
|
| 1538 |
epochs_without_improvement += 1
|
| 1539 |
|
|
|
|
| 1550 |
if best_state is not None:
|
| 1551 |
model.load_state_dict(best_state)
|
| 1552 |
return {"best_val_loss": best_val_loss, "best_val_Q_loss": best_val_Q_loss,
|
| 1553 |
+
"best_val_H_loss": best_val_H_loss, "model_state": model.state_dict(),
|
| 1554 |
+
"best_epoch": history.get("best_epoch")}
|
| 1555 |
|
| 1556 |
|
| 1557 |
def main() -> None:
|
|
|
|
| 1613 |
"Applied to the median prediction only, same convention as every other "
|
| 1614 |
"physics-adjacent loss in this file. Real per-node historical mean is "
|
| 1615 |
"fit on the training period only, same discipline as discharge_mean/std.")
|
| 1616 |
+
parser.add_argument("--log-space-discharge-loss", action="store_true",
|
| 1617 |
+
help="Opt-in, not yet the default -- computes the discharge supervised loss "
|
| 1618 |
+
"(pinball loss) in log(real L/s + log_discharge_offset) space instead of "
|
| 1619 |
+
"standardized space. Built after real evaluation confirmed a peak-specific "
|
| 1620 |
+
"bias (25.0% PBIAS at high flow vs. -1.8% at low flow, one real station). "
|
| 1621 |
+
"REAL, CONFIRMED CAVEAT: the original offset=1.0 (log1p) default weighted "
|
| 1622 |
+
"a low-flow error ~28x more than an identical high-flow error at real "
|
| 1623 |
+
"station scale, and a real training run with it produced a real, severe "
|
| 1624 |
+
"regression -- low-flow NSE collapsed to -116.7, PBIAS to -102.7%, at the "
|
| 1625 |
+
"same station this was built to help. See --log-discharge-offset, whose "
|
| 1626 |
+
"new default softens this. Mutually exclusive with "
|
| 1627 |
+
"--per-station-relative-loss by design -- pick one to test at a time. "
|
| 1628 |
+
"Scoped to discharge only -- water level doesn't share discharge's orders-"
|
| 1629 |
+
"of-magnitude real skew. Real validation loss (val_Q_loss) stays in the "
|
| 1630 |
+
"original, standardized space regardless of this flag, so it remains "
|
| 1631 |
+
"directly comparable across every run in this project, with or without "
|
| 1632 |
+
"this flag set -- only the TRAINING objective changes.")
|
| 1633 |
+
parser.add_argument("--log-discharge-offset", type=float, default=None,
|
| 1634 |
+
help="The real 'c' in log(real L/s + c), for --log-space-discharge-loss. None "
|
| 1635 |
+
"(the default) resolves to 0.25*discharge_std at run time -- a real, "
|
| 1636 |
+
"measured, data-derived value, not an arbitrary guess: measured directly "
|
| 1637 |
+
"to weight a real low-flow error ~10x more than an identical real high-"
|
| 1638 |
+
"flow error (down from ~28x at the original, riskier offset=1.0/log1p). "
|
| 1639 |
+
"Pass 1.0 explicitly to reproduce the original log1p behavior (not "
|
| 1640 |
+
"recommended given the real regression it caused) -- larger values soften "
|
| 1641 |
+
"low-flow sensitivity further, at the real cost of weakening the exact "
|
| 1642 |
+
"peak-bias fix this loss exists for.")
|
| 1643 |
+
parser.add_argument("--per-station-relative-loss", action="store_true",
|
| 1644 |
+
help="Opt-in -- normalizes each real node's real discharge by THAT NODE's own "
|
| 1645 |
+
"real historical std (see compute_per_node_historical_std) before computing "
|
| 1646 |
+
"the discharge supervised loss, instead of the single, GLOBAL discharge_std "
|
| 1647 |
+
"every other version of this loss uses. Built after real, direct evidence "
|
| 1648 |
+
"(check_discharge_quantile_distribution.py) showed this project's 6 real "
|
| 1649 |
+
"gauges span wildly different real scale (real Q50 from ~868 to ~9323 L/s) "
|
| 1650 |
+
"and skew (real Q50/Q01 from 1.4x to literally infinite) -- under global "
|
| 1651 |
+
"standardization, a real, proportionally severe error at a small station "
|
| 1652 |
+
"contributes much less loss than the identical real proportional error at "
|
| 1653 |
+
"a large station. Addresses a genuinely different dimension of the real "
|
| 1654 |
+
"scale problem than --log-space-discharge-loss does (across stations, not "
|
| 1655 |
+
"within one station's own low-vs-high flow) -- mutually exclusive with it "
|
| 1656 |
+
"by design, not stacked, so results can be attributed to one real change "
|
| 1657 |
+
"at a time. Scoped to discharge only, same reasoning as log-space.")
|
| 1658 |
+
parser.add_argument("--use-kge-loss", action="store_true",
|
| 1659 |
+
help="Opt-in real, TEMPORAL Kling-Gupta Efficiency loss, meant to replace NSE "
|
| 1660 |
+
"(pass --nse-weight 0 alongside this for a clean A/B test), not stack with "
|
| 1661 |
+
"it. Unlike nse_loss, which only needs a node's own pre-computed historical "
|
| 1662 |
+
"mean and works fine per-example, real KGE's correlation term needs "
|
| 1663 |
+
"multiple real, PAIRED (observed, predicted) points for the SAME node to "
|
| 1664 |
+
"mean anything -- ground truth is too sparse within one example for that "
|
| 1665 |
+
"(20-50 real values spread across ~100 nodes on one real anchor date). This "
|
| 1666 |
+
"pools real pairs PER NODE ACROSS THE WHOLE BATCH before computing KGE once "
|
| 1667 |
+
"per node with enough real points (see --kge-min-points), a genuinely "
|
| 1668 |
+
"different, batch-level accumulation pattern from every other per-example "
|
| 1669 |
+
"loss in this file, not a bug. Applied to both discharge and water level "
|
| 1670 |
+
"medians, same scope as NSE had.")
|
| 1671 |
+
parser.add_argument("--kge-min-points", type=int, default=5,
|
| 1672 |
+
help="Minimum real, pooled (observed, predicted) points required for a node "
|
| 1673 |
+
"before computing its real KGE contribution this batch -- a correlation "
|
| 1674 |
+
"from fewer real points isn't real evidence of timing skill (2 points give "
|
| 1675 |
+
"a correlation of exactly +-1 or an undefined value). Nodes below this "
|
| 1676 |
+
"threshold in a given batch simply don't contribute to KGE loss that step, "
|
| 1677 |
+
"not treated as zero loss.")
|
| 1678 |
+
parser.add_argument("--kge-weight", type=float, default=0.3,
|
| 1679 |
+
help="Weight for --use-kge-loss. Defaults to 0.3, not 1.0 -- matching the real, "
|
| 1680 |
+
"measured lesson from NSE: full weight (1.0) caused a real, severe day-30 "
|
| 1681 |
+
"flood-detection collapse (tp 21->1), only partially recovered at 0.3. "
|
| 1682 |
+
"Since KGE is a new, less-tested loss with a genuinely different "
|
| 1683 |
+
"accumulation pattern (batch-pooled, not per-example), starting cautious "
|
| 1684 |
+
"rather than assuming full weight is safe.")
|
| 1685 |
+
parser.add_argument("--event-reweight", action="store_true",
|
| 1686 |
+
help="Opt-in -- upweights the discharge supervised (pinball) loss for real "
|
| 1687 |
+
"(node, horizon) examples whose real TARGET date falls within "
|
| 1688 |
+
"--event-reweight-window-days of a real labeled flood event (from "
|
| 1689 |
+
"--events-path), boosted by --event-reweight-boost; every other example "
|
| 1690 |
+
"keeps the neutral weight of 1.0. Built as the direct response to a real, "
|
| 1691 |
+
"measured problem: --check-quantile-calibration on a real checkpoint showed "
|
| 1692 |
+
"opposite-direction miscalibration (q0.95 coverage 0.990 on low-flow days, "
|
| 1693 |
+
"0.698 on high-flow days -- both should be ~0.95) consistent with a model "
|
| 1694 |
+
"whose learned quantile function is dominated by the sheer VOLUME of quiet-"
|
| 1695 |
+
"day examples, since real flood events are rare (125 across the whole "
|
| 1696 |
+
"2013-2025 record) -- pinball loss's own per-sample asymmetry (a flood-day "
|
| 1697 |
+
"underprediction already costs more than a same-size quiet-day "
|
| 1698 |
+
"overprediction) doesn't fix a per-example-COUNT imbalance, which this "
|
| 1699 |
+
"targets directly instead. Composes with --log-space-discharge-loss AND "
|
| 1700 |
+
"with --per-station-relative-loss (deliberately NOT mutually exclusive with "
|
| 1701 |
+
"either -- this addresses a genuinely different axis, sample count, not "
|
| 1702 |
+
"value scale). Scoped to discharge only, same reasoning as log-space and "
|
| 1703 |
+
"per-station-relative. Requires real events at --events-path; if none are "
|
| 1704 |
+
"found, this flag is a no-op (weight stays 1.0 everywhere) and a warning is "
|
| 1705 |
+
"printed rather than failing the run.")
|
| 1706 |
+
parser.add_argument("--events-path", type=Path, default=None,
|
| 1707 |
+
help="Defaults to <data-root>/flood_events_discharge.csv, same real file "
|
| 1708 |
+
"evaluate_flood_detection.py already reads -- only loaded when "
|
| 1709 |
+
"--event-reweight is set.")
|
| 1710 |
+
parser.add_argument("--event-reweight-window-days", type=int, default=5,
|
| 1711 |
+
help="How many real days from a labeled event's start/end the boost still "
|
| 1712 |
+
"applies. Default 5 -- identify_flood_events.py's own docstring notes real "
|
| 1713 |
+
"event durations already run 3-10 days, so this catches the real rising/"
|
| 1714 |
+
"falling limb immediately around a labeled event, not just the event's own "
|
| 1715 |
+
"days.")
|
| 1716 |
+
parser.add_argument("--event-reweight-boost", type=float, default=4.0,
|
| 1717 |
+
help="The real weight multiplier applied to boosted examples (an untouched "
|
| 1718 |
+
"example keeps weight 1.0). Default 4.0 -- a real, moderate value in the "
|
| 1719 |
+
"3-5x range; large enough to meaningfully shift gradient share toward "
|
| 1720 |
+
"flood-adjacent examples without risking the kind of real, severe "
|
| 1721 |
+
"regression this project has already hit from being too aggressive with a "
|
| 1722 |
+
"new loss mechanism (see --kge-weight's own docstring for a real precedent). "
|
| 1723 |
+
"Compare against a run with --event-reweight omitted (an exact no-op "
|
| 1724 |
+
"baseline, not just boost=1.0) before trusting this helps.")
|
| 1725 |
+
parser.add_argument("--temporal-type", type=str, default="gru", choices=["gru", "lstm", "cnn"],
|
| 1726 |
+
help="A real, direct ablation switch for the per-node temporal encoder -- "
|
| 1727 |
+
"everything else (graph layers, physics losses, decoder) stays identical "
|
| 1728 |
+
"regardless of this choice, so comparing runs across the three isolates "
|
| 1729 |
+
"just this one component. 'gru' is the original, default architecture. "
|
| 1730 |
+
"'lstm' swaps in nn.LSTM (same interface, uses only its hidden state, not "
|
| 1731 |
+
"its cell state, downstream). 'cnn' uses a real, causal, dilated 1D conv "
|
| 1732 |
+
"stack (_CausalTemporalCNN in spatiotemporal_gnn.py) instead of a "
|
| 1733 |
+
"recurrent mechanism -- its causality (no future timestep ever leaking "
|
| 1734 |
+
"into an earlier output) was verified directly, not just reasoned about, "
|
| 1735 |
+
"before this option was added.")
|
| 1736 |
parser.add_argument("--batch-size", type=int, default=8,
|
| 1737 |
help="Small default -- each example is a full graph forward pass looped "
|
| 1738 |
"internally (see train_spatiotemporal_model's docstring), so this is "
|
|
|
|
| 1741 |
parser.add_argument("--patience", type=int, default=10)
|
| 1742 |
parser.add_argument("--lr", type=float, default=1e-3)
|
| 1743 |
parser.add_argument("--weight-decay", type=float, default=1e-4)
|
| 1744 |
+
parser.add_argument("--checkpoint-path", type=Path, default=None,
|
| 1745 |
+
help="Where to save a real, RESUMABLE checkpoint (model weights + optimizer "
|
| 1746 |
+
"state + epoch counter + best-val-loss-so-far + real loss history) every "
|
| 1747 |
+
"single time validation loss improves during training -- not just the bare "
|
| 1748 |
+
"final model.pt, which is only ever written once the whole real training "
|
| 1749 |
+
"run completes. This makes an interrupted/crashed real run recoverable: the "
|
| 1750 |
+
"best real weights seen so far are always already on disk, updated the same "
|
| 1751 |
+
"epoch they're found, not held only in memory until training finishes. "
|
| 1752 |
+
"Defaults to <output_dir>/checkpoint.pt, i.e. the same real per-temporal_type "
|
| 1753 |
+
"directory model.pt itself already uses (datasets/spatiotemporal_gnn/ for "
|
| 1754 |
+
"gru, .../lstm/ or .../cnn/ otherwise).")
|
| 1755 |
+
parser.add_argument("--resume", action="store_true",
|
| 1756 |
+
help="Resume training from --checkpoint-path (or its default location) if a "
|
| 1757 |
+
"real checkpoint exists there -- restores real model weights, optimizer "
|
| 1758 |
+
"state, the epoch counter, the best validation loss seen so far, "
|
| 1759 |
+
"epochs-without-improvement (for early stopping), and real loss history, "
|
| 1760 |
+
"then continues training from the next real epoch instead of starting over "
|
| 1761 |
+
"from a freshly initialized model. Silently starts fresh (same as omitting "
|
| 1762 |
+
"this flag) if no checkpoint is found at that real path yet.")
|
| 1763 |
args = parser.parse_args()
|
| 1764 |
|
| 1765 |
+
if args.log_space_discharge_loss and args.per_station_relative_loss:
|
| 1766 |
+
print("--log-space-discharge-loss and --per-station-relative-loss are mutually exclusive by "
|
| 1767 |
+
"design -- both address a real scale problem, but along different dimensions (within one "
|
| 1768 |
+
"station vs. across stations), and this project's own real evidence (log-space + KGE both "
|
| 1769 |
+
"independently causing the same real regression) argues for isolating one new mechanism "
|
| 1770 |
+
"at a time before ever combining them. Pick one.")
|
| 1771 |
+
return
|
| 1772 |
+
|
| 1773 |
shared_static_cols = ["elevation_m", "catchment_area_km2", "idpr_value",
|
| 1774 |
"avg_groundwater_level_m", "distance_to_nearest_cavity_km", "n_cavities_within_20km"]
|
| 1775 |
# Dropped raw latitude/longitude -- real risk of the model memorizing
|
|
|
|
| 1801 |
combined = combine_basins(basin_data, shared_static_cols, gauge_static_cols)
|
| 1802 |
print(f"Combined graph: {combined['n_nodes']} nodes, {combined['edge_index'].shape[1]} edges")
|
| 1803 |
|
| 1804 |
+
# Real, separate storage per temporal_type -- "gru" (the original,
|
| 1805 |
+
# default architecture, used in every real run this project has
|
| 1806 |
+
# produced so far) keeps the existing, unchanged path for backward
|
| 1807 |
+
# compatibility; "lstm"/"cnn" get their own real subfolder, so a
|
| 1808 |
+
# later comparison run never silently overwrites a different
|
| 1809 |
+
# architecture's checkpoint, and each type's own archive/ history
|
| 1810 |
+
# (see the archiving logic below) stays genuinely separate too.
|
| 1811 |
output_dir = args.data_root / "spatiotemporal_gnn"
|
| 1812 |
+
if args.temporal_type != "gru":
|
| 1813 |
+
output_dir = output_dir / args.temporal_type
|
| 1814 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1815 |
try:
|
| 1816 |
plot_selected_subgraph(basin_data, combined, output_dir / "selected_subgraph.png")
|
|
|
|
| 1897 |
}
|
| 1898 |
print(f"Real days-since-last-observation channel added for: {list(dt_tensors.keys())}")
|
| 1899 |
|
| 1900 |
+
# Real, per-node fill values -- CRITICAL, CONFIRMED FIX (see
|
| 1901 |
+
# prepare_graph_training_windows' own docstring): plain 0.0 (the
|
| 1902 |
+
# global-standardized "neutral" fill) unstandardizes to the GLOBAL
|
| 1903 |
+
# mean, which is wildly non-neutral for any real, small station far
|
| 1904 |
+
# below that global average -- confirmed directly, traced via a
|
| 1905 |
+
# real false-spike investigation, to a real ~60x input distortion
|
| 1906 |
+
# at one real station during a real data gap. Computed from the
|
| 1907 |
+
# real TRAINING period only (same leakage discipline as
|
| 1908 |
+
# discharge_mean/std themselves), on the ALREADY-standardized
|
| 1909 |
+
# dynamic_tensors, so each value is a per-node offset in the same
|
| 1910 |
+
# global-standardized units the rest of the tensor already uses.
|
| 1911 |
+
#
|
| 1912 |
+
# MEDIAN, not mean, per a real, confirmed finding: the per-node
|
| 1913 |
+
# MEAN fill value for one real station (H605641401) was itself
|
| 1914 |
+
# still ~30x that station's real low-flow values, because real
|
| 1915 |
+
# discharge is right-skewed and rare, real flood days pull the
|
| 1916 |
+
# mean well above a real "typical" day. See
|
| 1917 |
+
# compute_per_node_historical_median's own docstring.
|
| 1918 |
+
train_end_ts_for_fill = pd.Timestamp(args.train_end)
|
| 1919 |
+
train_date_mask_for_fill = np.array([d <= train_end_ts_for_fill for d in dates])
|
| 1920 |
+
per_node_fill_values = {
|
| 1921 |
+
var: compute_per_node_historical_median(tensor[:, train_date_mask_for_fill])
|
| 1922 |
+
for var, tensor in dynamic_tensors.items()
|
| 1923 |
+
}
|
| 1924 |
+
|
| 1925 |
+
# CONFIRMED FIX #2 -- forward-fill, preferred over the per-node
|
| 1926 |
+
# median above whenever a real prior observation exists to carry
|
| 1927 |
+
# forward. A station-wide median is only neutral for a node's
|
| 1928 |
+
# TYPICAL day; it's the wrong reference for a gap that falls
|
| 1929 |
+
# inside a real, ongoing low- or high-flow episode (confirmed
|
| 1930 |
+
# directly: H605641401's real per-node MEDIAN fill, 1142.0, was
|
| 1931 |
+
# still ~17x its real low-flow values, 57-70 L/s, during a real
|
| 1932 |
+
# gap inside a real low-flow episode -- and a direct causal
|
| 1933 |
+
# ablation confirmed the missingness-flag mechanism alone cannot
|
| 1934 |
+
# compensate for a fill value that's simply the wrong magnitude).
|
| 1935 |
+
# Computed on the SAME already-standardized dynamic_tensors as
|
| 1936 |
+
# per_node_fill_values above, over the FULL real date range (not
|
| 1937 |
+
# just the training period) -- unlike per_node_fill_values,
|
| 1938 |
+
# forward-fill needs no train-only-fit discipline at all, since it
|
| 1939 |
+
# only ever looks backward from any given date (see
|
| 1940 |
+
# compute_forward_filled_tensor's own docstring).
|
| 1941 |
+
forward_fill_tensors = {
|
| 1942 |
+
var: compute_forward_filled_tensor(tensor)
|
| 1943 |
+
for var, tensor in dynamic_tensors.items()
|
| 1944 |
+
}
|
| 1945 |
+
|
| 1946 |
X, Y, Y_mask, anchor_dates = prepare_graph_training_windows(
|
| 1947 |
combined["x_shared_static"], dynamic_tensors, target_tensor, dates, args.lookback_days, HORIZONS,
|
| 1948 |
+
dt_tensors=dt_tensors, per_node_fill_values=per_node_fill_values,
|
| 1949 |
+
forward_fill_tensors=forward_fill_tensors,
|
| 1950 |
)
|
| 1951 |
print(f"Training windows: X={X.shape}, Y={Y.shape}")
|
| 1952 |
|
| 1953 |
+
# --event-reweight -- ALWAYS build an event_weight array, same shape
|
| 1954 |
+
# as Y's (example, node, horizon) axes, even when the flag is off:
|
| 1955 |
+
# an all-1.0 array makes the weighted loss functions below EXACTLY
|
| 1956 |
+
# reproduce the original unweighted ones (confirmed directly --
|
| 1957 |
+
# weighted_multi_quantile_loss's single grand-weighted-mean equals
|
| 1958 |
+
# multi_quantile_loss's mean-of-per-quantile-means whenever every
|
| 1959 |
+
# quantile has the same real finite-element count, which is always
|
| 1960 |
+
# true here since finiteness is governed by Y's own NaN pattern, not
|
| 1961 |
+
# by which quantile is being scored). This keeps the training loop
|
| 1962 |
+
# below simple -- always call the weighted form, no separate
|
| 1963 |
+
# on/off branch needed there.
|
| 1964 |
+
event_weight = np.ones((len(anchor_dates), combined["n_nodes"], len(HORIZONS)), dtype=np.float32)
|
| 1965 |
+
if args.event_reweight:
|
| 1966 |
+
events_path_train = args.events_path or (args.data_root / "flood_events_discharge.csv")
|
| 1967 |
+
if events_path_train.exists():
|
| 1968 |
+
events_df_train = pd.read_csv(events_path_train)
|
| 1969 |
+
if events_df_train.empty:
|
| 1970 |
+
print(f"--event-reweight requested, but {events_path_train} has no real events -- "
|
| 1971 |
+
f"run identify_flood_events.py first. Proceeding with neutral weight=1.0 "
|
| 1972 |
+
f"everywhere (a no-op).")
|
| 1973 |
+
else:
|
| 1974 |
+
event_lookup_train = build_event_lookup(events_df_train)
|
| 1975 |
+
event_weight = build_event_weight_tensor(
|
| 1976 |
+
combined["station_codes"], anchor_dates, HORIZONS, event_lookup_train,
|
| 1977 |
+
window_days=args.event_reweight_window_days, boost=args.event_reweight_boost,
|
| 1978 |
+
)
|
| 1979 |
+
n_boosted = int((event_weight > 1.0).sum())
|
| 1980 |
+
print(f"--event-reweight: loaded {len(events_df_train)} real event(s) across "
|
| 1981 |
+
f"{events_df_train['station_code'].nunique()} station(s); boosted "
|
| 1982 |
+
f"{n_boosted} (example, node, horizon) value(s) to weight={args.event_reweight_boost} "
|
| 1983 |
+
f"(within {args.event_reweight_window_days} real day(s) of a real labeled event)")
|
| 1984 |
+
else:
|
| 1985 |
+
print(f"--event-reweight requested, but {events_path_train} doesn't exist -- run "
|
| 1986 |
+
f"identify_flood_events.py first. Proceeding with neutral weight=1.0 everywhere "
|
| 1987 |
+
f"(a no-op).")
|
| 1988 |
+
|
| 1989 |
Y_H = extract_targets_at_anchor_dates(waterlevel_target_tensor, dates, anchor_dates, HORIZONS)
|
| 1990 |
Y_H_mask = ~np.isnan(Y_H)
|
| 1991 |
n_real_wl = int(Y_H_mask.sum())
|
|
|
|
| 2052 |
return
|
| 2053 |
|
| 2054 |
train_ds = GraphWindowDataset(X[train_mask], Y[train_mask], Y_mask[train_mask], Y_H[train_mask], Y_H_mask[train_mask],
|
| 2055 |
+
forecast_precip[train_mask], forecast_missing[train_mask], event_weight[train_mask])
|
| 2056 |
val_ds = GraphWindowDataset(X[val_mask], Y[val_mask], Y_mask[val_mask], Y_H[val_mask], Y_H_mask[val_mask],
|
| 2057 |
+
forecast_precip[val_mask], forecast_missing[val_mask], event_weight[val_mask])
|
| 2058 |
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
|
| 2059 |
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
| 2060 |
|
|
|
|
| 2083 |
shared_static_dim=x_shared_static_t.shape[1], gauge_static_dim=x_gauge_static_t.shape[1],
|
| 2084 |
gauge_dynamic_dim=n_gauge_dynamic_channels, edge_dim=edge_attr_t.shape[1],
|
| 2085 |
n_basins=len(BASIN_FILE_NAMES), n_targets=2, # [discharge, water_level]
|
| 2086 |
+
quantiles=QUANTILES, temporal_type=args.temporal_type,
|
| 2087 |
)
|
| 2088 |
|
| 2089 |
physics_weights = {"confluence": args.confluence_weight, "split_rejoin": args.split_rejoin_weight,
|
| 2090 |
"routing": args.routing_weight, "manning": args.manning_weight,
|
| 2091 |
"water_balance": args.water_balance_weight,
|
| 2092 |
"routing_soft_dtw": args.routing_soft_dtw_weight,
|
| 2093 |
+
"nse": args.nse_weight, "kge": args.kge_weight}
|
| 2094 |
print(f"Physics loss weights: {physics_weights}")
|
| 2095 |
print()
|
| 2096 |
|
| 2097 |
+
# Same real per-temporal_type default location as model.pt itself
|
| 2098 |
+
# -- see --checkpoint-path's own help text for why this is a
|
| 2099 |
+
# SEPARATE real file from model.pt (resumable: carries optimizer
|
| 2100 |
+
# state/epoch/history too, updated every improving epoch, not just
|
| 2101 |
+
# once at the very end of training).
|
| 2102 |
+
checkpoint_path = args.checkpoint_path or (output_dir / "checkpoint.pt")
|
| 2103 |
+
resume_from = checkpoint_path if args.resume else None
|
| 2104 |
+
|
| 2105 |
# Real precipitation channel index -- None if the real precipitation
|
| 2106 |
# CSV wasn't fetched for this run, in which case water balance is
|
| 2107 |
# skipped entirely inside compute_physics_loss, not silently faked.
|
|
|
|
| 2121 |
|
| 2122 |
discharge_mean, discharge_std = standardization_stats["discharge"]
|
| 2123 |
precip_mean, precip_std = standardization_stats.get("precipitation", (0.0, 1.0))
|
| 2124 |
+
# Needed for --use-kge-loss: real KGE requires real, un-standardized
|
| 2125 |
+
# units, confirmed as a real, structural requirement, not a nicety
|
| 2126 |
+
# (see kge_loss_from_pooled_pairs' own docstring).
|
| 2127 |
+
waterlevel_mean, waterlevel_std = standardization_stats.get("waterlevel", (0.0, 1.0))
|
| 2128 |
|
| 2129 |
# Real per-node historical mean, for NSE -- fit on the training
|
| 2130 |
# period only (same discipline as discharge_mean/std above), from
|
|
|
|
| 2139 |
waterlevel_per_node_mean_t = torch.tensor(
|
| 2140 |
compute_per_node_historical_mean(waterlevel_target_tensor[:, train_date_mask]), dtype=torch.float32,
|
| 2141 |
)
|
| 2142 |
+
# For --per-station-relative-loss: real, per-node std, in REAL,
|
| 2143 |
+
# physical L/s units (converted from the standardized-space std by
|
| 2144 |
+
# multiplying by the real, global discharge_std -- std(a*X) =
|
| 2145 |
+
# |a|*std(X), confirmed directly before use, not assumed). Built
|
| 2146 |
+
# after real, direct evidence (check_discharge_quantile_
|
| 2147 |
+
# distribution.py) showed this project's 6 real gauges have wildly
|
| 2148 |
+
# different real scale (Q50 ranging from real ~868 L/s to ~9323
|
| 2149 |
+
# L/s) -- a single, global discharge_std treats a real,
|
| 2150 |
+
# proportionally large error at a small station as much smaller
|
| 2151 |
+
# than the identical real proportional error at a large station.
|
| 2152 |
+
discharge_per_node_std_t = torch.tensor(
|
| 2153 |
+
compute_per_node_historical_std(target_tensor[:, train_date_mask]) * discharge_std, dtype=torch.float32,
|
| 2154 |
+
)
|
| 2155 |
|
| 2156 |
result = train_spatiotemporal_model(
|
| 2157 |
model, train_loader, val_loader, x_shared_static_t, x_gauge_static_t, is_gauged_t,
|
|
|
|
| 2164 |
soft_dtw_gamma=args.routing_soft_dtw_gamma,
|
| 2165 |
discharge_per_node_mean=discharge_per_node_mean_t,
|
| 2166 |
waterlevel_per_node_mean=waterlevel_per_node_mean_t,
|
| 2167 |
+
use_log_space_discharge_loss=args.log_space_discharge_loss,
|
| 2168 |
+
log_discharge_offset=args.log_discharge_offset,
|
| 2169 |
+
use_per_station_relative_loss=args.per_station_relative_loss,
|
| 2170 |
+
discharge_per_node_std=discharge_per_node_std_t,
|
| 2171 |
+
use_kge_loss=args.use_kge_loss,
|
| 2172 |
+
kge_min_points=args.kge_min_points,
|
| 2173 |
+
waterlevel_mean=waterlevel_mean,
|
| 2174 |
+
waterlevel_std=waterlevel_std,
|
| 2175 |
+
checkpoint_path=checkpoint_path,
|
| 2176 |
+
resume_from=resume_from,
|
| 2177 |
)
|
| 2178 |
|
| 2179 |
model_path = output_dir / "model.pt"
|
|
|
|
| 2192 |
shutil.move(str(model_path), str(archived_path))
|
| 2193 |
print(f"Archived previous checkpoint to {archived_path}")
|
| 2194 |
torch.save(result["model_state"], model_path)
|
| 2195 |
+
print(f"Saved final real model (bare state_dict, for evaluate_flood_detection.py etc.) to {model_path}")
|
| 2196 |
+
print(f"(The real resumable checkpoint at {checkpoint_path} -- model+optimizer+epoch+history -- "
|
| 2197 |
+
f"already held these exact same best real weights since epoch {result.get('best_epoch', '?')}; "
|
| 2198 |
+
f"this final save is a convenience copy in the plain format the evaluation scripts expect.)")
|
| 2199 |
print(f"\nBest validation loss: {result['best_val_loss']:.4f}")
|
| 2200 |
learned_n = 0.01 + 0.29 * torch.sigmoid(model.manning_n_logit).item()
|
| 2201 |
print(f"Learned Manning's roughness n = {learned_n:.4f} "
|
src/graph/__pycache__/dynamic_features.cpython-311.pyc
CHANGED
|
Binary files a/src/graph/__pycache__/dynamic_features.cpython-311.pyc and b/src/graph/__pycache__/dynamic_features.cpython-311.pyc differ
|
|
|
src/graph/__pycache__/flood_events.cpython-311.pyc
ADDED
|
Binary file (10.4 kB). View file
|
|
|
src/graph/__pycache__/physics_losses.cpython-311.pyc
CHANGED
|
Binary files a/src/graph/__pycache__/physics_losses.cpython-311.pyc and b/src/graph/__pycache__/physics_losses.cpython-311.pyc differ
|
|
|
src/graph/__pycache__/pinball_loss.cpython-311.pyc
CHANGED
|
Binary files a/src/graph/__pycache__/pinball_loss.cpython-311.pyc and b/src/graph/__pycache__/pinball_loss.cpython-311.pyc differ
|
|
|
src/graph/__pycache__/spatiotemporal_gnn.cpython-311.pyc
CHANGED
|
Binary files a/src/graph/__pycache__/spatiotemporal_gnn.cpython-311.pyc and b/src/graph/__pycache__/spatiotemporal_gnn.cpython-311.pyc differ
|
|
|
src/graph/dynamic_features.py
CHANGED
|
@@ -334,6 +334,57 @@ def compress_days_since_last_real(dt):
|
|
| 334 |
return np.log1p(dt).astype(np.float32)
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
def build_precipitation_timeseries(
|
| 338 |
nodes_df: pd.DataFrame, precip_csv_path: Path, date_range: Tuple[str, str], freq: str = "D",
|
| 339 |
) -> pd.DataFrame:
|
|
@@ -357,7 +408,40 @@ def build_precipitation_timeseries(
|
|
| 357 |
precip_df = pd.read_csv(precip_csv_path)
|
| 358 |
precip_df["date"] = pd.to_datetime(precip_df["date"])
|
| 359 |
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
start, end = date_range
|
| 363 |
full_index = pd.date_range(start, end, freq=freq)
|
|
|
|
| 334 |
return np.log1p(dt).astype(np.float32)
|
| 335 |
|
| 336 |
|
| 337 |
+
def compute_forward_filled_tensor(tensor):
|
| 338 |
+
"""
|
| 339 |
+
Real forward-filled value per [node, date] position: the most
|
| 340 |
+
recent real (non-NaN) observation at or before that date, NaN if
|
| 341 |
+
no real observation has occurred yet anywhere before it. Causally
|
| 342 |
+
safe by construction -- forward-fill only ever looks backward in
|
| 343 |
+
time, so unlike a fitted statistic (a per-node mean/median, which
|
| 344 |
+
needs a "fit on train only" discipline to avoid leaking future
|
| 345 |
+
information into the fill), this needs no train/val/test split
|
| 346 |
+
guard at all: a forward-filled value at any date, in any split,
|
| 347 |
+
only ever depends on dates strictly before it.
|
| 348 |
+
|
| 349 |
+
Built specifically as a fill-value SOURCE for real gaps during a
|
| 350 |
+
real low- (or high-) flow episode, where a station-wide central-
|
| 351 |
+
tendency fill (compute_per_node_historical_median, in
|
| 352 |
+
physics_losses.py) is itself the wrong reference point --
|
| 353 |
+
confirmed directly via a real false-spike investigation: even a
|
| 354 |
+
real per-node MEDIAN fill (1142.0, for station H605641401) was
|
| 355 |
+
still ~17x that station's real low-flow values (57-70 L/s) during
|
| 356 |
+
a real gap that fell IN THE MIDDLE of a real low-flow episode,
|
| 357 |
+
because a median describes a station's typical day, not
|
| 358 |
+
necessarily the day immediately preceding a specific gap. A real,
|
| 359 |
+
direct ablation (zeroing the missingness flag and days-since
|
| 360 |
+
channel for this exact example) confirmed the flag mechanism alone
|
| 361 |
+
cannot fix this: even with the model given no signal at all that
|
| 362 |
+
the value was filled, the median-based fill still produced a
|
| 363 |
+
predicted 0.95 quantile ~28x the real observed value, because the
|
| 364 |
+
filled VALUE itself (1142.0) was implausible for the real
|
| 365 |
+
conditions at that moment, not because the model failed to
|
| 366 |
+
discount a flagged fill. Forward-fill instead carries the real,
|
| 367 |
+
most-recently-known value forward, directly informative about
|
| 368 |
+
real LOCAL, CURRENT conditions in a way a station-wide statistic
|
| 369 |
+
cannot be.
|
| 370 |
+
|
| 371 |
+
Same accumulate-forward pattern already validated in
|
| 372 |
+
compute_days_since_last_real -- computed over the FULL real date
|
| 373 |
+
range, not per-window, for the same reason: correctly looks
|
| 374 |
+
backward past wherever a later lookback window happens to start.
|
| 375 |
+
"""
|
| 376 |
+
n_nodes, n_dates = tensor.shape
|
| 377 |
+
is_real = ~np.isnan(tensor)
|
| 378 |
+
date_indices = np.arange(n_dates)
|
| 379 |
+
|
| 380 |
+
idx_where_real = np.where(is_real, date_indices[None, :], -1)
|
| 381 |
+
last_real_idx = np.maximum.accumulate(idx_where_real, axis=1)
|
| 382 |
+
|
| 383 |
+
filled = np.take_along_axis(tensor, np.clip(last_real_idx, 0, None), axis=1)
|
| 384 |
+
filled = np.where(last_real_idx < 0, np.nan, filled)
|
| 385 |
+
return filled.astype(np.float32)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
def build_precipitation_timeseries(
|
| 389 |
nodes_df: pd.DataFrame, precip_csv_path: Path, date_range: Tuple[str, str], freq: str = "D",
|
| 390 |
) -> pd.DataFrame:
|
|
|
|
| 408 |
precip_df = pd.read_csv(precip_csv_path)
|
| 409 |
precip_df["date"] = pd.to_datetime(precip_df["date"])
|
| 410 |
|
| 411 |
+
# CRITICAL, CONFIRMED FIX -- exclude real "ghost" stations (present
|
| 412 |
+
# in the real Météo-France station list, with real LAT/LON, but
|
| 413 |
+
# ZERO real precip_mm_daily readings across every real row they
|
| 414 |
+
# have) from the candidate pool BEFORE nearest-neighbor matching.
|
| 415 |
+
# Confirmed directly against a real daily_precipitation.csv: 56 of
|
| 416 |
+
# 110 real candidate stations (51%) have precip_mm_daily entirely
|
| 417 |
+
# NaN -- a real fetch/ingestion gap in fetch_meteofrance_
|
| 418 |
+
# precipitation.py, not a downstream bug -- yet
|
| 419 |
+
# map_stations_to_precipitation_gauges has no liveness check, only
|
| 420 |
+
# a distance check, so any real graph node whose real geographically
|
| 421 |
+
# -nearest station happened to be one of these 56 real ghosts got
|
| 422 |
+
# permanently, silently matched to a station that can never
|
| 423 |
+
# contribute a single real reading. Confirmed as the real, direct
|
| 424 |
+
# cause of 22 of 27 real gauge stations in one real evaluation run
|
| 425 |
+
# having ZERO real precipitation anywhere in the full 2013-2026
|
| 426 |
+
# record -- every one of forward-fill, the per-node median fallback,
|
| 427 |
+
# AND compress_days_since_last_real's real 365-day sentinel were all
|
| 428 |
+
# working exactly as designed; there was simply never a real
|
| 429 |
+
# observation for any of them to find. Filtering here, once, before
|
| 430 |
+
# the nearest-neighbor search, routes every real node to the
|
| 431 |
+
# nearest real LIVE station instead -- a real, if sometimes farther,
|
| 432 |
+
# station that can actually contribute real data, which is strictly
|
| 433 |
+
# better than a real ghost at zero distance.
|
| 434 |
+
stations_with_real_data = precip_df.loc[precip_df["precip_mm_daily"].notna(), "NUM_POSTE"].unique()
|
| 435 |
+
n_candidates_before = precip_df["NUM_POSTE"].nunique()
|
| 436 |
+
precip_stations_live = precip_df[precip_df["NUM_POSTE"].isin(stations_with_real_data)]
|
| 437 |
+
n_candidates_after = precip_stations_live["NUM_POSTE"].nunique()
|
| 438 |
+
if n_candidates_after < n_candidates_before:
|
| 439 |
+
print(f"build_precipitation_timeseries: excluding {n_candidates_before - n_candidates_after} of "
|
| 440 |
+
f"{n_candidates_before} real candidate precipitation station(s) with ZERO real "
|
| 441 |
+
f"precip_mm_daily readings from nearest-neighbor matching, leaving "
|
| 442 |
+
f"{n_candidates_after} real live station(s).")
|
| 443 |
+
|
| 444 |
+
assignments = map_stations_to_precipitation_gauges(nodes_df, precip_stations_live[["NUM_POSTE", "LAT", "LON"]])
|
| 445 |
|
| 446 |
start, end = date_range
|
| 447 |
full_index = pd.date_range(start, end, freq=freq)
|
src/graph/flood_events.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real labeled flood-event bookkeeping -- shared between
|
| 3 |
+
evaluate_flood_detection.py (which USES these events to flag real
|
| 4 |
+
false/true positives after training) and train_spatiotemporal_gnn.py
|
| 5 |
+
(which, as of the event-reweighted loss added here, USES the same real
|
| 6 |
+
events to upweight training examples near them). Moved out of
|
| 7 |
+
evaluate_flood_detection.py into its own module specifically so
|
| 8 |
+
train_spatiotemporal_gnn.py can import it too, without creating a
|
| 9 |
+
circular import -- evaluate_flood_detection.py already imports FROM
|
| 10 |
+
scripts.training.train_spatiotemporal_gnn (shared data-pipeline functions), so
|
| 11 |
+
the reverse import would have failed; this module has no dependency on
|
| 12 |
+
either script, so both can import it safely.
|
| 13 |
+
|
| 14 |
+
events_df is identify_flood_events.py's own output (a CSV with columns
|
| 15 |
+
station_code, start_date, end_date, threshold) -- both scripts treat
|
| 16 |
+
this the same way: real, labeled, human-verifiable flood windows, not
|
| 17 |
+
a heuristic re-derived at import time.
|
| 18 |
+
"""
|
| 19 |
+
from typing import Dict, List
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_event_lookup(events_df: pd.DataFrame) -> dict:
|
| 26 |
+
"""
|
| 27 |
+
Real event date ranges, indexed by station, for fast membership
|
| 28 |
+
checks -- {station_code: [(start_date, end_date, threshold), ...]}.
|
| 29 |
+
A single station can have several real events across the record, so
|
| 30 |
+
this stays a list per station rather than assuming at most one.
|
| 31 |
+
"""
|
| 32 |
+
lookup = {}
|
| 33 |
+
for _, row in events_df.iterrows():
|
| 34 |
+
lookup.setdefault(row["station_code"], []).append(
|
| 35 |
+
(pd.Timestamp(row["start_date"]), pd.Timestamp(row["end_date"]), row["threshold"])
|
| 36 |
+
)
|
| 37 |
+
return lookup
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def is_real_event_day(station_code: str, date: pd.Timestamp, event_lookup: dict):
|
| 41 |
+
"""
|
| 42 |
+
Returns the real threshold if `date` falls within a real event for
|
| 43 |
+
this station, else None -- distinguishing "not an event" from
|
| 44 |
+
"no threshold defined" matters for how the caller should treat it,
|
| 45 |
+
so this returns the threshold itself rather than a bare boolean.
|
| 46 |
+
"""
|
| 47 |
+
for start, end, threshold in event_lookup.get(station_code, []):
|
| 48 |
+
if start <= date <= end:
|
| 49 |
+
return threshold
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def days_to_nearest_real_event(station_code: str, date: pd.Timestamp, event_lookup: dict) -> "float":
|
| 54 |
+
"""
|
| 55 |
+
Real, signed distance in days from `date` to the nearest real event
|
| 56 |
+
window for this station -- 0 if `date` falls inside one. Built to
|
| 57 |
+
distinguish a real false positive that's a near-miss (a day or two
|
| 58 |
+
from an actual real event) from one with no real event anywhere
|
| 59 |
+
nearby, which are genuinely different situations worth telling
|
| 60 |
+
apart when inspecting false positives directly.
|
| 61 |
+
|
| 62 |
+
Returns float("inf") if this station has no real events at all.
|
| 63 |
+
"""
|
| 64 |
+
best = float("inf")
|
| 65 |
+
for start, end, _ in event_lookup.get(station_code, []):
|
| 66 |
+
if start <= date <= end:
|
| 67 |
+
return 0.0
|
| 68 |
+
gap = min(abs((date - start).days), abs((date - end).days))
|
| 69 |
+
best = min(best, gap)
|
| 70 |
+
return best
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_station_thresholds(events_df: pd.DataFrame) -> dict:
|
| 74 |
+
"""
|
| 75 |
+
One real threshold per station -- identify_flood_events.py computes
|
| 76 |
+
this once per station (that station's own historical percentile),
|
| 77 |
+
so every real event row for a given station carries the same value;
|
| 78 |
+
this collapses that back down to {station_code: threshold}.
|
| 79 |
+
|
| 80 |
+
This threshold is what gets compared against EVERY day's
|
| 81 |
+
prediction, event or not -- comparing only on real event days (and
|
| 82 |
+
treating every other day as an automatic non-flag) would make false
|
| 83 |
+
positives structurally impossible and precision meaningless by
|
| 84 |
+
construction, not just optimistic.
|
| 85 |
+
"""
|
| 86 |
+
return events_df.groupby("station_code")["threshold"].first().to_dict()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def build_event_weight_tensor(
|
| 90 |
+
station_codes: List[str], anchor_dates: List[pd.Timestamp], horizons: List[int],
|
| 91 |
+
event_lookup: dict, window_days: int = 5, boost: float = 4.0,
|
| 92 |
+
) -> np.ndarray:
|
| 93 |
+
"""
|
| 94 |
+
Real, per-(example, node, horizon) sample weight for the SUPERVISED
|
| 95 |
+
discharge quantile loss -- boost where the real TARGET date (anchor
|
| 96 |
+
+ horizon - 1 days, not the anchor date itself) falls within
|
| 97 |
+
`window_days` of a real labeled flood event at that station, 1.0
|
| 98 |
+
(neutral) everywhere else.
|
| 99 |
+
|
| 100 |
+
Built as the direct, concrete response to the real, measured
|
| 101 |
+
problem this project's own diagnostics confirmed: real flood
|
| 102 |
+
events are rare (125 across the whole 2013-2025 record), so under
|
| 103 |
+
plain (or log-space) pinball loss, the sheer VOLUME of quiet-day
|
| 104 |
+
gradient updates dilutes the few real flood-day updates, even
|
| 105 |
+
though pinball loss's own per-sample asymmetry already weights a
|
| 106 |
+
flood-day underprediction more heavily than a quiet-day
|
| 107 |
+
overprediction of the same size -- that per-sample weighting
|
| 108 |
+
doesn't fix a per-EXAMPLE-COUNT imbalance. Direct, measured
|
| 109 |
+
evidence this project already has: --check-quantile-calibration
|
| 110 |
+
showed q0.95 coverage at 0.990 on low-flow days (should be ~0.95)
|
| 111 |
+
and 0.698 on high-flow days (should also be ~0.95) on the SAME
|
| 112 |
+
checkpoint -- opposite-direction miscalibration consistent with a
|
| 113 |
+
model whose learned quantile function is dominated by the
|
| 114 |
+
overwhelmingly more common quiet-day examples.
|
| 115 |
+
|
| 116 |
+
Deliberately restricted to stations that appear in event_lookup at
|
| 117 |
+
all (i.e. have at least one real labeled event) -- every other node
|
| 118 |
+
(virtual/confluence nodes, or real gauges with zero labeled events)
|
| 119 |
+
gets the neutral weight of 1.0 everywhere, without needing any real
|
| 120 |
+
per-example work, since days_to_nearest_real_event would return
|
| 121 |
+
float("inf") for them regardless. This keeps the real cost of
|
| 122 |
+
building this tensor proportional to (stations with events) x
|
| 123 |
+
(real examples) x (real horizons), not the full node count -- with
|
| 124 |
+
~4,500 nodes but only a handful of real gauges ever having labeled
|
| 125 |
+
events, this is a real, large, deliberate savings, not a premature
|
| 126 |
+
optimization.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
station_codes: this run's real, ordered node->station-code list
|
| 130 |
+
(combined["station_codes"]), same ordering Y's node axis
|
| 131 |
+
uses.
|
| 132 |
+
anchor_dates: this run's real anchor dates, same ordering X/Y's
|
| 133 |
+
example axis uses (from prepare_graph_training_windows).
|
| 134 |
+
horizons: this run's real HORIZONS list, same ordering Y's
|
| 135 |
+
horizon axis uses.
|
| 136 |
+
event_lookup: from build_event_lookup(events_df).
|
| 137 |
+
window_days: how many real days from a labeled event's start/
|
| 138 |
+
end the boost still applies -- 5 chosen as a real, moderate
|
| 139 |
+
window (identify_flood_events.py's own docstring notes real
|
| 140 |
+
event durations already run 3-10 days, so this catches the
|
| 141 |
+
real rising/falling limb immediately around a labeled
|
| 142 |
+
event, not just the event's own days).
|
| 143 |
+
boost: the real weight multiplier applied to boosted examples
|
| 144 |
+
(an untouched example keeps weight 1.0). 4.0 chosen as a
|
| 145 |
+
real, moderate value in the 3-5x range this project's own
|
| 146 |
+
discussion landed on -- large enough to meaningfully shift
|
| 147 |
+
gradient share toward flood-adjacent examples, not so large
|
| 148 |
+
it risks destabilizing training the way an aggressive
|
| 149 |
+
reweighting scheme could. Treat as a real, tunable
|
| 150 |
+
hyperparameter, not a fixed constant -- compare against
|
| 151 |
+
boost=1.0 (a no-op, exactly reproducing the unweighted
|
| 152 |
+
loss) as the baseline for any real run using this.
|
| 153 |
+
"""
|
| 154 |
+
n_examples = len(anchor_dates)
|
| 155 |
+
n_nodes = len(station_codes)
|
| 156 |
+
n_horizons = len(horizons)
|
| 157 |
+
weight = np.ones((n_examples, n_nodes, n_horizons), dtype=np.float32)
|
| 158 |
+
|
| 159 |
+
stations_with_events = [code for code in station_codes if event_lookup.get(code)]
|
| 160 |
+
if not stations_with_events:
|
| 161 |
+
return weight
|
| 162 |
+
|
| 163 |
+
for code in stations_with_events:
|
| 164 |
+
node_idx = station_codes.index(code)
|
| 165 |
+
for ex_idx, anchor in enumerate(anchor_dates):
|
| 166 |
+
for h_idx, h in enumerate(horizons):
|
| 167 |
+
target_date = anchor + pd.Timedelta(days=h - 1)
|
| 168 |
+
if days_to_nearest_real_event(code, target_date, event_lookup) <= window_days:
|
| 169 |
+
weight[ex_idx, node_idx, h_idx] = boost
|
| 170 |
+
return weight
|
src/graph/physics_losses.py
CHANGED
|
@@ -801,6 +801,76 @@ def compute_per_node_historical_mean(target_tensor: np.ndarray) -> np.ndarray:
|
|
| 801 |
return means.astype(np.float32)
|
| 802 |
|
| 803 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
def nse_loss(Y_true, Y_pred, per_node_mean, epsilon: float = 1e-6):
|
| 805 |
"""
|
| 806 |
1 - NSE, framed as a loss to minimize. A different signal from
|
|
@@ -851,6 +921,90 @@ def nse_loss(Y_true, Y_pred, per_node_mean, epsilon: float = 1e-6):
|
|
| 851 |
return float(numerator / denominator)
|
| 852 |
|
| 853 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
# ---------------------------------------------------------------------------
|
| 855 |
# 7. Gated spatial smoothness (Dirichlet energy / graph Laplacian) --
|
| 856 |
# only where continuity is actually expected
|
|
|
|
| 801 |
return means.astype(np.float32)
|
| 802 |
|
| 803 |
|
| 804 |
+
def compute_per_node_historical_median(target_tensor: np.ndarray) -> np.ndarray:
|
| 805 |
+
"""
|
| 806 |
+
Per-node MEDIAN of whatever real (non-NaN) observations exist in
|
| 807 |
+
target_tensor -- deliberately separate from
|
| 808 |
+
compute_per_node_historical_mean, not a replacement for it.
|
| 809 |
+
NSE's formula (1 - sum((obs-pred)^2)/sum((obs-mean)^2)) mathematically
|
| 810 |
+
requires the real MEAN specifically -- swapping mean for median
|
| 811 |
+
there would silently stop computing NSE at all. This function
|
| 812 |
+
exists for a genuinely different real use: a robust, real, per-
|
| 813 |
+
node FILL VALUE for prepare_graph_training_windows' missing-value
|
| 814 |
+
handling.
|
| 815 |
+
|
| 816 |
+
Real, confirmed reason median, not mean, is the right choice there:
|
| 817 |
+
a real, direct trace showed the per-node MEAN fill value for one
|
| 818 |
+
real, low-flow station (H605641401) was itself ~2023 L/s -- still
|
| 819 |
+
roughly 30x that station's real low-flow values (real ~57-70 L/s)
|
| 820 |
+
-- because real discharge is right-skewed (rare, real high-flow
|
| 821 |
+
events pull the real mean well above what a "typical" day actually
|
| 822 |
+
looks like). The real median is far more robust to that real skew,
|
| 823 |
+
landing much closer to a station's real, typical day.
|
| 824 |
+
|
| 825 |
+
Nodes with zero real observations anywhere get NaN, not a
|
| 826 |
+
fabricated 0 -- the caller (prepare_graph_training_windows) falls
|
| 827 |
+
back to the original, global 0.0 fill for such nodes, same as
|
| 828 |
+
compute_per_node_historical_mean's own NaN nodes already do.
|
| 829 |
+
"""
|
| 830 |
+
import warnings
|
| 831 |
+
with warnings.catch_warnings():
|
| 832 |
+
warnings.filterwarnings("ignore", message="All-NaN slice encountered")
|
| 833 |
+
medians = np.nanmedian(target_tensor, axis=1)
|
| 834 |
+
return medians.astype(np.float32)
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def compute_per_node_historical_std(target_tensor: np.ndarray, epsilon: float = 1e-3) -> np.ndarray:
|
| 838 |
+
"""
|
| 839 |
+
Per-node std of whatever real (non-NaN) observations exist in
|
| 840 |
+
target_tensor -- same real, training-period-only discipline as
|
| 841 |
+
compute_per_node_historical_mean. Built specifically for real,
|
| 842 |
+
per-station-relative quantile loss normalization: real, direct
|
| 843 |
+
evidence (check_discharge_quantile_distribution.py) showed this
|
| 844 |
+
project's 6 real gauges have WILDLY different real discharge
|
| 845 |
+
scales and skew (Q50/Q01 ranging from 1.4x at one real station to
|
| 846 |
+
literally infinite -- real zero -- at another), so a single,
|
| 847 |
+
GLOBAL discharge_std treats a real, proportionally large error at
|
| 848 |
+
a small station and a real, proportionally tiny error at a large
|
| 849 |
+
station as if they carried the same real weight in the loss, which
|
| 850 |
+
they do not.
|
| 851 |
+
|
| 852 |
+
A real, small floor (epsilon) prevents division by a near-zero std
|
| 853 |
+
for a station with real, genuinely low variability (e.g. the two
|
| 854 |
+
Eure stations found to be nearly flat at low flow) -- without this,
|
| 855 |
+
such a station's ALREADY small real errors would be divided by an
|
| 856 |
+
even smaller real denominator, exploding into a disproportionate,
|
| 857 |
+
destabilizing loss contribution -- the same class of numerical
|
| 858 |
+
risk KGE's real bias-ratio term already showed us directly.
|
| 859 |
+
|
| 860 |
+
Nodes with zero real observations, or only one, get NaN (std is
|
| 861 |
+
undefined from a single point) -- callers must handle this the
|
| 862 |
+
same way nse_loss already handles a NaN per_node_mean.
|
| 863 |
+
"""
|
| 864 |
+
import warnings
|
| 865 |
+
with warnings.catch_warnings():
|
| 866 |
+
warnings.filterwarnings("ignore", message="Degrees of freedom <= 0 for slice")
|
| 867 |
+
stds = np.nanstd(target_tensor, axis=1)
|
| 868 |
+
stds = np.where(stds < epsilon, epsilon, stds)
|
| 869 |
+
n_real_per_node = np.sum(~np.isnan(target_tensor), axis=1)
|
| 870 |
+
stds = np.where(n_real_per_node < 2, np.nan, stds)
|
| 871 |
+
return stds.astype(np.float32)
|
| 872 |
+
|
| 873 |
+
|
| 874 |
def nse_loss(Y_true, Y_pred, per_node_mean, epsilon: float = 1e-6):
|
| 875 |
"""
|
| 876 |
1 - NSE, framed as a loss to minimize. A different signal from
|
|
|
|
| 921 |
return float(numerator / denominator)
|
| 922 |
|
| 923 |
|
| 924 |
+
def kge_loss_from_pooled_pairs(obs, pred, epsilon: float = 1e-6, max_loss: float = 20.0):
|
| 925 |
+
"""
|
| 926 |
+
1 - KGE (Kling-Gupta Efficiency), framed as a loss to minimize --
|
| 927 |
+
a real, temporally-meaningful replacement for nse_loss, not a
|
| 928 |
+
cross-station approximation. KGE's correlation term (r) needs
|
| 929 |
+
multiple real, PAIRED (observed, predicted) points for the SAME
|
| 930 |
+
node to mean anything -- unlike nse_loss's formula, which only
|
| 931 |
+
needs a node's own pre-computed historical mean and works fine
|
| 932 |
+
within a single, sparse per-example training step, a real
|
| 933 |
+
correlation genuinely requires several real points. This function
|
| 934 |
+
expects the CALLER to have already gathered those real pairs for
|
| 935 |
+
one node, pooled across multiple real anchor dates within a batch
|
| 936 |
+
(see the real pooling logic in train_spatiotemporal_gnn.py's batch
|
| 937 |
+
loop) -- it does not gather or mask NaN itself, unlike every other
|
| 938 |
+
loss in this file, because by the time this function is called,
|
| 939 |
+
only real, already-matched pairs should remain.
|
| 940 |
+
|
| 941 |
+
KGE = 1 - sqrt((r-1)^2 + (alpha-1)^2 + (beta-1)^2), where:
|
| 942 |
+
r = real Pearson correlation between obs and pred (timing)
|
| 943 |
+
alpha = std(pred) / std(obs) (variability ratio)
|
| 944 |
+
beta = mean(pred) / mean(obs) (bias ratio)
|
| 945 |
+
|
| 946 |
+
CRITICAL, CONFIRMED REQUIREMENT: obs/pred MUST be in real,
|
| 947 |
+
UN-STANDARDIZED physical units (real L/s, real cm) here, NOT
|
| 948 |
+
standardized (z-scored) values -- confirmed directly as a real,
|
| 949 |
+
structural bug, not a rare edge case: on realistic standardized-
|
| 950 |
+
space-like pooled samples (mean~0, std~1, exactly what a small,
|
| 951 |
+
sparse real pool of z-scored values looks like), beta = pred_mean
|
| 952 |
+
/ obs_mean exploded to a worst-case loss of 8,260 across 1000
|
| 953 |
+
trials, with 4% exceeding loss=10 -- versus a worst case of 4.07,
|
| 954 |
+
zero trials over 10, on realistic REAL, physical-unit discharge
|
| 955 |
+
values. Standardization deliberately centers data at mean~0, which
|
| 956 |
+
is exactly the condition that makes a mean-ratio term numerically
|
| 957 |
+
unstable; real, physical units don't have that problem, since a
|
| 958 |
+
real station's real discharge is never centered near zero. The
|
| 959 |
+
caller (train_spatiotemporal_gnn.py) un-standardizes before pooling
|
| 960 |
+
for exactly this reason -- do not call this on standardized values.
|
| 961 |
+
|
| 962 |
+
Args:
|
| 963 |
+
obs, pred: 1-D, same length, already real (no NaN), in REAL,
|
| 964 |
+
UN-STANDARDIZED physical units -- every real (observed,
|
| 965 |
+
predicted) pair for ONE node, pooled across however many
|
| 966 |
+
real anchor dates/horizons contributed a real observation
|
| 967 |
+
within the current batch.
|
| 968 |
+
epsilon: prevents division by zero when a node's real pooled
|
| 969 |
+
std or mean is at or near zero.
|
| 970 |
+
max_loss: a real, measured defensive ceiling, not a guess --
|
| 971 |
+
even in real, physical units, a small pooled sample
|
| 972 |
+
combined with a genuinely bad prediction can still produce
|
| 973 |
+
a real, large loss (measured worst case 28.92 across 3000
|
| 974 |
+
trials including deliberately bad predictions, with only
|
| 975 |
+
0.5% exceeding 20) -- this is a second, independent layer
|
| 976 |
+
of protection on top of using real units, not a substitute
|
| 977 |
+
for it.
|
| 978 |
+
|
| 979 |
+
Returns:
|
| 980 |
+
Scalar loss, clamped to [0, max_loss]. Callers should require
|
| 981 |
+
at least ~3-5 real pooled points before calling this (checked
|
| 982 |
+
by the caller, not here) -- a correlation from 2 points is not
|
| 983 |
+
real evidence of timing skill, it is guaranteed to be +-1 or
|
| 984 |
+
undefined.
|
| 985 |
+
"""
|
| 986 |
+
is_torch = _HAS_TORCH and isinstance(obs, torch.Tensor)
|
| 987 |
+
|
| 988 |
+
if is_torch:
|
| 989 |
+
obs_mean, pred_mean = obs.mean(), pred.mean()
|
| 990 |
+
obs_std, pred_std = obs.std(unbiased=False), pred.std(unbiased=False)
|
| 991 |
+
cov = ((obs - obs_mean) * (pred - pred_mean)).mean()
|
| 992 |
+
r = cov / (obs_std * pred_std + epsilon)
|
| 993 |
+
alpha = pred_std / (obs_std + epsilon)
|
| 994 |
+
beta = pred_mean / (obs_mean + epsilon)
|
| 995 |
+
kge = 1.0 - torch.sqrt((r - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2)
|
| 996 |
+
return torch.clamp(1.0 - kge, min=0.0, max=max_loss)
|
| 997 |
+
|
| 998 |
+
obs_mean, pred_mean = obs.mean(), pred.mean()
|
| 999 |
+
obs_std, pred_std = obs.std(), pred.std()
|
| 1000 |
+
cov = ((obs - obs_mean) * (pred - pred_mean)).mean()
|
| 1001 |
+
r = cov / (obs_std * pred_std + epsilon)
|
| 1002 |
+
alpha = pred_std / (obs_std + epsilon)
|
| 1003 |
+
beta = pred_mean / (obs_mean + epsilon)
|
| 1004 |
+
kge = 1.0 - np.sqrt((r - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2)
|
| 1005 |
+
return float(np.clip(1.0 - kge, 0.0, max_loss))
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
# ---------------------------------------------------------------------------
|
| 1009 |
# 7. Gated spatial smoothness (Dirichlet energy / graph Laplacian) --
|
| 1010 |
# only where continuity is actually expected
|
src/graph/pinball_loss.py
CHANGED
|
@@ -17,7 +17,7 @@ amount, which is exactly the incentive missing from MSE: a model
|
|
| 17 |
trained with this loss is pushed toward actually covering the real
|
| 18 |
tail, not toward the safe average.
|
| 19 |
"""
|
| 20 |
-
from typing import List
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
|
|
@@ -88,50 +88,276 @@ def multi_quantile_loss(y_true, y_pred_quantiles, quantiles: List[float]):
|
|
| 88 |
return total / n_real_terms
|
| 89 |
|
| 90 |
|
| 91 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
-
Same real pinball-loss
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
Args:
|
| 118 |
y_true, y_pred_quantiles, quantiles: same as multi_quantile_loss.
|
| 119 |
-
|
| 120 |
-
leading node dimension)
|
| 121 |
-
|
| 122 |
-
computed
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
recency_weight is passed pre-built, this argument has no
|
| 129 |
-
further effect and is not used inside this function.
|
| 130 |
"""
|
| 131 |
is_torch = _HAS_TORCH and isinstance(y_true, torch.Tensor)
|
| 132 |
-
weight_bcast =
|
| 133 |
-
(
|
| 134 |
-
|
| 135 |
|
| 136 |
total = torch.zeros((), dtype=y_true.dtype, device=y_true.device) if is_torch \
|
| 137 |
else np.zeros((), dtype=y_true.dtype)
|
|
|
|
| 17 |
trained with this loss is pushed toward actually covering the real
|
| 18 |
tail, not toward the safe average.
|
| 19 |
"""
|
| 20 |
+
from typing import List, Optional
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
|
|
|
|
| 88 |
return total / n_real_terms
|
| 89 |
|
| 90 |
|
| 91 |
+
def log_space_multi_quantile_loss(
|
| 92 |
+
y_true_standardized, y_pred_quantiles_standardized, quantiles: List[float],
|
| 93 |
+
real_mean: float, real_std: float, log_offset: Optional[float] = None,
|
| 94 |
+
sample_weight=None,
|
| 95 |
+
):
|
| 96 |
"""
|
| 97 |
+
Same real pinball-loss mechanism as multi_quantile_loss, but
|
| 98 |
+
computed in log(real discharge + log_offset) space instead of
|
| 99 |
+
standardized (z-scored) space -- built specifically to address a
|
| 100 |
+
real, measured problem: real evaluation showed high-flow PBIAS at
|
| 101 |
+
25.0% vs. -1.8% at low flow for one real station, confirmed as
|
| 102 |
+
peak-specific, not a general low-flow issue. In real, linear
|
| 103 |
+
units, a large absolute error during a real 30,000 L/s peak swamps
|
| 104 |
+
a proportionally IDENTICAL relative error during a real 500 L/s
|
| 105 |
+
baseline day, even under pinball loss's linear (not squared)
|
| 106 |
+
penalty -- log-space compresses that gap so a given real
|
| 107 |
+
PROPORTIONAL error contributes comparable loss regardless of
|
| 108 |
+
whether it happens at low or high flow, directly targeting the
|
| 109 |
+
confirmed peak-specific bias.
|
| 110 |
+
|
| 111 |
+
log_offset MATTERS, CONFIRMED DIRECTLY: the original log1p (offset
|
| 112 |
+
fixed at 1.0) turned out to weight a low-flow error ~28x more than
|
| 113 |
+
an identical high-flow error (measured on real station-scale
|
| 114 |
+
values), and a real training run with that offset produced a real,
|
| 115 |
+
severe regression -- low-flow NSE collapsed to -116.7, PBIAS to
|
| 116 |
+
-102.7%, at the same real station whose high-flow bias this was
|
| 117 |
+
built to fix. A larger, real, data-derived offset softens that
|
| 118 |
+
ratio (measured directly: 0.25*discharge_std -> ~10x, not
|
| 119 |
+
eliminating the low-flow priority, but no longer as extreme).
|
| 120 |
+
log_offset defaults to None, which resolves to 0.25*real_std here
|
| 121 |
+
-- a real, tested starting point, not the original, now-known-
|
| 122 |
+
risky offset=1.0 default. HONEST CAVEAT: this addresses one real,
|
| 123 |
+
understood mechanism (log-space's near-zero sensitivity) -- it
|
| 124 |
+
does NOT fully explain why kge_loss_from_pooled_pairs, a genuinely
|
| 125 |
+
different mechanism, produced a similar real low-flow regression
|
| 126 |
+
independently. Treat this as a real, testable improvement, not a
|
| 127 |
+
confirmed, complete fix, until re-evaluated the same
|
| 128 |
+
stratified-low/high-flow way the original regression was found.
|
| 129 |
+
|
| 130 |
+
SCOPED TO THIS FUNCTION ONLY -- deliberately not a project-wide
|
| 131 |
+
change. confluence_mass_balance_loss and water_balance_loss need
|
| 132 |
+
real, ADDITIVE, linear-space discharge (Q_downstream ~=
|
| 133 |
+
sum(Q_upstream)); log(a)+log(b) != log(a+b), so log-transforming
|
| 134 |
+
discharge throughout this project would break the physical meaning
|
| 135 |
+
of every real mass-conservation constraint already built and
|
| 136 |
+
validated. Everything else keeps operating on real, linear
|
| 137 |
+
discharge exactly as before; only the supervised term changes.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
y_true_standardized: real target, in the SAME standardized
|
| 141 |
+
(z-scored) space the model already operates in -- this
|
| 142 |
+
function does the un-standardization internally, the
|
| 143 |
+
caller doesn't need to pass real, un-standardized values.
|
| 144 |
+
y_pred_quantiles_standardized: [..., n_quantiles], same
|
| 145 |
+
standardized space.
|
| 146 |
+
quantiles: quantile levels, e.g. [0.5, 0.9, 0.95, 0.99].
|
| 147 |
+
real_mean, real_std: this run's real discharge_mean/
|
| 148 |
+
discharge_std (from standardize_dynamic_tensors, fit on
|
| 149 |
+
the real training period only) -- needed to recover real,
|
| 150 |
+
physical L/s values before the log transform, since log
|
| 151 |
+
has no valid meaning applied directly to a z-score.
|
| 152 |
+
log_offset: the real "c" in log(x + c). None resolves to
|
| 153 |
+
0.25*real_std. Larger values soften low-flow sensitivity
|
| 154 |
+
(real, measured trade-off, not a free parameter -- see
|
| 155 |
+
this docstring's own measured ratios).
|
| 156 |
+
sample_weight: optional, same meaning as
|
| 157 |
+
weighted_multi_quantile_loss's own sample_weight -- e.g.
|
| 158 |
+
train_spatiotemporal_gnn.py's --event-reweight combines
|
| 159 |
+
with log-space loss by passing the same event-proximity
|
| 160 |
+
weight tensor through here. None (the default) reproduces
|
| 161 |
+
the original, unweighted behavior exactly.
|
| 162 |
+
"""
|
| 163 |
+
is_torch = _HAS_TORCH and isinstance(y_true_standardized, torch.Tensor)
|
| 164 |
+
if log_offset is None:
|
| 165 |
+
log_offset = 0.25 * real_std
|
| 166 |
+
|
| 167 |
+
y_true_real = y_true_standardized * real_std + real_mean
|
| 168 |
+
y_pred_real = y_pred_quantiles_standardized * real_std + real_mean
|
| 169 |
+
|
| 170 |
+
# Real discharge is never negative; log is undefined at/below
|
| 171 |
+
# -log_offset. This clamp is NOT the sole force keeping predictions
|
| 172 |
+
# non-negative the way Manning's n's old hard clamp was (a single,
|
| 173 |
+
# shared, permanently-reused scalar that could get stuck outside
|
| 174 |
+
# its range) -- the pinball loss itself, even computed in log-
|
| 175 |
+
# space, naturally pulls every real prediction toward real, non-
|
| 176 |
+
# negative observed discharge, and each (node, horizon, quantile)
|
| 177 |
+
# value here comes from an independent forward pass, so an
|
| 178 |
+
# occasional real clamp doesn't create the same permanent-zero-
|
| 179 |
+
# gradient trap.
|
| 180 |
+
if is_torch:
|
| 181 |
+
y_true_log = torch.log(torch.clamp(y_true_real, min=0.0) + log_offset)
|
| 182 |
+
y_pred_log = torch.log(torch.clamp(y_pred_real, min=0.0) + log_offset)
|
| 183 |
+
else:
|
| 184 |
+
y_true_log = np.log(np.clip(y_true_real, 0.0, None) + log_offset)
|
| 185 |
+
y_pred_log = np.log(np.clip(y_pred_real, 0.0, None) + log_offset)
|
| 186 |
+
|
| 187 |
+
if sample_weight is None:
|
| 188 |
+
return multi_quantile_loss(y_true_log, y_pred_log, quantiles)
|
| 189 |
+
return weighted_multi_quantile_loss(y_true_log, y_pred_log, quantiles, sample_weight)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def per_station_relative_multi_quantile_loss(
|
| 193 |
+
y_true_standardized, y_pred_quantiles_standardized, quantiles: List[float],
|
| 194 |
+
real_mean: float, real_std: float, per_node_std,
|
| 195 |
+
sample_weight=None,
|
| 196 |
+
):
|
| 197 |
+
"""
|
| 198 |
+
Same real pinball-loss mechanism as multi_quantile_loss, but
|
| 199 |
+
normalizes each real node's real discharge by THAT NODE's OWN real
|
| 200 |
+
historical std before computing pinball loss, instead of the
|
| 201 |
+
single, GLOBAL discharge_std every other version of this loss uses.
|
| 202 |
+
|
| 203 |
+
Built after real, direct evidence
|
| 204 |
+
(check_discharge_quantile_distribution.py) showed this project's 6
|
| 205 |
+
real gauges have WILDLY different real scale and skew -- Q50/Q01
|
| 206 |
+
ranged from 1.4x at one real station (nearly flat at low flow) to
|
| 207 |
+
literally infinite (real zero) at another, and Q99/Q50 ranged from
|
| 208 |
+
4.3x to 12.0x. Under the existing GLOBAL standardization, a real,
|
| 209 |
+
proportionally large error at a small station (e.g. real Q50~868
|
| 210 |
+
L/s) contributes a much smaller loss than the identical real
|
| 211 |
+
PROPORTIONAL error at a large station (e.g. real Q50~9323 L/s),
|
| 212 |
+
simply because both get divided by the same global discharge_std
|
| 213 |
+
-- the loss implicitly favors getting the large stations right,
|
| 214 |
+
not a deliberate, reasoned choice.
|
| 215 |
+
|
| 216 |
+
Dividing by each node's own real per_node_std puts every real
|
| 217 |
+
station's discharge into a comparable, station-relative unit --
|
| 218 |
+
a real error that's proportionally severe for a given station
|
| 219 |
+
contributes comparable loss regardless of whether that station
|
| 220 |
+
is large or small.
|
| 221 |
+
|
| 222 |
+
SCOPED TO THIS FUNCTION ONLY -- deliberately not a project-wide
|
| 223 |
+
change, same discipline as log_space_multi_quantile_loss.
|
| 224 |
+
confluence_mass_balance_loss and water_balance_loss need real,
|
| 225 |
+
ADDITIVE, linear-space discharge in the ORIGINAL global scale
|
| 226 |
+
(Q_downstream ~= sum(Q_upstream)); per-node-relative values aren't
|
| 227 |
+
additive across nodes the way real physical discharge is, so this
|
| 228 |
+
transform would break the physical meaning of every real
|
| 229 |
+
mass-conservation constraint if applied there. Everything else
|
| 230 |
+
keeps operating on real, linear, globally-standardized discharge
|
| 231 |
+
exactly as before; only this one supervised term changes.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
y_true_standardized: real target, standardized (z-scored)
|
| 235 |
+
space, [n_nodes, n_horizons] or similar -- last dim
|
| 236 |
+
broadcasts against per_node_std via its own leading node
|
| 237 |
+
dimension.
|
| 238 |
+
y_pred_quantiles_standardized: [..., n_quantiles], same
|
| 239 |
+
standardized space, same leading node dimension.
|
| 240 |
+
quantiles: quantile levels, e.g. [0.5, 0.9, 0.95, 0.99].
|
| 241 |
+
real_mean, real_std: this run's real, GLOBAL discharge_mean/
|
| 242 |
+
discharge_std (from standardize_dynamic_tensors) -- needed
|
| 243 |
+
to recover real, physical L/s values first.
|
| 244 |
+
per_node_std: [n_nodes] real, per-node historical std (from
|
| 245 |
+
compute_per_node_historical_std, fit on the training
|
| 246 |
+
period only) -- real NaN for any node with fewer than 2
|
| 247 |
+
real training-period observations, which correctly
|
| 248 |
+
propagates to NaN loss for that node's terms, excluded by
|
| 249 |
+
multi_quantile_loss's own real NaN-masking, the same way a
|
| 250 |
+
NaN per_node_mean already gets handled for NSE.
|
| 251 |
+
sample_weight: optional, same meaning as
|
| 252 |
+
weighted_multi_quantile_loss's own sample_weight. None (the
|
| 253 |
+
default) reproduces the original, unweighted behavior
|
| 254 |
+
exactly.
|
| 255 |
+
"""
|
| 256 |
+
is_torch = _HAS_TORCH and isinstance(y_true_standardized, torch.Tensor)
|
| 257 |
+
|
| 258 |
+
y_true_real = y_true_standardized * real_std + real_mean
|
| 259 |
+
y_pred_real = y_pred_quantiles_standardized * real_std + real_mean
|
| 260 |
+
|
| 261 |
+
# Broadcast per_node_std ([n_nodes]) against y_true_real's
|
| 262 |
+
# [n_nodes, n_horizons, ...] shape -- reshape to add trailing
|
| 263 |
+
# singleton dims matching however many extra dims y_true_real has
|
| 264 |
+
# beyond the leading node dimension.
|
| 265 |
+
#
|
| 266 |
+
# CRITICAL, REAL, CONFIRMED FIX: replace NaN entries in per_node_std
|
| 267 |
+
# (real, ungauged/virtual nodes with no real training-period
|
| 268 |
+
# discharge at all -- compute_per_node_historical_std correctly
|
| 269 |
+
# returns NaN for these) with a safe, finite placeholder BEFORE the
|
| 270 |
+
# division, not after. A real training run confirmed this is NOT
|
| 271 |
+
# optional: dividing by a real NaN produces a NaN y_pred_relative at
|
| 272 |
+
# those real node positions, and even though multi_quantile_loss's
|
| 273 |
+
# own real masking correctly excludes those NaN positions from the
|
| 274 |
+
# FORWARD loss sum, PyTorch's backward pass still computes
|
| 275 |
+
# d(NaN)/d(input) for every real parameter that touched that
|
| 276 |
+
# division -- NaN * 0 is NaN, not 0, in gradient computation, a
|
| 277 |
+
# real, well-known autograd pitfall. Confirmed directly: a real
|
| 278 |
+
# training run showed EVERY SINGLE model parameter with a real NaN
|
| 279 |
+
# gradient from epoch 1, batch 1 onward, tracing back to exactly
|
| 280 |
+
# this division. The placeholder value itself doesn't matter for
|
| 281 |
+
# correctness (any finite, positive number works, since the
|
| 282 |
+
# corresponding real Y_true is ALSO NaN at these same node
|
| 283 |
+
# positions and gets excluded from the forward sum regardless) --
|
| 284 |
+
# 1.0 is used here for simplicity and because it can never itself
|
| 285 |
+
# be zero.
|
| 286 |
+
if is_torch:
|
| 287 |
+
safe_per_node_std = torch.where(torch.isnan(per_node_std), torch.ones_like(per_node_std), per_node_std)
|
| 288 |
+
else:
|
| 289 |
+
safe_per_node_std = np.where(np.isnan(per_node_std), 1.0, per_node_std)
|
| 290 |
+
|
| 291 |
+
if is_torch:
|
| 292 |
+
true_extra_dims = y_true_real.dim() - 1
|
| 293 |
+
pred_extra_dims = y_pred_real.dim() - 1
|
| 294 |
+
node_std_true = safe_per_node_std.reshape(-1, *([1] * true_extra_dims))
|
| 295 |
+
node_std_pred = safe_per_node_std.reshape(-1, *([1] * pred_extra_dims))
|
| 296 |
+
else:
|
| 297 |
+
true_extra_dims = y_true_real.ndim - 1
|
| 298 |
+
pred_extra_dims = y_pred_real.ndim - 1
|
| 299 |
+
node_std_true = safe_per_node_std.reshape(-1, *([1] * true_extra_dims))
|
| 300 |
+
node_std_pred = safe_per_node_std.reshape(-1, *([1] * pred_extra_dims))
|
| 301 |
+
|
| 302 |
+
y_true_relative = y_true_real / node_std_true
|
| 303 |
+
y_pred_relative = y_pred_real / node_std_pred
|
| 304 |
+
|
| 305 |
+
if sample_weight is None:
|
| 306 |
+
return multi_quantile_loss(y_true_relative, y_pred_relative, quantiles)
|
| 307 |
+
return weighted_multi_quantile_loss(y_true_relative, y_pred_relative, quantiles, sample_weight)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def weighted_multi_quantile_loss(y_true, y_pred_quantiles, quantiles: List[float], sample_weight, alpha: float = 1.0):
|
| 311 |
+
"""
|
| 312 |
+
Same pinball-loss sum as multi_quantile_loss, but each element is
|
| 313 |
+
additionally weighted before averaging -- a generic weighted
|
| 314 |
+
pinball average, not tied to any one specific weighting scheme.
|
| 315 |
+
Two real, distinct uses in this project so far:
|
| 316 |
+
|
| 317 |
+
(1) Recency weighting (the original use this was built for): sample_weight
|
| 318 |
+
= 1 + alpha*log(1+dt) per node, forcing more loss-weight onto
|
| 319 |
+
observations following a longer gap in that node's own
|
| 320 |
+
history. Overlap warning: a days-since-last-real-observation
|
| 321 |
+
channel is already wired in as a direct input feature
|
| 322 |
+
(dynamic_features.py's compute_days_since_last_real /
|
| 323 |
+
compress_days_since_last_real), which lets the model learn how
|
| 324 |
+
to use staleness itself. This function instead forces more
|
| 325 |
+
loss-weight onto post-gap examples regardless of whether the
|
| 326 |
+
model finds that useful -- two different mechanisms carrying
|
| 327 |
+
the same underlying signal, not guaranteed to be aligned.
|
| 328 |
+
|
| 329 |
+
(2) Event-proximity weighting (train_spatiotemporal_gnn.py's
|
| 330 |
+
--event-reweight): sample_weight = build_event_weight_tensor's
|
| 331 |
+
output, boosting examples near a real labeled flood event.
|
| 332 |
+
Built to directly counteract real, measured sample-count
|
| 333 |
+
dilution -- real flood events are rare (125 across the whole
|
| 334 |
+
2013-2025 record), so the sheer VOLUME of quiet-day gradient
|
| 335 |
+
updates dominates training even though pinball loss's own
|
| 336 |
+
per-sample asymmetry already weights a flood-day
|
| 337 |
+
underprediction more heavily than a same-size quiet-day
|
| 338 |
+
overprediction; that per-sample weighting doesn't fix a
|
| 339 |
+
per-example-COUNT imbalance the way this does.
|
| 340 |
+
|
| 341 |
+
Treat sample_weight of all-1.0 (a no-op, exactly reproducing
|
| 342 |
+
multi_quantile_loss) as the baseline to compare any real weighting
|
| 343 |
+
scheme against, rather than assuming either one helps by default.
|
| 344 |
|
| 345 |
Args:
|
| 346 |
y_true, y_pred_quantiles, quantiles: same as multi_quantile_loss.
|
| 347 |
+
sample_weight: either [n_nodes] (broadcast against y_true's
|
| 348 |
+
leading node dimension) or full-shaped
|
| 349 |
+
(same shape as y_true, e.g. [n_nodes, n_horizons]) --
|
| 350 |
+
already-computed per-element weights.
|
| 351 |
+
alpha: scaling on the log(1+dt) term for the recency-weighting
|
| 352 |
+
use case, applied by the caller when building that
|
| 353 |
+
sample_weight -- kept here only for direct-call
|
| 354 |
+
convenience; has no effect if sample_weight is pre-built
|
| 355 |
+
(as it always is for event-proximity weighting).
|
|
|
|
|
|
|
| 356 |
"""
|
| 357 |
is_torch = _HAS_TORCH and isinstance(y_true, torch.Tensor)
|
| 358 |
+
weight_bcast = sample_weight if (hasattr(sample_weight, "ndim") and sample_weight.ndim == y_true.ndim) else \
|
| 359 |
+
(sample_weight.view(-1, *([1] * (y_true.ndim - 1))) if is_torch else
|
| 360 |
+
sample_weight.reshape(-1, *([1] * (y_true.ndim - 1))))
|
| 361 |
|
| 362 |
total = torch.zeros((), dtype=y_true.dtype, device=y_true.device) if is_torch \
|
| 363 |
else np.zeros((), dtype=y_true.dtype)
|
src/graph/spatiotemporal_gnn.py
CHANGED
|
@@ -49,6 +49,8 @@ def prepare_graph_training_windows(
|
|
| 49 |
x_static: np.ndarray, dynamic_tensors: Dict[str, np.ndarray], target_tensor: np.ndarray,
|
| 50 |
dates: List[pd.Timestamp], lookback_days: int, horizons: List[int],
|
| 51 |
dt_tensors: Optional[Dict[str, np.ndarray]] = None,
|
|
|
|
|
|
|
| 52 |
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[pd.Timestamp]]:
|
| 53 |
"""
|
| 54 |
Builds graph-wide training windows -- genuinely different from
|
|
@@ -70,6 +72,66 @@ def prepare_graph_training_windows(
|
|
| 70 |
to fill the gap physically, rather than the raw input pretending
|
| 71 |
a filled zero is a real observation.
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
dt_tensors, when provided, adds a THIRD channel per variable: real,
|
| 74 |
log-compressed days-since-last-real-observation (see
|
| 75 |
dynamic_features.py's compute_days_since_last_real). A binary
|
|
@@ -130,7 +192,44 @@ def prepare_graph_training_windows(
|
|
| 130 |
for var in var_names:
|
| 131 |
raw_window = dynamic_tensors[var][:, t - lookback_days:t] # [n_nodes, lookback]
|
| 132 |
missing = np.isnan(raw_window)
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
window_channels.append(filled)
|
| 135 |
window_channels.append(missing.astype(np.float32))
|
| 136 |
if dt_tensors is not None:
|
|
@@ -275,14 +374,70 @@ if _HAS_TORCH:
|
|
| 275 |
return shared_embed + gauge_embed
|
| 276 |
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
class SpatiotemporalGNN(nn.Module):
|
| 279 |
"""
|
| 280 |
Full model: type-aware input encoding (see TypeAwareInputEncoder
|
| 281 |
above), two RiverMessagePassing layers per timestep (two hops --
|
| 282 |
lets a node see two edges' worth of upstream influence per step,
|
| 283 |
-
e.g. a confluence's own upstream confluence), a per-node
|
| 284 |
-
across the lookback window
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
| 286 |
"""
|
| 287 |
|
| 288 |
def __init__(
|
|
@@ -290,6 +445,7 @@ if _HAS_TORCH:
|
|
| 290 |
edge_dim: int = 5, embed_dim: int = 32, hidden_dim: int = 64, gru_hidden: int = 64,
|
| 291 |
n_basins: int = 2, basin_embed_dim: int = 8, horizon_embed_dim: int = 8, n_targets: int = 2,
|
| 292 |
quantiles: Optional[List[float]] = None, forecast_embed_dim: int = 8,
|
|
|
|
| 293 |
):
|
| 294 |
super().__init__()
|
| 295 |
self.quantiles = quantiles or [0.5, 0.9, 0.95, 0.99]
|
|
@@ -299,7 +455,23 @@ if _HAS_TORCH:
|
|
| 299 |
)
|
| 300 |
self.spatial1 = RiverMessagePassing(embed_dim, hidden_dim, edge_dim)
|
| 301 |
self.spatial2 = RiverMessagePassing(hidden_dim, hidden_dim, edge_dim)
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
self.basin_embed = nn.Embedding(n_basins, basin_embed_dim)
|
| 304 |
self.horizon_embed = nn.Linear(1, horizon_embed_dim)
|
| 305 |
# Real forecasted precipitation, per node per horizon -- NOT
|
|
@@ -399,8 +571,21 @@ if _HAS_TORCH:
|
|
| 399 |
embeddings.append(h)
|
| 400 |
seq = torch.stack(embeddings, dim=1) # [n_nodes, lookback, hidden_dim]
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
basin_feat = self.basin_embed(basin_id) # [n_nodes, basin_embed_dim]
|
| 406 |
|
|
|
|
| 49 |
x_static: np.ndarray, dynamic_tensors: Dict[str, np.ndarray], target_tensor: np.ndarray,
|
| 50 |
dates: List[pd.Timestamp], lookback_days: int, horizons: List[int],
|
| 51 |
dt_tensors: Optional[Dict[str, np.ndarray]] = None,
|
| 52 |
+
per_node_fill_values: Optional[Dict[str, np.ndarray]] = None,
|
| 53 |
+
forward_fill_tensors: Optional[Dict[str, np.ndarray]] = None,
|
| 54 |
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[pd.Timestamp]]:
|
| 55 |
"""
|
| 56 |
Builds graph-wide training windows -- genuinely different from
|
|
|
|
| 72 |
to fill the gap physically, rather than the raw input pretending
|
| 73 |
a filled zero is a real observation.
|
| 74 |
|
| 75 |
+
CRITICAL, CONFIRMED FIX -- per_node_fill_values: plain 0.0 is
|
| 76 |
+
standardized using the GLOBAL mean/std across every real gauge
|
| 77 |
+
together, so it's only genuinely "neutral" for a node whose own
|
| 78 |
+
real scale happens to sit near that global average. Confirmed
|
| 79 |
+
directly as a real, severe bug, not a theoretical concern: for one
|
| 80 |
+
real, small station (real Q50~983 L/s, far below the real global
|
| 81 |
+
discharge_mean~4168.71), a real data gap filled the model's input
|
| 82 |
+
with a value ~60x larger than that station ever actually sees --
|
| 83 |
+
traced directly via a real false-spike investigation, and this
|
| 84 |
+
exact real station showed the worst real low-flow PBIAS measured
|
| 85 |
+
all project (-240.1%). The missingness flag alone evidently wasn't
|
| 86 |
+
enough for the model to fully discount a fill value that large and
|
| 87 |
+
misleading. per_node_fill_values, when provided, gives each real
|
| 88 |
+
node its OWN real historical mean (in the same global-standardized
|
| 89 |
+
units the rest of the tensor is already in, via
|
| 90 |
+
compute_per_node_historical_mean applied to the ALREADY-
|
| 91 |
+
standardized tensor -- fit on the real training period only, same
|
| 92 |
+
leakage discipline as discharge_mean/std themselves) as its fill
|
| 93 |
+
value instead of the single, global 0.0 -- genuinely neutral for
|
| 94 |
+
THAT node's own real typical range, not the whole network's.
|
| 95 |
+
A real node with no real training-period observations at all
|
| 96 |
+
(a true virtual/confluence node, or per_node_fill_values not
|
| 97 |
+
provided) correctly falls back to the original global 0.0 -- there
|
| 98 |
+
is no real, per-node information to prefer for such a node.
|
| 99 |
+
|
| 100 |
+
forward_fill_tensors, when provided, takes priority over
|
| 101 |
+
per_node_fill_values as the fill source for a missing timestep.
|
| 102 |
+
CRITICAL, CONFIRMED FIX #2 -- per_node_fill_values (a station-wide
|
| 103 |
+
mean or median) is only a genuinely neutral fill for a node's
|
| 104 |
+
TYPICAL day; it is the wrong reference point for a gap that falls
|
| 105 |
+
in the middle of a real, ongoing low- (or high-) flow episode.
|
| 106 |
+
Confirmed directly: even a real per-node MEDIAN fill (1142.0, for
|
| 107 |
+
station H605641401) was ~17x that station's real low-flow values
|
| 108 |
+
(57-70 L/s) during a real gap inside a real low-flow episode, and
|
| 109 |
+
a direct causal ablation (zeroing the missingness flag and
|
| 110 |
+
days-since channel for this exact example) confirmed the model
|
| 111 |
+
could not fully compensate: predicted Q0.95 was still ~28x the
|
| 112 |
+
real observed value even with no "this was filled" signal reaching
|
| 113 |
+
it at all, because the filled VALUE itself was implausible for the
|
| 114 |
+
real local conditions at that moment -- not a flag-weighting
|
| 115 |
+
problem. forward_fill_tensors (see dynamic_features.py's
|
| 116 |
+
compute_forward_filled_tensor) instead carries the real,
|
| 117 |
+
most-recently-known value at that specific node forward, which is
|
| 118 |
+
directly informative about real LOCAL, CURRENT conditions in a way
|
| 119 |
+
a station-wide statistic structurally cannot be. Needs no
|
| 120 |
+
train-only-fit discipline (unlike per_node_fill_values) since
|
| 121 |
+
forward-fill only ever looks backward in time -- no leakage is
|
| 122 |
+
possible by construction.
|
| 123 |
+
|
| 124 |
+
Fill priority per missing timestep, per variable:
|
| 125 |
+
1. forward_fill_tensors[var] at that [node, date] position, if
|
| 126 |
+
provided AND real (i.e. some real observation has occurred at
|
| 127 |
+
that node before that date).
|
| 128 |
+
2. per_node_fill_values[var] for that node, if provided AND real
|
| 129 |
+
(station-wide fallback when there's no real prior observation
|
| 130 |
+
yet for forward-fill to carry forward -- e.g. the very start
|
| 131 |
+
of a node's real record).
|
| 132 |
+
3. global 0.0 (original fallback, for a node with no real
|
| 133 |
+
information at all from either source).
|
| 134 |
+
|
| 135 |
dt_tensors, when provided, adds a THIRD channel per variable: real,
|
| 136 |
log-compressed days-since-last-real-observation (see
|
| 137 |
dynamic_features.py's compute_days_since_last_real). A binary
|
|
|
|
| 192 |
for var in var_names:
|
| 193 |
raw_window = dynamic_tensors[var][:, t - lookback_days:t] # [n_nodes, lookback]
|
| 194 |
missing = np.isnan(raw_window)
|
| 195 |
+
|
| 196 |
+
# Fill priority: forward_fill_tensors (real, local,
|
| 197 |
+
# most-recent value at THIS node) > per_node_fill_values
|
| 198 |
+
# (station-wide mean/median, only when forward-fill has no
|
| 199 |
+
# real prior observation yet to carry) > global 0.0 (no
|
| 200 |
+
# real information at all). See this function's docstring
|
| 201 |
+
# ("CRITICAL, CONFIRMED FIX #2") for why forward-fill takes
|
| 202 |
+
# priority over the station-wide statistic.
|
| 203 |
+
if forward_fill_tensors is not None and var in forward_fill_tensors:
|
| 204 |
+
ff_window = forward_fill_tensors[var][:, t - lookback_days:t] # [n_nodes, lookback]
|
| 205 |
+
if per_node_fill_values is not None and var in per_node_fill_values:
|
| 206 |
+
node_fill = per_node_fill_values[var]
|
| 207 |
+
node_fill_broadcast = np.where(
|
| 208 |
+
np.isnan(node_fill), 0.0, node_fill,
|
| 209 |
+
).reshape(-1, 1)
|
| 210 |
+
stat_fallback = np.broadcast_to(node_fill_broadcast, ff_window.shape)
|
| 211 |
+
else:
|
| 212 |
+
stat_fallback = np.zeros_like(ff_window)
|
| 213 |
+
# ff_window is NaN only where this node has no real
|
| 214 |
+
# observation yet anywhere before that date -- fall
|
| 215 |
+
# back to the station-wide stat (or 0.0) only there.
|
| 216 |
+
fill_source = np.where(np.isnan(ff_window), stat_fallback, ff_window)
|
| 217 |
+
filled = np.where(missing, fill_source, raw_window)
|
| 218 |
+
elif per_node_fill_values is not None and var in per_node_fill_values:
|
| 219 |
+
# Real, per-node fill values -- [n_nodes], broadcast
|
| 220 |
+
# across the lookback dimension. A node whose own real
|
| 221 |
+
# fill value is itself NaN (no real training-period
|
| 222 |
+
# observations at all for this variable at this node)
|
| 223 |
+
# correctly falls back to the original, global 0.0 --
|
| 224 |
+
# np.where below prefers the per-node value only where
|
| 225 |
+
# it's real.
|
| 226 |
+
node_fill = per_node_fill_values[var]
|
| 227 |
+
node_fill_broadcast = np.where(
|
| 228 |
+
np.isnan(node_fill), 0.0, node_fill,
|
| 229 |
+
).reshape(-1, 1)
|
| 230 |
+
filled = np.where(missing, node_fill_broadcast, raw_window)
|
| 231 |
+
else:
|
| 232 |
+
filled = np.where(missing, 0.0, raw_window)
|
| 233 |
window_channels.append(filled)
|
| 234 |
window_channels.append(missing.astype(np.float32))
|
| 235 |
if dt_tensors is not None:
|
|
|
|
| 374 |
return shared_embed + gauge_embed
|
| 375 |
|
| 376 |
|
| 377 |
+
class _CausalTemporalCNN(nn.Module):
|
| 378 |
+
"""
|
| 379 |
+
Real, causal, dilated 1D convolution stack over the lookback
|
| 380 |
+
window -- an alternative temporal encoder for the real, direct
|
| 381 |
+
GRU-vs-LSTM-vs-CNN ablation this project is building (same
|
| 382 |
+
graph layers, same physics losses, same everything else;
|
| 383 |
+
temporal_type is the only thing that changes). A genuinely
|
| 384 |
+
different mechanism from a GRU/LSTM (a fixed, dilated
|
| 385 |
+
receptive field vs. recurrent memory), not a near-duplicate.
|
| 386 |
+
|
| 387 |
+
CAUSALITY IS THE REAL, LOAD-BEARING PROPERTY HERE: a GRU/LSTM's
|
| 388 |
+
forward-only recurrence guarantees a given timestep's hidden
|
| 389 |
+
state never depends on future inputs. A naive Conv1d with
|
| 390 |
+
symmetric padding does NOT have that property -- it would let
|
| 391 |
+
future timesteps leak into the "final" summary this class
|
| 392 |
+
produces, which is exactly the kind of real look-ahead bug this
|
| 393 |
+
project has been careful to verify against everywhere else.
|
| 394 |
+
Fixed here via explicit LEFT-only (causal) padding: pad by
|
| 395 |
+
(kernel_size-1)*dilation on the left, none on the right, so
|
| 396 |
+
the output at position t only ever convolves over real inputs
|
| 397 |
+
at positions <= t. Directly, numerically verified (not just
|
| 398 |
+
reasoned about) in the same session this was built.
|
| 399 |
+
"""
|
| 400 |
+
def __init__(self, in_dim: int, out_dim: int, kernel_size: int = 3,
|
| 401 |
+
dilations: Tuple[int, ...] = (1, 2, 4, 8)):
|
| 402 |
+
super().__init__()
|
| 403 |
+
self.kernel_size = kernel_size
|
| 404 |
+
self.dilations = list(dilations)
|
| 405 |
+
self.pads = [(kernel_size - 1) * d for d in self.dilations]
|
| 406 |
+
convs = []
|
| 407 |
+
channels = in_dim
|
| 408 |
+
for dilation in self.dilations:
|
| 409 |
+
convs.append(nn.Conv1d(channels, out_dim, kernel_size, dilation=dilation))
|
| 410 |
+
channels = out_dim
|
| 411 |
+
self.convs = nn.ModuleList(convs)
|
| 412 |
+
|
| 413 |
+
def forward(self, seq):
|
| 414 |
+
# seq: [n_nodes, lookback, in_dim] -> Conv1d wants
|
| 415 |
+
# [n_nodes, in_dim, lookback] (channels-first).
|
| 416 |
+
x = seq.transpose(1, 2)
|
| 417 |
+
for conv, pad in zip(self.convs, self.pads):
|
| 418 |
+
# Explicit LEFT-only pad -- this is the real causality
|
| 419 |
+
# mechanism, not Conv1d's own `padding=` argument (which
|
| 420 |
+
# pads both sides equally and would leak future steps).
|
| 421 |
+
x = nn.functional.pad(x, (pad, 0))
|
| 422 |
+
x = torch.relu(conv(x))
|
| 423 |
+
# [n_nodes, out_dim, lookback] -- the LAST real timestep is
|
| 424 |
+
# this stack's causal summary of the whole real sequence up
|
| 425 |
+
# to now, matching a GRU/LSTM's own final-hidden-state
|
| 426 |
+
# semantics, for a fair, comparable ablation.
|
| 427 |
+
return x[:, :, -1]
|
| 428 |
+
|
| 429 |
+
|
| 430 |
class SpatiotemporalGNN(nn.Module):
|
| 431 |
"""
|
| 432 |
Full model: type-aware input encoding (see TypeAwareInputEncoder
|
| 433 |
above), two RiverMessagePassing layers per timestep (two hops --
|
| 434 |
lets a node see two edges' worth of upstream influence per step,
|
| 435 |
+
e.g. a confluence's own upstream confluence), a per-node temporal
|
| 436 |
+
encoder across the lookback window (temporal_type: "gru"
|
| 437 |
+
(default), "lstm", or "cnn" -- a real, direct ablation of just
|
| 438 |
+
this one component, everything else identical), and a
|
| 439 |
+
horizon+basin-conditioned decoder with shared weights across
|
| 440 |
+
both basins.
|
| 441 |
"""
|
| 442 |
|
| 443 |
def __init__(
|
|
|
|
| 445 |
edge_dim: int = 5, embed_dim: int = 32, hidden_dim: int = 64, gru_hidden: int = 64,
|
| 446 |
n_basins: int = 2, basin_embed_dim: int = 8, horizon_embed_dim: int = 8, n_targets: int = 2,
|
| 447 |
quantiles: Optional[List[float]] = None, forecast_embed_dim: int = 8,
|
| 448 |
+
temporal_type: str = "gru",
|
| 449 |
):
|
| 450 |
super().__init__()
|
| 451 |
self.quantiles = quantiles or [0.5, 0.9, 0.95, 0.99]
|
|
|
|
| 455 |
)
|
| 456 |
self.spatial1 = RiverMessagePassing(embed_dim, hidden_dim, edge_dim)
|
| 457 |
self.spatial2 = RiverMessagePassing(hidden_dim, hidden_dim, edge_dim)
|
| 458 |
+
# temporal_type is a real, direct ablation switch -- "gru"
|
| 459 |
+
# (default, unchanged behavior), "lstm", or "cnn"
|
| 460 |
+
# (_CausalTemporalCNN, defined above). Everything else in
|
| 461 |
+
# this model (graph layers, decoder, physics losses used
|
| 462 |
+
# during training) stays identical regardless of this
|
| 463 |
+
# choice, so a real comparison across the three isolates
|
| 464 |
+
# just the temporal-encoding mechanism, not a mix of
|
| 465 |
+
# changes.
|
| 466 |
+
self.temporal_type = temporal_type
|
| 467 |
+
if temporal_type == "gru":
|
| 468 |
+
self.temporal = nn.GRU(hidden_dim, gru_hidden, batch_first=True)
|
| 469 |
+
elif temporal_type == "lstm":
|
| 470 |
+
self.temporal = nn.LSTM(hidden_dim, gru_hidden, batch_first=True)
|
| 471 |
+
elif temporal_type == "cnn":
|
| 472 |
+
self.temporal = _CausalTemporalCNN(hidden_dim, gru_hidden)
|
| 473 |
+
else:
|
| 474 |
+
raise ValueError(f"temporal_type must be 'gru', 'lstm', or 'cnn', got {temporal_type!r}")
|
| 475 |
self.basin_embed = nn.Embedding(n_basins, basin_embed_dim)
|
| 476 |
self.horizon_embed = nn.Linear(1, horizon_embed_dim)
|
| 477 |
# Real forecasted precipitation, per node per horizon -- NOT
|
|
|
|
| 571 |
embeddings.append(h)
|
| 572 |
seq = torch.stack(embeddings, dim=1) # [n_nodes, lookback, hidden_dim]
|
| 573 |
|
| 574 |
+
# Real per-temporal_type handling -- GRU returns a single
|
| 575 |
+
# final hidden state, LSTM returns a (h_n, c_n) tuple (real
|
| 576 |
+
# cell state alongside hidden state, an LSTM-specific
|
| 577 |
+
# difference from GRU), CNN (_CausalTemporalCNN) already
|
| 578 |
+
# returns the right [n_nodes, gru_hidden] shape directly, no
|
| 579 |
+
# further unpacking needed. All three converge to the same
|
| 580 |
+
# real node_hidden shape the decoder below expects.
|
| 581 |
+
if self.temporal_type == "gru":
|
| 582 |
+
_, h_n = self.temporal(seq)
|
| 583 |
+
node_hidden = h_n.squeeze(0) # [n_nodes, gru_hidden]
|
| 584 |
+
elif self.temporal_type == "lstm":
|
| 585 |
+
_, (h_n, c_n) = self.temporal(seq)
|
| 586 |
+
node_hidden = h_n.squeeze(0) # [n_nodes, gru_hidden]
|
| 587 |
+
else: # "cnn"
|
| 588 |
+
node_hidden = self.temporal(seq) # [n_nodes, gru_hidden]
|
| 589 |
|
| 590 |
basin_feat = self.basin_embed(basin_id) # [n_nodes, basin_embed_dim]
|
| 591 |
|