River_Network / scripts /graph_building /diagnose_persistence_baseline.py
ageraustine's picture
Upload folder using huggingface_hub (part 2)
f2046b4 verified
Raw History Blame Contribute Delete
5.48 kB
"""
Diagnostic: adds a naive persistence baseline (tomorrow's anomaly =
today's real anomaly, no model at all) to the climatology comparison,
computed directly from real anomaly data -- no trained model needed.
This exists specifically to distinguish two very different explanations
for the anomaly model losing to climatology at short horizons:
(a) there's genuinely no learnable short-term signal beyond
climatology for that variable, or
(b) the model/training setup has a real problem, and even the
simplest possible non-climatology guess (yesterday's value)
would have beaten climatology if given the chance.
If persistence beats climatology at h=1 but the trained model doesn't,
that's a training/architecture issue, not a genuine predictability
limit -- a very different, much more actionable finding.
Usage:
python -m scripts.diagnose_persistence_baseline --data-root datasets \
--train-end 2023-12-31 --test-end 2026-12-31
"""
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
try:
from src.graph.dynamic_features import build_climate_grid_timeseries
from src.graph.climate_climatology import fit_harmonic_climatology, compute_anomalies
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
DEFAULT_HORIZONS = [1, 5, 10, 30, 60, 90, 180]
def evaluate_persistence(anomalies: pd.DataFrame, horizons: list) -> pd.DataFrame:
"""
For every real (date, cell) anomaly value, compares:
- persistence_rmse: predicting that SAME day's anomaly repeated
forward h days (i.e. "nothing has changed since today")
- climatology_only_rmse: predicting zero anomaly (pure climatology)
against the REAL anomaly that actually occurred h days later.
Both computed only where the real target h days ahead exists.
"""
values = anomalies.values
n_dates, n_cells = values.shape
rows = []
for h in horizons:
if h >= n_dates:
continue
today = values[:n_dates - h, :]
future = values[h:, :]
valid = ~np.isnan(today) & ~np.isnan(future)
if not valid.any():
continue
persistence_pred = today[valid] # "tomorrow = today"
actual = future[valid]
persistence_rmse = np.sqrt(np.mean((persistence_pred - actual) ** 2))
climatology_rmse = np.sqrt(np.mean(actual ** 2)) # predicting anomaly = 0
rows.append({
"horizon_days": h, "n_pairs": int(valid.sum()),
"persistence_rmse": persistence_rmse, "climatology_only_rmse": climatology_rmse,
"persistence_beats_climatology": persistence_rmse < climatology_rmse,
})
return pd.DataFrame(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="Persistence-vs-climatology diagnostic")
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("--train-start", type=str, default="2013-01-01")
parser.add_argument("--train-end", type=str, default="2023-12-31")
parser.add_argument("--test-end", type=str, default="2026-12-31")
parser.add_argument("--horizons", type=int, nargs="+", default=DEFAULT_HORIZONS)
parser.add_argument("--n-harmonics", type=int, default=2)
parser.add_argument("--min-months-covered", type=int, default=8)
args = parser.parse_args()
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...")
climate_dict, grid_coords = build_climate_grid_timeseries(
args.data_root / "safran", bbox, (args.train_start, args.test_end)
)
print(f"Loaded {len(climate_dict)} real variable(s) across {len(grid_coords)} real grid cell(s)\n")
train_start, train_end, test_end = (
pd.Timestamp(args.train_start), pd.Timestamp(args.train_end), pd.Timestamp(args.test_end)
)
print("=" * 80)
print(f"{'Variable':<15}{'Horizon':>8} {'Persistence RMSE':>17} {'Climatology RMSE':>17} {'Persistence wins?':>18}")
print("=" * 80)
for var, wide in climate_dict.items():
train_wide = wide[(wide.index >= train_start) & (wide.index <= train_end)]
test_wide = wide[(wide.index > train_end) & (wide.index <= test_end)]
coeffs = fit_harmonic_climatology(train_wide, n_harmonics=args.n_harmonics,
min_months_covered=args.min_months_covered)
if not coeffs:
continue
test_anomalies = compute_anomalies(test_wide, coeffs, args.n_harmonics)
result = evaluate_persistence(test_anomalies, args.horizons)
for _, row in result.iterrows():
print(f"{var:<15}{int(row['horizon_days']):>8} {row['persistence_rmse']:>17.4f} "
f"{row['climatology_only_rmse']:>17.4f} {str(row['persistence_beats_climatology']):>18}")
print()
if __name__ == "__main__":
main()