File size: 5,484 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
"""
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()