File size: 4,668 Bytes
1e10174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Diagnostic: find where the model predictions go wrong.

Prints the target scaler's fitted statistics, per-balancing-authority error, and
the worst individual predictions. Run after `gridpulse train`::

    python scripts/diagnose_models.py
"""

from __future__ import annotations

import sys
from pathlib import Path

import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from gridpulse.features.build import build_features  # noqa: E402
from gridpulse.models.gbm import TrainedGBM  # noqa: E402

pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 40)

TEST_DAYS = 90
VALID_DAYS = 60


def rule(title: str) -> None:
    print(f"\n{'=' * 78}\n  {title}\n{'=' * 78}")


def main() -> int:
    rule("1. Loading model artifacts")
    gbm = TrainedGBM.load()
    print(f"  best_iteration : {gbm.best_iteration}")
    print(f"  features       : {len(gbm.feature_names)}")
    print(f"  ba_categories  : {gbm.ba_categories}")

    rule("2. Target scaler statistics (fitted on training rows)")
    stats = pd.DataFrame(
        [{"ba_code": k, "mean_mw": v[0], "std_mw": v[1]} for k, v in gbm.target_scaler.stats.items()]
    ).sort_values("mean_mw", ascending=False)
    print(stats.to_string(index=False))
    print(f"\n  global_mean : {gbm.target_scaler.global_mean:,.1f}")
    print(f"  global_std  : {gbm.target_scaler.global_std:,.1f}")
    print(f"  scale ratio (largest/smallest BA mean): "
          f"{stats['mean_mw'].max() / stats['mean_mw'].min():.1f}x")

    rule("3. Rebuilding the test split")
    frame = build_features()
    test_start = frame["period_utc"].max() - pd.Timedelta(days=TEST_DAYS)
    test = frame[frame["period_utc"] >= test_start].copy()
    print(f"  test rows : {len(test):,}")
    print(f"  BAs       : {sorted(test['ba_code'].unique())}")

    rule("4. Does every test BA resolve in the scaler?")
    missing = sorted(set(test["ba_code"].unique()) - set(gbm.target_scaler.stats))
    if missing:
        print(f"  !! MISSING, will silently fall back to global stats: {missing}")
    else:
        print("  OK - every test BA is present in the scaler.")

    rule("5. Raw model output, BEFORE the inverse transform")
    matrix = test[gbm.feature_names].copy()
    matrix["ba_code"] = pd.Categorical(test["ba_code"], categories=gbm.ba_categories)
    raw = gbm.point_model.predict(matrix)
    print(f"  z-score predictions   min={raw.min():9.3f}  max={raw.max():9.3f}  "
          f"mean={raw.mean():8.3f}  std={raw.std():7.3f}")
    print("  (a healthy z-score prediction sits roughly within -4 .. +4)")

    actual_z = gbm.target_scaler.transform(test)
    print(f"  z-score actuals       min={actual_z.min():9.3f}  max={actual_z.max():9.3f}  "
          f"mean={actual_z.mean():8.3f}  std={actual_z.std():7.3f}")

    rule("6. Predictions AFTER the inverse transform")
    predicted = gbm.predict(test)
    test = test.reset_index(drop=True)
    predicted = predicted.reset_index(drop=True)
    test["pred"] = predicted["pred_gbm"].to_numpy()
    test["ape"] = (test["pred"] - test["demand_mwh"]).abs() / test["demand_mwh"] * 100

    print(f"  predicted MW  min={test['pred'].min():12,.0f}  max={test['pred'].max():12,.0f}")
    print(f"  actual    MW  min={test['demand_mwh'].min():12,.0f}  max={test['demand_mwh'].max():12,.0f}")

    rule("7. Error by balancing authority")
    by_ba = (
        test.groupby("ba_code")
        .agg(
            hours=("demand_mwh", "size"),
            actual_mean_mw=("demand_mwh", "mean"),
            pred_mean_mw=("pred", "mean"),
            mape_pct=("ape", "mean"),
            worst_ape_pct=("ape", "max"),
        )
        .sort_values("mape_pct", ascending=False)
        .round(2)
    )
    print(by_ba.to_string())

    rule("8. The 15 worst individual predictions")
    worst = test.nlargest(15, "ape")[
        ["period_utc", "ba_code", "demand_mwh", "pred", "ape", "demand_lag_24h", "demand_lag_168h"]
    ]
    print(worst.to_string(index=False))

    rule("9. How concentrated is the damage?")
    for threshold in (10, 25, 50, 100, 500):
        share = (test["ape"] > threshold).mean() * 100
        print(f"  rows with APE > {threshold:4d}% : {share:6.2f}%")

    contribution = test.nlargest(int(len(test) * 0.01), "ape")["ape"].sum() / test["ape"].sum() * 100
    print(f"\n  the worst 1% of rows account for {contribution:.1f}% of total absolute error")
    print(f"  overall MAPE {test['ape'].mean():.3f}%   median APE {test['ape'].median():.3f}%")
    print("  (a median far below the mean confirms a small number of extreme outliers)")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())