"""Compute paper verification metrics and plot performance and reliability diagrams.""" import json from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import yaml from sklearn.metrics import average_precision_score, roc_auc_score ROOT = Path(__file__).resolve().parents[1] def curves(probability, target, threshold_count): thresholds = np.linspace(0, 1, threshold_count) pod, pofd, sr, far, csi, bias = [], [], [], [], [], [] for threshold in thresholds: forecast = probability >= threshold event = target == 1 hits = np.sum(forecast & event); false_alarms = np.sum(forecast & ~event) misses = np.sum(~forecast & event); negatives = np.sum(~forecast & ~event) pod.append(hits / max(hits + misses, 1)); pofd.append(false_alarms / max(false_alarms + negatives, 1)) sr.append(hits / max(hits + false_alarms, 1)); far.append(false_alarms / max(hits + false_alarms, 1)) csi.append(hits / max(hits + false_alarms + misses, 1)); bias.append((hits + false_alarms) / max(hits + misses, 1)) return {key: np.asarray(value) for key, value in (("threshold", thresholds), ("pod", pod), ("pofd", pofd), ("sr", sr), ("far", far), ("csi", csi), ("bias", bias))} def reliability(probability, target, bins): edges = np.linspace(0, 1, bins + 1) index = np.minimum(np.digitize(probability, edges[1:-1]), bins - 1) records, component = [], 0.0 for bin_index in range(bins): mask = index == bin_index if not mask.any(): continue forecast_mean, observed_frequency = float(probability[mask].mean()), float(target[mask].mean()) component += mask.mean() * (forecast_mean - observed_frequency) ** 2 records.append({"count": int(mask.sum()), "forecast_probability": forecast_mean, "observed_frequency": observed_frequency}) return records, float(component) def metrics(probability, target, threshold_count, bins): curve = curves(probability, target, threshold_count) prevalence = float(target.mean()) auc = float(roc_auc_score(target, probability)) if 0 < target.sum() < len(target) else 0.5 aupdc = float(average_precision_score(target, probability)) if target.sum() else 0.0 positives, negatives = int(target.sum()), int(len(target) - target.sum()) minimum_aupdc = float(np.mean(np.arange(1, positives + 1) / (np.arange(1, positives + 1) + negatives))) if positives else 0.0 best = int(np.argmax(curve["csi"])); max_csi = float(curve["csi"][best]) brier = float(np.mean((probability - target) ** 2)) reference = prevalence * (1 - prevalence) records, reliability_component = reliability(probability, target, bins) return {"samples": len(target), "event_rate": prevalence, "auc": auc, "aupdc": aupdc, "minimum_aupdc": minimum_aupdc, "naupdc": (aupdc - minimum_aupdc) / max(1 - minimum_aupdc, 1e-12), "max_csi": max_csi, "ncsi": (max_csi - prevalence) / max(1 - prevalence, 1e-12), "max_csi_threshold": float(curve["threshold"][best]), "pod": float(curve["pod"][best]), "pofd": float(curve["pofd"][best]), "sr": float(curve["sr"][best]), "far": float(curve["far"][best]), "csi": max_csi, "bias": float(curve["bias"][best]), "brier_score": brier, "brier_skill_score": 1 - brier / reference if reference > 0 else 0.0, "reliability_component": reliability_component, "reliability_bins": records}, curve def main(): config = yaml.safe_load((ROOT / "conf/config.yaml").read_text()) data = np.load(ROOT / config["paths"]["inference_dir"] / "predictions.npz") if str(data["format_version"]) != config["data"]["format_version"]: raise ValueError("incompatible prediction format") probabilities, targets, groups = data["probabilities"], data["targets"], data["lead_group"] if probabilities.shape != targets.shape or probabilities.shape[1:] != (3,): raise ValueError("probabilities and targets must have shape [N,3]") hazards, group_names = data["hazards"].tolist(), data["lead_group_names"].tolist() report, all_curves = {}, {} for group_index, group_name in enumerate(group_names): report[group_name] = {} mask = groups == group_index for hazard_index, hazard in enumerate(hazards): result, curve = metrics(probabilities[mask, hazard_index], targets[mask, hazard_index], int(config["evaluation"]["threshold_count"]), int(config["evaluation"]["probability_bins"])) report[group_name][hazard] = result; all_curves[(group_name, hazard)] = curve numeric = [value for group in report.values() for hazard in group.values() for value in hazard.values() if isinstance(value, (int, float))] if not np.isfinite(numeric).all(): raise FloatingPointError("evaluation contains NaN or Inf") output = ROOT / config["paths"]["evaluation_dir"] output.mkdir(parents=True, exist_ok=True) (output / "metrics.json").write_text(json.dumps({"by_lead_group_and_hazard": report, "metric_protocol": "Flora et al. AUC, weighted-average-precision AUPDC, minimum-AUPDC NAUPDC, climatology-normalized max CSI, and Brier verification"}, indent=2) + "\n") colors = {"tornado": "#b3261e", "hail": "#2e7d32", "wind": "#1565c0"} figure, axes = plt.subplots(1, 2, figsize=(10, 4.5), sharex=True, sharey=True) for axis, group_name in zip(axes, group_names): for hazard in hazards: curve = all_curves[(group_name, hazard)] axis.plot(curve["sr"], curve["pod"], color=colors[hazard], label=hazard) axis.set(xlim=(0, 1), ylim=(0, 1), xlabel="Success ratio", ylabel="Probability of detection", title=group_name.replace("_", " ").title()) axis.grid(alpha=0.25); axis.legend() figure.tight_layout(); figure.savefig(output / "performance.png", dpi=160); plt.close(figure) figure, axes = plt.subplots(1, 2, figsize=(10, 4.5), sharex=True, sharey=True) for axis, group_name in zip(axes, group_names): axis.plot((0, 1), (0, 1), "k--", linewidth=1, label="perfect") for hazard in hazards: bins = report[group_name][hazard]["reliability_bins"] axis.plot([item["forecast_probability"] for item in bins], [item["observed_frequency"] for item in bins], marker="o", color=colors[hazard], label=hazard) axis.set(xlim=(0, 1), ylim=(0, 1), xlabel="Forecast probability", ylabel="Observed frequency", title=group_name.replace("_", " ").title()) axis.grid(alpha=0.25); axis.legend() figure.tight_layout(); figure.savefig(output / "reliability.png", dpi=160); plt.close(figure) print(f"evaluation={output.relative_to(ROOT)} groups={len(group_names)} hazards={len(hazards)}") if __name__ == "__main__": main()