monsoon-rl / evaluate_checkpoint.py
DHDRL's picture
Upload 27 files
976eb45 verified
Raw
History Blame Contribute Delete
18.3 kB
#!/usr/bin/env python3
"""
evaluate_checkpoint.py
=======================
Synthetic alert-quality eval for a trained checkpoint.
Critical design points
----------------------
1. Product-gate flags come from compute_risk_score on episode ZoneObs at
terminate — identical for checkpoint vs zero_inspect on the same seed.
Identical product P/R is *expected*; it is not evidence of agent skill.
2. Policy-sensitive signal is believed_p (max zone belief at terminate)
and episode length. Inspections update belief; zero_inspect keeps the
reset-time blend only.
3. Ground truth must use the same regional event model as training
(_episode_event_plan), not independent _zone_event_flags.
4. Eval ForecastConfig must match train (clean_episode_ratio +
event_spatial_correlation) or base rates and EV tables are meaningless.
Usage
-----
python evaluate_checkpoint.py \\
--checkpoint run_nz3_c090/final_model.zip \\
--n-zones 3 --max-steps 250 --n-episodes 200 \\
--clean-episode-ratio 0.90 --event-spatial-correlation 0.85 \\
--also-zero-inspect
"""
from __future__ import annotations
import argparse
from collections import Counter
from typing import Any, Dict, List, Optional
import numpy as np
import zone_observation as _zo
assert _zo.SCHEMA_VERSION == 3, (
f"evaluate_checkpoint: zone_observation schema mismatch "
f"(expected 3, got {_zo.SCHEMA_VERSION})"
)
from zone_observation import AlertLevel, ForecastConfig
from weather_forecast_env import (
make_weather_env,
_episode_event_plan,
)
def _episode_is_risky(
effective_seed: int,
n_zones: int,
clean_ratio: float,
spatial_corr: float,
) -> bool:
"""Match training: regional event plan, not independent per-zone draws."""
plan = _episode_event_plan(
n_zones, effective_seed, clean_ratio, spatial_corr
)
return any(any(f.values()) for f in plan)
def _confusion(tp: int, fp: int, fn: int, tn: int) -> Dict[str, float]:
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = (
2 * precision * recall / (precision + recall)
if (precision + recall) > 0
else 0.0
)
return dict(
tp=tp, fp=fp, fn=fn, tn=tn,
precision=precision, recall=recall, f1=f1,
n_positive=tp + fn,
n_negative=tn + fp,
n=tp + fp + fn + tn,
base_rate=(tp + fn) / max(tp + fp + fn + tn, 1),
alert_rate=(tp + fp) / max(tp + fp + fn + tn, 1),
)
def _day_level_ev(
alert: bool,
risky: bool,
alert_value: float,
false_alert_penalty: float,
miss_penalty: float,
) -> float:
if alert and risky:
return alert_value
if alert and not risky:
return -false_alert_penalty
if (not alert) and risky:
return -miss_penalty
return 0.0
def evaluate(
config: ForecastConfig,
n_episodes: int,
eval_seed_base: int = 500_000,
model=None,
policy: str = "checkpoint",
) -> Dict[str, Any]:
if policy == "checkpoint" and model is None:
raise ValueError("policy=checkpoint requires a loaded model")
env = make_weather_env(config, use_nan_wrapper=True)
base_env = env
if hasattr(env, "env"):
base_env = env.env
terminate_action = int(getattr(base_env, "terminate_action", config.n_zones))
lengths: List[int] = []
a_tp = a_fp = a_fn = a_tn = 0
p_tp = p_fp = p_fn = p_tn = 0
b_tp = b_fp = b_fn = b_tn = 0
alert_levels = Counter()
product_flags = Counter()
drought_risks: List[float] = []
flood_risks: List[float] = []
max_risks: List[float] = []
believed_list: List[float] = []
believed_risky: List[float] = []
believed_clean: List[float] = []
adv_ev = prod_ev = belief_ev = 0.0
always_ev = never_ev = oracle_ev = 0.0
av = float(config.alert_value)
fap = float(config.false_alert_penalty)
mp = float(config.miss_penalty)
rational = float(config.rational_termination_threshold)
belief_bar = max(rational, float(config.prior_belief) + 0.05, 0.20)
rho = float(getattr(config, "event_spatial_correlation", 0.85))
n_risky = 0
for i in range(n_episodes):
eval_seed = eval_seed_base + i
obs, info = env.reset(seed=eval_seed)
risky = _episode_is_risky(
eval_seed,
config.n_zones,
config.clean_episode_ratio,
rho,
)
if risky:
n_risky += 1
done = False
ep_len = 0
final_severity = 0
product = False
drought = flood = 0.0
alert_level = "none"
believed_p = float(config.prior_belief)
while not done:
if policy == "zero_inspect":
action = terminate_action
else:
action_masks = env.action_masks()
action, _ = model.predict(
obs, action_masks=action_masks, deterministic=True
)
action = int(action)
obs, reward, terminated, truncated, info = env.step(action)
ep_len += 1
done = terminated or truncated
if "alert_level" in info:
alert_level = str(info["alert_level"])
final_severity = AlertLevel(alert_level).severity()
if "product_actionable" in info:
product = bool(info["product_actionable"])
if "drought_risk" in info:
drought = float(info["drought_risk"])
if "flood_risk" in info:
flood = float(info["flood_risk"])
if "believed_p" in info:
believed_p = float(info["believed_p"])
elif "zone_belief" in obs:
n_act = config.n_zones
believed_p = float(np.max(obs["zone_belief"][:n_act]))
lengths.append(ep_len)
alert_levels[alert_level] += 1
product_flags[str(product)] += 1
drought_risks.append(drought)
flood_risks.append(flood)
max_risks.append(max(drought, flood))
believed_list.append(believed_p)
if risky:
believed_risky.append(believed_p)
else:
believed_clean.append(believed_p)
alerted_adv = final_severity >= AlertLevel.ADVISORY.severity()
if risky and alerted_adv:
a_tp += 1
elif risky and not alerted_adv:
a_fn += 1
elif (not risky) and alerted_adv:
a_fp += 1
else:
a_tn += 1
alerted_prod = product
if risky and alerted_prod:
p_tp += 1
elif risky and not alerted_prod:
p_fn += 1
elif (not risky) and alerted_prod:
p_fp += 1
else:
p_tn += 1
alerted_belief = believed_p >= belief_bar
if risky and alerted_belief:
b_tp += 1
elif risky and not alerted_belief:
b_fn += 1
elif (not risky) and alerted_belief:
b_fp += 1
else:
b_tn += 1
adv_ev += _day_level_ev(alerted_adv, risky, av, fap, mp)
prod_ev += _day_level_ev(alerted_prod, risky, av, fap, mp)
belief_ev += _day_level_ev(alerted_belief, risky, av, fap, mp)
always_ev += _day_level_ev(True, risky, av, fap, mp)
never_ev += _day_level_ev(False, risky, av, fap, mp)
oracle_ev += _day_level_ev(risky, risky, av, fap, mp)
p_event = 1.0 - float(config.clean_episode_ratio)
analytic_corr = p_event
analytic_iid = 1.0 - (config.clean_episode_ratio ** config.n_zones)
def _mean(xs: List[float]) -> float:
return float(np.mean(xs)) if xs else 0.0
return {
"policy": policy,
"mean_len": float(np.mean(lengths)) if lengths else 0.0,
"n_episodes": n_episodes,
"n_risky": n_risky,
"empirical_base_rate": n_risky / max(n_episodes, 1),
"analytic_base_rate_iid": analytic_iid,
"analytic_base_rate_corr": analytic_corr,
"clean_episode_ratio": config.clean_episode_ratio,
"event_spatial_correlation": rho,
"n_zones": config.n_zones,
"belief_bar": belief_bar,
"advisory": _confusion(a_tp, a_fp, a_fn, a_tn),
"product": _confusion(p_tp, p_fp, p_fn, p_tn),
"belief": _confusion(b_tp, b_fp, b_fn, b_tn),
"alert_level_counts": dict(alert_levels),
"product_flag_counts": dict(product_flags),
"drought_risk_mean": _mean(drought_risks),
"flood_risk_mean": _mean(flood_risks),
"max_hazard_mean": _mean(max_risks),
"believed_p_mean": _mean(believed_list),
"believed_p_risky_mean": _mean(believed_risky),
"believed_p_clean_mean": _mean(believed_clean),
"believed_p_sep": _mean(believed_risky) - _mean(believed_clean),
"economics": {
"alert_value": av,
"false_alert_penalty": fap,
"miss_penalty": mp,
"rational_threshold": rational,
"ev_advisory_policy": adv_ev,
"ev_product_policy": prod_ev,
"ev_belief_policy": belief_ev,
"ev_always_alert": always_ev,
"ev_never_alert": never_ev,
"ev_oracle": oracle_ev,
"ev_always_per_ep": always_ev / max(n_episodes, 1),
"ev_never_per_ep": never_ev / max(n_episodes, 1),
"ev_product_per_ep": prod_ev / max(n_episodes, 1),
"ev_belief_per_ep": belief_ev / max(n_episodes, 1),
"ev_advisory_per_ep": adv_ev / max(n_episodes, 1),
"oracle_minus_always": oracle_ev - always_ev,
"oracle_minus_always_frac": (
(oracle_ev - always_ev) / max(abs(always_ev), 1e-9)
),
},
}
def _print_block(name: str, c: Dict[str, float]) -> None:
print(f"--- {name} ---")
print(
f" P={c['precision']:.3f} R={c['recall']:.3f} F1={c['f1']:.3f} "
f"alert_rate={c['alert_rate']:.3f}"
)
print(
f" tp={int(c['tp'])} fp={int(c['fp'])} "
f"fn={int(c['fn'])} tn={int(c['tn'])}"
)
def _print_result(label: str, m: Dict[str, Any], args: argparse.Namespace) -> None:
print(f"\n===== {label} =====")
print(f"policy={m['policy']}")
if getattr(args, "checkpoint", None) and m["policy"] == "checkpoint":
print(f"checkpoint: {args.checkpoint}")
print(
f"n_zones={m['n_zones']} max_steps={args.max_steps} "
f"n_episodes={m['n_episodes']}"
)
print(
f"mean_ep_len = {m['mean_len']:.2f} "
f"(structural ceiling ~{m['n_zones'] + 1})"
)
print(
f"risky base rate empirical={m['empirical_base_rate']:.3f} "
f"analytic_corr≈{m['analytic_base_rate_corr']:.3f} "
f"analytic_iid={m['analytic_base_rate_iid']:.3f} "
f"(clean={m['clean_episode_ratio']:.3f} rho={m['event_spatial_correlation']:.3f})"
)
print(
f"hazard means drought={m['drought_risk_mean']:.3f} "
f"flood={m['flood_risk_mean']:.3f} max={m['max_hazard_mean']:.3f}"
)
print(
f"believed_p mean={m['believed_p_mean']:.3f} "
f"risky={m['believed_p_risky_mean']:.3f} "
f"clean={m['believed_p_clean_mean']:.3f} "
f"sep={m['believed_p_sep']:+.3f} "
f"bar={m['belief_bar']:.3f}"
)
print(f"alert_level counts: {m['alert_level_counts']}")
print(f"product_actionable counts: {m['product_flag_counts']}")
print()
_print_block("ADVISORY+ (legacy; often saturates)", m["advisory"])
print()
_print_block(
"PRODUCT GATE (scorer on episode obs — policy-insensitive by design)",
m["product"],
)
print()
_print_block(
f"BELIEF GATE (believed_p ≥ {m['belief_bar']:.2f} — policy-sensitive)",
m["belief"],
)
print()
e = m["economics"]
print("--- day-level EV ---")
print(
f" economics: alert={e['alert_value']} false={e['false_alert_penalty']} "
f"miss={e['miss_penalty']}"
)
print(
f" always_alert total_ev={e['ev_always_alert']:+.1f} "
f"per_ep={e['ev_always_per_ep']:+.3f}"
)
print(
f" never_alert total_ev={e['ev_never_alert']:+.1f} "
f"per_ep={e['ev_never_per_ep']:+.3f}"
)
print(
f" product_gate total_ev={e['ev_product_policy']:+.1f} "
f"per_ep={e['ev_product_per_ep']:+.3f}"
)
print(
f" belief_gate total_ev={e['ev_belief_policy']:+.1f} "
f"per_ep={e['ev_belief_per_ep']:+.3f}"
)
print(f" oracle total_ev={e['ev_oracle']:+.1f}")
print(
f" oracle−always = {e['oracle_minus_always']:+.1f} "
f"({100 * e['oracle_minus_always_frac']:.1f}% of |always|)"
)
def _print_comparison(trained: Dict[str, Any], zero: Dict[str, Any]) -> None:
print("\n===== POLICY SENSITIVITY =====")
tp, zp = trained["product"], zero["product"]
tb, zb = trained["belief"], zero["belief"]
print("product gate (expect IDENTICAL — scorer on episode obs):")
print(
f" trained P={tp['precision']:.3f} R={tp['recall']:.3f} "
f"F1={tp['f1']:.3f} mean_len={trained['mean_len']:.2f}"
)
print(
f" zero_inspect P={zp['precision']:.3f} R={zp['recall']:.3f} "
f"F1={zp['f1']:.3f} mean_len={zero['mean_len']:.2f}"
)
same_prod = (
int(tp["tp"]) == int(zp["tp"])
and int(tp["fp"]) == int(zp["fp"])
and int(tp["fn"]) == int(zp["fn"])
and int(tp["tn"]) == int(zp["tn"])
)
print(f" product identical: {same_prod} (expected True)")
print()
print("belief gate (should DIFFER if inspections change beliefs):")
print(
f" trained P={tb['precision']:.3f} R={tb['recall']:.3f} "
f"F1={tb['f1']:.3f} sep={trained['believed_p_sep']:+.3f}"
)
print(
f" zero_inspect P={zb['precision']:.3f} R={zb['recall']:.3f} "
f"F1={zb['f1']:.3f} sep={zero['believed_p_sep']:+.3f}"
)
same_bel = (
int(tb["tp"]) == int(zb["tp"])
and int(tb["fp"]) == int(zb["fp"])
and int(tb["fn"]) == int(zb["fn"])
and int(tb["tn"]) == int(zb["tn"])
)
print(f" belief identical: {same_bel}")
print()
if same_prod and not same_bel:
print(
" RESULT: product policy-insensitive (expected); belief gate differs →\n"
" inspections change terminal belief. Use belief metrics + ep_len as\n"
" the synthetic skill signal."
)
elif same_prod and same_bel:
print(
" RESULT: both product and belief match zero_inspect.\n"
" Either inspections do not move belief enough, or reset-time\n"
" composite blend already encodes the event (common). Check\n"
" believed_p sep and ep_len; real L1 eval remains the decisive test."
)
else:
print(" RESULT: product differs (unexpected — check env product path).")
e = trained["economics"]
print()
print("--- calibration pressure ---")
print(
f" oracle−always = {e['oracle_minus_always']:+.1f} "
f"({100 * e['oracle_minus_always_frac']:.1f}% of |always EV|)"
)
if abs(e["oracle_minus_always_frac"]) < 0.20:
print(
" GAP < 20%: always-alert near oracle under this base rate.\n"
" Prefer higher clean_episode_ratio for selective policies."
)
def main() -> None:
p = argparse.ArgumentParser(
description="Alert-quality eval (product + belief gate + matched train config)"
)
p.add_argument("--checkpoint", default=None)
p.add_argument(
"--policy",
choices=("checkpoint", "zero_inspect"),
default="checkpoint",
)
p.add_argument("--also-zero-inspect", action="store_true")
p.add_argument("--n-zones", type=int, required=True)
p.add_argument("--max-steps", type=int, required=True)
p.add_argument("--n-episodes", type=int, default=200)
p.add_argument("--eval-seed-base", type=int, default=500_000)
p.add_argument("--device", default="auto")
p.add_argument(
"--clean-episode-ratio",
type=float,
default=0.90,
help="Must match train (default 0.90 for correlated runs)",
)
p.add_argument(
"--event-spatial-correlation",
type=float,
default=0.85,
help="Must match train",
)
args = p.parse_args()
if args.policy == "checkpoint" and not args.checkpoint:
p.error("--checkpoint is required when --policy checkpoint")
config = ForecastConfig(
n_zones=args.n_zones,
max_steps=args.max_steps,
soft_reset=True,
clean_episode_ratio=args.clean_episode_ratio,
event_spatial_correlation=args.event_spatial_correlation,
)
model = None
if args.policy == "checkpoint" or args.also_zero_inspect:
if args.checkpoint:
from sb3_contrib import MaskablePPO
model = MaskablePPO.load(args.checkpoint, device=args.device)
trained_m = None
if args.policy == "checkpoint":
trained_m = evaluate(
config,
args.n_episodes,
args.eval_seed_base,
model=model,
policy="checkpoint",
)
_print_result("TRAINED CHECKPOINT", trained_m, args)
if args.policy == "zero_inspect" or args.also_zero_inspect:
zero_m = evaluate(
config,
args.n_episodes,
args.eval_seed_base,
model=None,
policy="zero_inspect",
)
_print_result("ZERO-INSPECT CONTROL", zero_m, args)
if trained_m is not None:
_print_comparison(trained_m, zero_m)
elif trained_m is not None:
e = trained_m["economics"]
print()
print("--- interpretation hints ---")
print(
" Product gate is policy-insensitive on synthetic data by design.\n"
" Re-run with --also-zero-inspect to compare belief gate + ep_len."
)
print(
f" oracle−always = {e['oracle_minus_always']:+.1f} "
f"({100 * e['oracle_minus_always_frac']:.1f}% of |always|)"
)
if __name__ == "__main__":
main()