Spaces:
Sleeping
Sleeping
File size: 12,087 Bytes
f2046b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | """
Rolling-window cross-validation for the climate anomaly model --
built specifically because single train/val/test runs proved unstable
against real data: restricting an otherwise-identical test window from
2024-2026 down to 2024-2025 (removing one year) flipped runoff_mm from
"beats climatology at 5 of 7 horizons" to "beats climatology nowhere at
all", and temp_C from winning at all 7 horizons to losing at 3 of them.
That's not noise to shrug off -- it means no single run's per-horizon
table is trustworthy on its own, and re-running with yet another date
range and reading whichever result looks best is the wrong fix.
This trains and evaluates across several genuinely independent
(non-overlapping test period) folds with an expanding training window,
then reports, per (variable, horizon), the FRACTION of folds where the
model actually beat climatology and the mean/std of the RMSE margin --
a "win" that holds up in 4 of 4 folds is real signal; a "win" that
shows up in 1 of 4 is very likely the kind of single-run artifact that
misled the earlier single-window runs.
Usage:
python -m scripts.cross_validate_climate_anomaly_model --data-root datasets
"""
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
import torch
try:
from src.graph.dynamic_features import build_climate_grid_timeseries
from src.graph.climate_climatology import fit_harmonic_climatology, compute_anomalies, aggregate_anomalies, REGIME_CONFIG
from scripts.tests.train_climate_anomaly_model import train_one_variable
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.graph.dynamic_features import build_climate_grid_timeseries
from src.graph.climate_climatology import fit_harmonic_climatology, compute_anomalies, aggregate_anomalies, REGIME_CONFIG
from scripts.tests.train_climate_anomaly_model import train_one_variable
# Expanding-window folds, each with a genuinely NON-OVERLAPPING test
# period -- this is what actually lets results from different folds be
# compared as independent evidence, rather than four correlated re-reads
# of mostly the same years. Spans as much of the real 2013-2026 ERA5
# record as possible while keeping every fold's train/val/test properly
# separated in time (no leakage from a later fold's test period into an
# earlier fold's training set, since every fold's train window starts
# at the same point and only extends forward).
DEFAULT_FOLDS = [
{"train_start": "2013-01-01", "train_end": "2016-12-31", "val_end": "2018-12-31", "test_end": "2020-12-31"},
{"train_start": "2013-01-01", "train_end": "2018-12-31", "val_end": "2020-12-31", "test_end": "2022-12-31"},
{"train_start": "2013-01-01", "train_end": "2020-12-31", "val_end": "2022-12-31", "test_end": "2024-12-31"},
{"train_start": "2013-01-01", "train_end": "2022-12-31", "val_end": "2024-12-31", "test_end": "2026-12-31"},
]
# Val periods widened from 1 year to 2 years (24 months) vs the original
# design -- confirmed as a real, not hypothetical, problem against real
# data: the monthly regime (lookback=12 + max horizon=6 = 18 months
# minimum to produce even one training example) got "val examples: 0"
# in every fold under the original 1-year val windows, silently skipping
# that entire regime rather than crashing. 24 months clears the 18-month
# floor with real margin for a stable early-stopping signal, not just
# the bare minimum. Test windows remain genuinely non-overlapping across
# folds (2019-2020, 2021-2022, 2023-2024, 2025-2026), same principle as
# before, just shifted to make room for the wider val period.
def run_cross_validation(
climate_dict: dict, variables: list, folds: list, regime_names: list,
max_epochs: int, patience: int, batch_size: int, lr: float, weight_decay: float,
n_harmonics: int, min_months_covered: int, device: str,
) -> pd.DataFrame:
all_rows = []
for fold_idx, fold in enumerate(folds):
train_start = pd.Timestamp(fold["train_start"])
train_end = pd.Timestamp(fold["train_end"])
val_end = pd.Timestamp(fold["val_end"])
test_end = pd.Timestamp(fold["test_end"])
print("=" * 70)
print(f"FOLD {fold_idx + 1}/{len(folds)}: train [{train_start.date()}, {train_end.date()}], "
f"val (...{val_end.date()}], test (...{test_end.date()}]")
print("=" * 70)
for var in variables:
if var not in climate_dict:
continue
wide = climate_dict[var]
train_wide = wide[(wide.index >= train_start) & (wide.index <= train_end)]
val_wide = wide[(wide.index > train_end) & (wide.index <= val_end)]
test_wide = wide[(wide.index > val_end) & (wide.index <= test_end)]
coeffs = fit_harmonic_climatology(train_wide, n_harmonics=n_harmonics,
min_months_covered=min_months_covered)
if not coeffs:
print(f" {var}: insufficient climatology coverage this fold, skipping")
continue
train_daily = compute_anomalies(train_wide, coeffs, n_harmonics)
val_daily = compute_anomalies(val_wide, coeffs, n_harmonics)
test_daily = compute_anomalies(test_wide, coeffs, n_harmonics)
# One model per (variable, regime) per fold -- same reasoning
# as train_climate_anomaly_model.py: daily resolution is the
# wrong target past ~2 weeks, so each regime gets its own
# appropriately-aggregated series and its own native horizons.
for regime_name, regime in REGIME_CONFIG.items():
if regime_name not in regime_names:
continue
if regime["freq"] is None:
train_anomalies, val_anomalies, test_anomalies = train_daily, val_daily, test_daily
else:
train_anomalies = aggregate_anomalies(train_daily, regime["freq"])
val_anomalies = aggregate_anomalies(val_daily, regime["freq"])
test_anomalies = aggregate_anomalies(test_daily, regime["freq"])
result = train_one_variable(
f"{var}_{regime_name}", train_anomalies, val_anomalies, test_anomalies,
regime["horizons_native"], regime["lookback"],
max_epochs, patience, batch_size, lr, weight_decay, device,
)
if result["status"] != "trained":
continue
for row in result["skill_by_horizon"]:
all_rows.append({
"fold": fold_idx + 1, "variable": var, "regime": regime_name,
"horizon_native": row["horizon_days"],
"model_rmse": row["model_rmse"], "climatology_only_rmse": row["climatology_only_rmse"],
"model_beats_climatology": row["model_beats_climatology"],
"rmse_margin_pct": 100 * (row["climatology_only_rmse"] - row["model_rmse"]) / row["climatology_only_rmse"],
})
return pd.DataFrame(all_rows)
def summarize_cross_validation(results: pd.DataFrame) -> pd.DataFrame:
"""
The actual answer this whole script exists to produce: per
(variable, regime, horizon), across all folds, what fraction
actually beat climatology, and what the margin looked like on
average -- this is what should be trusted, not any single fold's
own table. Grouped by regime as well as horizon now -- "horizon 3"
means 3 days in the daily regime, 3 weeks in the weekly regime, and
3 months in the monthly regime, so conflating them would silently
average together numbers that don't refer to the same real horizon.
"""
summary = results.groupby(["variable", "regime", "horizon_native"]).agg(
n_folds=("fold", "count"),
n_folds_beat_climatology=("model_beats_climatology", "sum"),
mean_rmse_margin_pct=("rmse_margin_pct", "mean"),
std_rmse_margin_pct=("rmse_margin_pct", "std"),
).reset_index()
summary["fraction_folds_beat"] = summary["n_folds_beat_climatology"] / summary["n_folds"]
summary["verdict"] = summary["fraction_folds_beat"].apply(
lambda f: "CONSISTENT WIN" if f == 1.0 else ("CONSISTENT LOSS" if f == 0.0 else "UNSTABLE")
)
return summary.sort_values(["variable", "regime", "horizon_native"])
def main() -> None:
parser = argparse.ArgumentParser(description="Rolling-window cross-validation for the climate anomaly model")
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
parser.add_argument("--stations-path", type=Path, default=None)
parser.add_argument("--bbox-margin-deg", type=float, default=0.3)
parser.add_argument("--regimes", type=str, nargs="+", default=list(REGIME_CONFIG.keys()),
help="Which resolution regimes to cross-validate (default: all).")
parser.add_argument("--n-harmonics", type=int, default=2)
parser.add_argument("--min-months-covered", type=int, default=8)
parser.add_argument("--max-epochs", type=int, default=200)
parser.add_argument("--patience", type=int, default=10)
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--lr", type=float, default=1e-3)
parser.add_argument("--weight-decay", type=float, default=1e-4)
parser.add_argument("--variables", type=str, nargs="+", default=None)
parser.add_argument("--output-dir", type=Path, default=None)
args = parser.parse_args()
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
nodes_df = pd.read_csv(stations_path)
m = args.bbox_margin_deg
bbox = (nodes_df["longitude"].min() - m, nodes_df["latitude"].min() - m,
nodes_df["longitude"].max() + m, nodes_df["latitude"].max() + m)
print("Loading real ERA5 grid data once, reused across every fold...")
climate_dict, grid_coords = build_climate_grid_timeseries(
args.data_root / "safran", bbox, ("2013-01-01", "2026-12-31")
)
print(f"Loaded {len(climate_dict)} real variable(s) across {len(grid_coords)} real grid cell(s)\n")
variables = args.variables or list(climate_dict.keys())
output_dir = args.output_dir or (args.data_root / "climate_anomaly_models" / "cross_validation")
output_dir.mkdir(parents=True, exist_ok=True)
results = run_cross_validation(
climate_dict, variables, DEFAULT_FOLDS, args.regimes,
args.max_epochs, args.patience, args.batch_size, args.lr, args.weight_decay,
args.n_harmonics, args.min_months_covered, device,
)
results_path = output_dir / "raw_fold_results.csv"
results.to_csv(results_path, index=False)
print(f"\nSaved raw per-fold results to {results_path}")
summary = summarize_cross_validation(results)
summary_path = output_dir / "cross_validation_summary.csv"
summary.to_csv(summary_path, index=False)
print("\n" + "=" * 70)
print("CROSS-VALIDATION SUMMARY -- this is the trustworthy answer, not any single fold")
print("=" * 70)
print(summary.to_string(index=False))
print(f"\nSaved to {summary_path}")
print("\nConsistent wins (real, repeatable skill) by variable and regime:")
for var in summary["variable"].unique():
for regime in summary[summary["variable"] == var]["regime"].unique():
var_regime_summary = summary[(summary["variable"] == var) & (summary["regime"] == regime)]
consistent = var_regime_summary[var_regime_summary["verdict"] == "CONSISTENT WIN"]["horizon_native"].tolist()
unstable = var_regime_summary[var_regime_summary["verdict"] == "UNSTABLE"]["horizon_native"].tolist()
print(f" {var} / {regime}: consistent wins at {consistent}, unstable (don't trust either way) at {unstable}")
if __name__ == "__main__":
main() |