""" 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()