ageraustine commited on
Commit
04d6dda
·
verified ·
1 Parent(s): b79afb0

Upload folder using huggingface_hub (part 2)

Browse files
Files changed (47) hide show
  1. .gitattributes +6 -0
  2. datasets/spatiotemporal_gnn/archive/model_20260916_230515.pt +3 -0
  3. datasets/spatiotemporal_gnn/archive/model_20260917_045239.pt +3 -0
  4. datasets/spatiotemporal_gnn/archive/model_20260917_061838.pt +3 -0
  5. datasets/spatiotemporal_gnn/archive/model_20260917_150452.pt +3 -0
  6. datasets/spatiotemporal_gnn/archive/model_20260917_232412.pt +3 -0
  7. datasets/spatiotemporal_gnn/archive/model_20260918_111742.pt +3 -0
  8. datasets/spatiotemporal_gnn/archive/model_20260918_141053.pt +3 -0
  9. datasets/spatiotemporal_gnn/archive/model_20260918_181508.pt +3 -0
  10. datasets/spatiotemporal_gnn/archive/model_20260919_152545.pt +3 -0
  11. datasets/spatiotemporal_gnn/archive/model_20260920_082410.pt +3 -0
  12. datasets/spatiotemporal_gnn/cnn/checkpoint.pt +3 -0
  13. datasets/spatiotemporal_gnn/cnn/logs.txt +89 -0
  14. datasets/spatiotemporal_gnn/cnn/model.pt +3 -0
  15. datasets/spatiotemporal_gnn/cnn/selected_subgraph.png +3 -0
  16. datasets/spatiotemporal_gnn/cnn/training_curves.png +3 -0
  17. datasets/spatiotemporal_gnn/gru/logs.txt +51 -0
  18. datasets/spatiotemporal_gnn/gru/model.pt +3 -0
  19. datasets/spatiotemporal_gnn/gru/selected_subgraph.png +3 -0
  20. datasets/spatiotemporal_gnn/gru/training_curves.png +0 -0
  21. datasets/spatiotemporal_gnn/lstm/logs.txt +50 -0
  22. datasets/spatiotemporal_gnn/lstm/model.pt +3 -0
  23. datasets/spatiotemporal_gnn/lstm/selected_subgraph.png +3 -0
  24. datasets/spatiotemporal_gnn/lstm/training_curves.png +3 -0
  25. datasets/spatiotemporal_gnn/model.pt +1 -1
  26. requirements.txt +2 -0
  27. scripts/analysis/__pycache__/check_discharge_quantile_distribution.cpython-311.pyc +0 -0
  28. scripts/analysis/__pycache__/check_dry_valleys.cpython-311.pyc +0 -0
  29. scripts/analysis/__pycache__/check_routing_lag_distribution.cpython-311.pyc +0 -0
  30. scripts/analysis/check_discharge_quantile_distribution.py +104 -0
  31. scripts/analysis/check_dry_valleys.py +115 -0
  32. scripts/analysis/check_routing_lag_distribution.py +112 -0
  33. scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc +0 -0
  34. scripts/evaluation/evaluate_flood_detection.py +0 -0
  35. scripts/evaluation/logs.txt +9 -0
  36. scripts/training/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc +2 -2
  37. scripts/training/train_spatiotemporal_gnn.py +563 -25
  38. src/graph/__pycache__/dynamic_features.cpython-311.pyc +0 -0
  39. src/graph/__pycache__/flood_events.cpython-311.pyc +0 -0
  40. src/graph/__pycache__/physics_losses.cpython-311.pyc +0 -0
  41. src/graph/__pycache__/pinball_loss.cpython-311.pyc +0 -0
  42. src/graph/__pycache__/spatiotemporal_gnn.cpython-311.pyc +0 -0
  43. src/graph/dynamic_features.py +85 -1
  44. src/graph/flood_events.py +170 -0
  45. src/graph/physics_losses.py +154 -0
  46. src/graph/pinball_loss.py +265 -39
  47. 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

  • SHA256: 64ee42317a5a54afc16c2b02b2b7f26bc23178f315bdb10f476f2bd8fc4f42ce
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
datasets/spatiotemporal_gnn/cnn/training_curves.png ADDED

Git LFS Details

  • SHA256: 7276be6c5a1a58292f3a0a3c35219be91b661542c18921959505e9061f3aefc8
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
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

  • SHA256: b1a0591498e67626e4139827abaf21a6ba3dedc25937168bf4060ae49e48f2fa
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
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

  • SHA256: fd3b7c0b65e429b11f95045f0d6d07c0114c750c2e918a31b2b0477d4646dd52
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
datasets/spatiotemporal_gnn/lstm/training_curves.png ADDED

Git LFS Details

  • SHA256: c566f73442beac6cc5599be3faacb70bf4821fee68327d95c9ef5a7cc9edca72
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
datasets/spatiotemporal_gnn/model.pt CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:debcf20e5f2fa6309d3fccacb0b4631bbe6a319625698a6a03c06d195b1d3022
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:a54bb608b5e1626b3243cf3c627f452242ffb421b66816bd29b3e9bfe84fcd87
3
- size 106555
 
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 multi_quantile_loss
 
 
 
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 multi_quantile_loss
 
 
 
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 = 7 # first 7 days -- extended from 5 to match the real max ECMWF
84
- # forecast lead time (7, now in HORIZONS) -- routing_consistency_loss and Soft-DTW compare
85
- # the model's own predictions against each other, so extending them carries no real new
86
- # risk. water_balance_loss is the one term this extension is more borderline for (it
87
- # checks predicted discharge against HISTORICAL, already-observed precipitation, which
88
- # genuinely stops explaining discharge the further out the prediction horizon goes) -- 7
89
- # is still within a real, defensible range; this constant should not be pushed further
90
- # without separately reconsidering water_balance_loss's own real physical justification.
 
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 in train_loader:
 
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
- supervised_Q = multi_quantile_loss(Y_i, Q_pred_quantiles, QUANTILES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 in val_loader:
 
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
- supervised_Q = multi_quantile_loss(Y_i, Q_pred_quantiles, QUANTILES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- assignments = map_stations_to_precipitation_gauges(nodes_df, precip_df[["NUM_POSTE", "LAT", "LON"]])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 weighted_multi_quantile_loss(y_true, y_pred_quantiles, quantiles: List[float], recency_weight, alpha: float = 1.0):
 
 
 
 
92
  """
93
- Same real pinball-loss sum as multi_quantile_loss, but each real
94
- element is additionally weighted by 1 + alpha*log(1+dt) before
95
- averaging -- forcing more real loss-weight onto observations that
96
- follow a longer real gap in that node's own history, so the model
97
- is pushed to actually close a real drift accumulated over a gap
98
- rather than being scored the same regardless of how stale its
99
- recent information was.
100
-
101
- A REAL, HONEST OVERLAP WARNING, not a minor footnote: this project
102
- already built and wired in a days-since-last-real-observation
103
- channel as a direct INPUT feature (see dynamic_features.py's
104
- compute_days_since_last_real / compress_days_since_last_real). That
105
- gives the model itself the same underlying real information and
106
- lets it learn how to use staleness; this function instead FORCES
107
- more loss-weight onto post-gap examples regardless of whether the
108
- model finds that useful. Two different mechanisms carrying the same
109
- real underlying signal, in directions that aren't guaranteed to be
110
- aligned -- this is a real, plausible risk of diluting rather than
111
- reinforcing the existing signal, not a hypothetical concern. Treat
112
- alpha=0 (a no-op, recovering exactly multi_quantile_loss's own
113
- behavior) as the real baseline to compare against before trusting
114
- any alpha>0 run's results -- an ablation, not an assumption either
115
- mechanism helps.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  Args:
118
  y_true, y_pred_quantiles, quantiles: same as multi_quantile_loss.
119
- recency_weight: [n_nodes] (or broadcastable against y_true's
120
- leading node dimension) -- real, already-computed
121
- 1 + alpha*log(1+dt) values per node. Passed in already
122
- computed, not recomputed here, so the caller controls
123
- exactly which real dt this reflects (e.g. dt as of the
124
- anchor date, not some other point).
125
- alpha: real scaling on the log(1+dt) term, applied by the
126
- CALLER when building recency_weight -- kept as a parameter
127
- here too only for direct-call convenience; if
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 = recency_weight if (hasattr(recency_weight, "ndim") and recency_weight.ndim == y_true.ndim) else \
133
- (recency_weight.view(-1, *([1] * (y_true.ndim - 1))) if is_torch else
134
- recency_weight.reshape(-1, *([1] * (y_true.ndim - 1))))
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
- filled = np.where(missing, 0.0, raw_window)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 GRU
284
- across the lookback window, and a horizon+basin-conditioned
285
- decoder with shared weights across both basins.
 
 
 
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
- self.temporal = nn.GRU(hidden_dim, gru_hidden, batch_first=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _, h_n = self.temporal(seq)
403
- node_hidden = h_n.squeeze(0) # [n_nodes, gru_hidden]
 
 
 
 
 
 
 
 
 
 
 
 
 
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