Add dominance-calibrated CTT selector diagnostics
Browse files
workspace/scripts/eval_dominance_selector.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 16 |
+
|
| 17 |
+
import torch # noqa: E402
|
| 18 |
+
|
| 19 |
+
from cil.metrics import macro_micro_summary # noqa: E402
|
| 20 |
+
from cil.models import CTTConfig, ChartEncoder, TangentNormalizer, UtilityEnergy # noqa: E402
|
| 21 |
+
from scripts.eval_ctt_generated_rollout import load_chart_items # noqa: E402
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main(argv: list[str] | None = None) -> int:
|
| 25 |
+
parser = argparse.ArgumentParser(
|
| 26 |
+
description=(
|
| 27 |
+
"Calibrate a causal-dominance fallback rule on measured generated "
|
| 28 |
+
"candidate rollouts and evaluate it on a held-out measured rollout set."
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
parser.add_argument("--calibration-input", type=Path, required=True)
|
| 32 |
+
parser.add_argument("--calibration-target-index", type=Path, required=True)
|
| 33 |
+
parser.add_argument("--eval-input", type=Path, required=True)
|
| 34 |
+
parser.add_argument("--eval-target-index", type=Path, required=True)
|
| 35 |
+
parser.add_argument(
|
| 36 |
+
"--checkpoint-template",
|
| 37 |
+
default="runs/ctt_residual_full_seed{seed}/model.pt",
|
| 38 |
+
help="Template used to load the train-seed utility-energy checkpoint.",
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_dominance_val_to_test"))
|
| 41 |
+
parser.add_argument("--alpha", type=float, default=0.1)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--tau",
|
| 44 |
+
default="auto",
|
| 45 |
+
help=(
|
| 46 |
+
"Dominance threshold. Use a float, or 'auto' to choose the threshold "
|
| 47 |
+
"that maximizes selected success on the calibration split after "
|
| 48 |
+
"conformal residual subtraction."
|
| 49 |
+
),
|
| 50 |
+
)
|
| 51 |
+
parser.add_argument("--k", type=int, default=8)
|
| 52 |
+
parser.add_argument("--bootstrap-samples", type=int, default=1000)
|
| 53 |
+
args = parser.parse_args(argv)
|
| 54 |
+
|
| 55 |
+
if not 0.0 < args.alpha < 1.0:
|
| 56 |
+
parser.error("--alpha must be in (0, 1)")
|
| 57 |
+
if args.k <= 0:
|
| 58 |
+
parser.error("--k must be positive")
|
| 59 |
+
|
| 60 |
+
out_dir = args.out_dir
|
| 61 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
_write_provenance(out_dir, args)
|
| 63 |
+
|
| 64 |
+
calibrator = _DominanceScorer(args.checkpoint_template)
|
| 65 |
+
calibration_rows = _rows(json.loads(args.calibration_input.read_text()))
|
| 66 |
+
eval_rows = _rows(json.loads(args.eval_input.read_text()))
|
| 67 |
+
calibration_charts, calibration_index = _chart_map(args.calibration_target_index)
|
| 68 |
+
eval_charts, eval_index = _chart_map(args.eval_target_index)
|
| 69 |
+
|
| 70 |
+
calibration_cases = [
|
| 71 |
+
_dominance_case(row, calibration_charts, scorer=calibrator, k=args.k)
|
| 72 |
+
for row in calibration_rows
|
| 73 |
+
]
|
| 74 |
+
residual_quantile = _conformal_quantile(
|
| 75 |
+
[abs(case["measured_margin"] - case["predicted_margin"]) for case in calibration_cases],
|
| 76 |
+
alpha=args.alpha,
|
| 77 |
+
)
|
| 78 |
+
tau = (
|
| 79 |
+
_choose_tau(calibration_cases, residual_quantile=residual_quantile)
|
| 80 |
+
if args.tau == "auto"
|
| 81 |
+
else float(args.tau)
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
evaluated_cases = [
|
| 85 |
+
_evaluate_case(
|
| 86 |
+
_dominance_case(row, eval_charts, scorer=calibrator, k=args.k),
|
| 87 |
+
residual_quantile=residual_quantile,
|
| 88 |
+
tau=tau,
|
| 89 |
+
)
|
| 90 |
+
for row in eval_rows
|
| 91 |
+
]
|
| 92 |
+
calibration_eval_cases = [
|
| 93 |
+
_evaluate_case(case, residual_quantile=residual_quantile, tau=tau)
|
| 94 |
+
for case in calibration_cases
|
| 95 |
+
]
|
| 96 |
+
metric_names = sorted(
|
| 97 |
+
{
|
| 98 |
+
key
|
| 99 |
+
for row in evaluated_cases
|
| 100 |
+
for key, value in row.items()
|
| 101 |
+
if key not in {"chart_id", "task_id", "seed", "train_seed"}
|
| 102 |
+
and isinstance(value, (int, float))
|
| 103 |
+
and math.isfinite(float(value))
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
summary = {
|
| 107 |
+
name: macro_micro_summary(
|
| 108 |
+
evaluated_cases,
|
| 109 |
+
name,
|
| 110 |
+
bootstrap_samples=args.bootstrap_samples,
|
| 111 |
+
confidence=0.95,
|
| 112 |
+
)
|
| 113 |
+
for name in metric_names
|
| 114 |
+
}
|
| 115 |
+
metrics = {
|
| 116 |
+
"report_type": "dominance_calibrated_selector_eval",
|
| 117 |
+
"schema_version": 1,
|
| 118 |
+
"k": args.k,
|
| 119 |
+
"alpha": args.alpha,
|
| 120 |
+
"tau": tau,
|
| 121 |
+
"tau_mode": args.tau,
|
| 122 |
+
"residual_quantile": residual_quantile,
|
| 123 |
+
"calibration_input": str(args.calibration_input),
|
| 124 |
+
"eval_input": str(args.eval_input),
|
| 125 |
+
"calibration_target_content_hash": calibration_index.get("content_hash"),
|
| 126 |
+
"calibration_target_split_hash": calibration_index.get("split_hash"),
|
| 127 |
+
"eval_target_content_hash": eval_index.get("content_hash"),
|
| 128 |
+
"eval_target_split_hash": eval_index.get("split_hash"),
|
| 129 |
+
"num_calibration_rows": len(calibration_cases),
|
| 130 |
+
"num_eval_rows": len(evaluated_cases),
|
| 131 |
+
"calibration_summary": _simple_summary(calibration_eval_cases),
|
| 132 |
+
"eval_summary": _simple_summary(evaluated_cases),
|
| 133 |
+
"summary": summary,
|
| 134 |
+
"rows": evaluated_cases,
|
| 135 |
+
}
|
| 136 |
+
(out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
|
| 137 |
+
(out_dir / "metrics_by_task.json").write_text(
|
| 138 |
+
json.dumps(_group_means(evaluated_cases, "task_id", metric_names), indent=2, sort_keys=True)
|
| 139 |
+
+ "\n"
|
| 140 |
+
)
|
| 141 |
+
(out_dir / "metrics_by_seed.json").write_text(
|
| 142 |
+
json.dumps(_group_means(evaluated_cases, "seed", metric_names), indent=2, sort_keys=True)
|
| 143 |
+
+ "\n"
|
| 144 |
+
)
|
| 145 |
+
(out_dir / "table.tex").write_text(_table(metrics) + "\n")
|
| 146 |
+
(out_dir / "report.md").write_text(_report(metrics) + "\n")
|
| 147 |
+
(out_dir / "train.log").write_text(
|
| 148 |
+
"fit conformal residual quantile and tau on calibration measured rows only\n"
|
| 149 |
+
f"calibration_input={args.calibration_input}\n"
|
| 150 |
+
f"residual_quantile={residual_quantile:.6f}\n"
|
| 151 |
+
f"tau={tau:.6f}\n"
|
| 152 |
+
)
|
| 153 |
+
(out_dir / "eval.log").write_text(
|
| 154 |
+
"evaluated calibrated fallback rule on held-out measured rollout rows\n"
|
| 155 |
+
f"eval_input={args.eval_input}\n"
|
| 156 |
+
f"num_eval_rows={len(evaluated_cases)}\n"
|
| 157 |
+
)
|
| 158 |
+
print(json.dumps({"out_dir": str(out_dir), "tau": tau, "rows": len(evaluated_cases)}, indent=2))
|
| 159 |
+
return 0
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class _DominanceScorer:
|
| 163 |
+
def __init__(self, checkpoint_template: str) -> None:
|
| 164 |
+
self.checkpoint_template = checkpoint_template
|
| 165 |
+
self._models: dict[str, tuple[ChartEncoder, UtilityEnergy, TangentNormalizer, CTTConfig]] = {}
|
| 166 |
+
|
| 167 |
+
def base_score(self, row: dict[str, Any], chart: Any) -> float:
|
| 168 |
+
if "base_predicted_score" in row:
|
| 169 |
+
return float(row["base_predicted_score"])
|
| 170 |
+
seed = str(row.get("train_seed", "0"))
|
| 171 |
+
encoder, utility_energy, normalizer, config = self._model(seed)
|
| 172 |
+
with torch.no_grad():
|
| 173 |
+
feature = torch.as_tensor(chart.feature, dtype=torch.float32).unsqueeze(0)
|
| 174 |
+
z_chart = encoder(feature)
|
| 175 |
+
zero = torch.zeros((1, config.tangent_dim), dtype=torch.float32)
|
| 176 |
+
zero_norm = normalizer.transform(zero)
|
| 177 |
+
return float(utility_energy(z_chart, zero_norm).squeeze(0).item())
|
| 178 |
+
|
| 179 |
+
def _model(self, seed: str) -> tuple[ChartEncoder, UtilityEnergy, TangentNormalizer, CTTConfig]:
|
| 180 |
+
if seed in self._models:
|
| 181 |
+
return self._models[seed]
|
| 182 |
+
checkpoint_path = Path(self.checkpoint_template.format(seed=seed))
|
| 183 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 184 |
+
config = CTTConfig(**checkpoint["config"])
|
| 185 |
+
encoder = ChartEncoder(config.chart_feature_dim, output_dim=config.chart_dim)
|
| 186 |
+
utility_energy = UtilityEnergy(chart_dim=config.chart_dim, tangent_dim=config.tangent_dim)
|
| 187 |
+
encoder.load_state_dict(checkpoint["chart_encoder"])
|
| 188 |
+
utility_energy.load_state_dict(checkpoint["utility_energy"])
|
| 189 |
+
normalizer = TangentNormalizer.from_dict(checkpoint["normalizer"])
|
| 190 |
+
encoder.eval()
|
| 191 |
+
utility_energy.eval()
|
| 192 |
+
self._models[seed] = (encoder, utility_energy, normalizer, config)
|
| 193 |
+
return self._models[seed]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _dominance_case(
|
| 197 |
+
row: dict[str, Any],
|
| 198 |
+
charts: dict[str, Any],
|
| 199 |
+
*,
|
| 200 |
+
scorer: _DominanceScorer,
|
| 201 |
+
k: int,
|
| 202 |
+
) -> dict[str, Any]:
|
| 203 |
+
generated_utilities = [float(value) for value in row.get("generated_utilities", [])[:k]]
|
| 204 |
+
candidate_success = [float(bool(value)) for value in row.get("candidate_success", [])[:k]]
|
| 205 |
+
predicted_scores = [float(value) for value in row.get("predicted_scores", [])[:k]]
|
| 206 |
+
if not generated_utilities or not predicted_scores:
|
| 207 |
+
raise ValueError("dominance evaluation requires generated utilities and predicted scores")
|
| 208 |
+
chart_id = str(row.get("chart_id", row.get("group_id", "")))
|
| 209 |
+
if chart_id not in charts:
|
| 210 |
+
raise KeyError(f"chart_id {chart_id!r} not found in target index")
|
| 211 |
+
top_index = max(range(len(predicted_scores)), key=lambda index: predicted_scores[index])
|
| 212 |
+
base_score = scorer.base_score(row, charts[chart_id])
|
| 213 |
+
base_utility = float(row["base_utility"])
|
| 214 |
+
base_success = float(bool(row.get("base_success", False)))
|
| 215 |
+
selected_generated_utility = generated_utilities[top_index]
|
| 216 |
+
selected_generated_success = candidate_success[top_index]
|
| 217 |
+
proposal_oracle_utility = max(generated_utilities)
|
| 218 |
+
proposal_oracle_success = float(any(candidate_success))
|
| 219 |
+
hidden = [float(value) for value in row.get("hidden_chart_utilities", [])]
|
| 220 |
+
hidden_oracle_utility = max(hidden) if hidden else math.nan
|
| 221 |
+
hidden_oracle_success = float(any(value >= 1.0 for value in hidden)) if hidden else math.nan
|
| 222 |
+
predicted_margin = predicted_scores[top_index] - base_score
|
| 223 |
+
measured_margin = selected_generated_utility - base_utility
|
| 224 |
+
return {
|
| 225 |
+
"chart_id": chart_id,
|
| 226 |
+
"task_id": str(row.get("task_id", "unknown")),
|
| 227 |
+
"seed": str(row.get("seed", "unknown")),
|
| 228 |
+
"train_seed": str(row.get("train_seed", "unknown")),
|
| 229 |
+
"top_index": top_index,
|
| 230 |
+
"base_predicted_score": base_score,
|
| 231 |
+
"top_predicted_score": predicted_scores[top_index],
|
| 232 |
+
"predicted_margin": predicted_margin,
|
| 233 |
+
"measured_margin": measured_margin,
|
| 234 |
+
"base_utility": base_utility,
|
| 235 |
+
"base_success": base_success,
|
| 236 |
+
"top_generated_utility": selected_generated_utility,
|
| 237 |
+
"top_generated_success": selected_generated_success,
|
| 238 |
+
"proposal_oracle_utility": proposal_oracle_utility,
|
| 239 |
+
"proposal_oracle_success": proposal_oracle_success,
|
| 240 |
+
"hidden_chart_oracle_utility": hidden_oracle_utility,
|
| 241 |
+
"hidden_chart_oracle_success": hidden_oracle_success,
|
| 242 |
+
"outcome_ptr": float(any(value > base_utility for value in generated_utilities)),
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _evaluate_case(case: dict[str, Any], *, residual_quantile: float, tau: float) -> dict[str, Any]:
|
| 247 |
+
lcb = float(case["predicted_margin"]) - float(residual_quantile)
|
| 248 |
+
execute_generated = lcb > float(tau)
|
| 249 |
+
selected_utility = (
|
| 250 |
+
float(case["top_generated_utility"]) if execute_generated else float(case["base_utility"])
|
| 251 |
+
)
|
| 252 |
+
selected_success = (
|
| 253 |
+
float(case["top_generated_success"]) if execute_generated else float(case["base_success"])
|
| 254 |
+
)
|
| 255 |
+
proposal_oracle_utility = float(case["proposal_oracle_utility"])
|
| 256 |
+
proposal_oracle_success = float(case["proposal_oracle_success"])
|
| 257 |
+
hidden_utility = float(case["hidden_chart_oracle_utility"])
|
| 258 |
+
hidden_success = float(case["hidden_chart_oracle_success"])
|
| 259 |
+
output = dict(case)
|
| 260 |
+
output.update(
|
| 261 |
+
{
|
| 262 |
+
"lcb_margin": lcb,
|
| 263 |
+
"execute_generated": float(execute_generated),
|
| 264 |
+
"fallback_to_base": float(not execute_generated),
|
| 265 |
+
"coverage": float(execute_generated),
|
| 266 |
+
"fallback_rate": float(not execute_generated),
|
| 267 |
+
"selected_utility": selected_utility,
|
| 268 |
+
"selected_success": selected_success,
|
| 269 |
+
"selected_utility_gain_over_base": selected_utility - float(case["base_utility"]),
|
| 270 |
+
"selected_success_gain_over_base": selected_success - float(case["base_success"]),
|
| 271 |
+
"selector_regret": max(0.0, proposal_oracle_utility - selected_utility),
|
| 272 |
+
"branch_car": max(0.0, proposal_oracle_utility - selected_utility),
|
| 273 |
+
"success_selector_gap": max(0.0, proposal_oracle_success - selected_success),
|
| 274 |
+
"support_gap": max(0.0, hidden_utility - proposal_oracle_utility)
|
| 275 |
+
if math.isfinite(hidden_utility)
|
| 276 |
+
else math.nan,
|
| 277 |
+
"success_support_gap": max(0.0, hidden_success - proposal_oracle_success)
|
| 278 |
+
if math.isfinite(hidden_success)
|
| 279 |
+
else math.nan,
|
| 280 |
+
"success_total_car_to_hidden": max(0.0, hidden_success - selected_success)
|
| 281 |
+
if math.isfinite(hidden_success)
|
| 282 |
+
else math.nan,
|
| 283 |
+
}
|
| 284 |
+
)
|
| 285 |
+
return output
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _choose_tau(cases: list[dict[str, Any]], *, residual_quantile: float) -> float:
|
| 289 |
+
candidates = sorted({float(case["predicted_margin"]) - float(residual_quantile) for case in cases})
|
| 290 |
+
thresholds = [min(candidates, default=0.0) - 1.0, *candidates, max(candidates, default=0.0) + 1.0]
|
| 291 |
+
best_tau = thresholds[0]
|
| 292 |
+
best_key: tuple[float, float, float] | None = None
|
| 293 |
+
for tau in thresholds:
|
| 294 |
+
evaluated = [_evaluate_case(case, residual_quantile=residual_quantile, tau=tau) for case in cases]
|
| 295 |
+
summary = _simple_summary(evaluated)
|
| 296 |
+
# Maximize selected success, then selected utility, then coverage.
|
| 297 |
+
key = (
|
| 298 |
+
float(summary.get("selected_success", 0.0) or 0.0),
|
| 299 |
+
float(summary.get("selected_utility", 0.0) or 0.0),
|
| 300 |
+
float(summary.get("coverage", 0.0) or 0.0),
|
| 301 |
+
)
|
| 302 |
+
if best_key is None or key > best_key:
|
| 303 |
+
best_key = key
|
| 304 |
+
best_tau = tau
|
| 305 |
+
return float(best_tau)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _conformal_quantile(values: list[float], *, alpha: float) -> float:
|
| 309 |
+
clean = sorted(float(value) for value in values if math.isfinite(float(value)))
|
| 310 |
+
if not clean:
|
| 311 |
+
raise ValueError("cannot calibrate dominance without residuals")
|
| 312 |
+
index = min(len(clean) - 1, max(0, math.ceil((1.0 - alpha) * (len(clean) + 1)) - 1))
|
| 313 |
+
return clean[index]
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _chart_map(index_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 317 |
+
charts, index = load_chart_items(
|
| 318 |
+
index_path,
|
| 319 |
+
max_charts=None,
|
| 320 |
+
require_positive=True,
|
| 321 |
+
include_hidden=True,
|
| 322 |
+
include_metadata=True,
|
| 323 |
+
)
|
| 324 |
+
return {chart.chart_id: chart for chart in charts}, index
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _rows(payload: Any) -> list[dict[str, Any]]:
|
| 328 |
+
rows = payload.get("rows", payload) if isinstance(payload, dict) else payload
|
| 329 |
+
if not isinstance(rows, list):
|
| 330 |
+
raise SystemExit("input must be a JSON list or object with rows")
|
| 331 |
+
return rows
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _simple_summary(rows: list[dict[str, Any]]) -> dict[str, float | None]:
|
| 335 |
+
keys = [
|
| 336 |
+
"base_success",
|
| 337 |
+
"selected_success",
|
| 338 |
+
"proposal_oracle_success",
|
| 339 |
+
"hidden_chart_oracle_success",
|
| 340 |
+
"selected_success_gain_over_base",
|
| 341 |
+
"coverage",
|
| 342 |
+
"fallback_rate",
|
| 343 |
+
"outcome_ptr",
|
| 344 |
+
"success_support_gap",
|
| 345 |
+
"success_selector_gap",
|
| 346 |
+
"base_utility",
|
| 347 |
+
"selected_utility",
|
| 348 |
+
"proposal_oracle_utility",
|
| 349 |
+
"hidden_chart_oracle_utility",
|
| 350 |
+
"support_gap",
|
| 351 |
+
"selector_regret",
|
| 352 |
+
]
|
| 353 |
+
return {key: _mean([row.get(key) for row in rows]) for key in keys}
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _group_means(
|
| 357 |
+
rows: list[dict[str, Any]],
|
| 358 |
+
key: str,
|
| 359 |
+
metric_names: list[str],
|
| 360 |
+
) -> dict[str, dict[str, float]]:
|
| 361 |
+
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 362 |
+
for row in rows:
|
| 363 |
+
grouped[str(row.get(key, "unknown"))].append(row)
|
| 364 |
+
output: dict[str, dict[str, float]] = {}
|
| 365 |
+
for group, group_rows in sorted(grouped.items()):
|
| 366 |
+
payload: dict[str, float] = {}
|
| 367 |
+
for metric in metric_names:
|
| 368 |
+
value = _mean([row.get(metric) for row in group_rows])
|
| 369 |
+
if value is not None:
|
| 370 |
+
payload[metric] = value
|
| 371 |
+
output[group] = payload
|
| 372 |
+
return output
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _mean(values: list[Any]) -> float | None:
|
| 376 |
+
clean = [
|
| 377 |
+
float(value)
|
| 378 |
+
for value in values
|
| 379 |
+
if isinstance(value, (int, float)) and math.isfinite(float(value))
|
| 380 |
+
]
|
| 381 |
+
return sum(clean) / len(clean) if clean else None
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _table(metrics: dict[str, Any]) -> str:
|
| 385 |
+
summary = metrics["eval_summary"]
|
| 386 |
+
lines = [
|
| 387 |
+
"% Auto-generated by scripts/eval_dominance_selector.py",
|
| 388 |
+
"\\begin{tabular}{lrrrrrrrr}",
|
| 389 |
+
"\\toprule",
|
| 390 |
+
"Rows & Coverage & Fallback & Base succ. & Selected succ. & Oracle succ. & OutcomePTR & Succ. support gap & Succ. selector gap \\\\",
|
| 391 |
+
"\\midrule",
|
| 392 |
+
f"{metrics['num_eval_rows']} & {_fmt(summary.get('coverage'))} & "
|
| 393 |
+
f"{_fmt(summary.get('fallback_rate'))} & {_fmt(summary.get('base_success'))} & "
|
| 394 |
+
f"{_fmt(summary.get('selected_success'))} & {_fmt(summary.get('proposal_oracle_success'))} & "
|
| 395 |
+
f"{_fmt(summary.get('outcome_ptr'))} & {_fmt(summary.get('success_support_gap'))} & "
|
| 396 |
+
f"{_fmt(summary.get('success_selector_gap'))} \\\\",
|
| 397 |
+
"\\bottomrule",
|
| 398 |
+
"\\end{tabular}",
|
| 399 |
+
]
|
| 400 |
+
return "\n".join(lines)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _report(metrics: dict[str, Any]) -> str:
|
| 404 |
+
summary = metrics["eval_summary"]
|
| 405 |
+
calibration = metrics["calibration_summary"]
|
| 406 |
+
lines = [
|
| 407 |
+
"# Dominance-Calibrated CTT Selector",
|
| 408 |
+
"",
|
| 409 |
+
f"Calibration rows: `{metrics['num_calibration_rows']}`",
|
| 410 |
+
f"Eval rows: `{metrics['num_eval_rows']}`",
|
| 411 |
+
f"Alpha: `{metrics['alpha']}`",
|
| 412 |
+
f"Residual quantile: `{metrics['residual_quantile']:.6f}`",
|
| 413 |
+
f"Tau: `{metrics['tau']:.6f}` (`{metrics['tau_mode']}`)",
|
| 414 |
+
"",
|
| 415 |
+
"The threshold is fit on calibration rows only. Eval outcomes are used only for reporting.",
|
| 416 |
+
"",
|
| 417 |
+
"| Split | Coverage | Fallback | Base success | Selected success | Proposal oracle | OutcomePTR | Success support gap | Success selector gap |",
|
| 418 |
+
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 419 |
+
f"| calibration | {_fmt(calibration.get('coverage'))} | {_fmt(calibration.get('fallback_rate'))} | "
|
| 420 |
+
f"{_fmt(calibration.get('base_success'))} | {_fmt(calibration.get('selected_success'))} | "
|
| 421 |
+
f"{_fmt(calibration.get('proposal_oracle_success'))} | {_fmt(calibration.get('outcome_ptr'))} | "
|
| 422 |
+
f"{_fmt(calibration.get('success_support_gap'))} | {_fmt(calibration.get('success_selector_gap'))} |",
|
| 423 |
+
f"| eval | {_fmt(summary.get('coverage'))} | {_fmt(summary.get('fallback_rate'))} | "
|
| 424 |
+
f"{_fmt(summary.get('base_success'))} | {_fmt(summary.get('selected_success'))} | "
|
| 425 |
+
f"{_fmt(summary.get('proposal_oracle_success'))} | {_fmt(summary.get('outcome_ptr'))} | "
|
| 426 |
+
f"{_fmt(summary.get('success_support_gap'))} | {_fmt(summary.get('success_selector_gap'))} |",
|
| 427 |
+
"",
|
| 428 |
+
"This is a calibrated fallback diagnostic. It is not a final safety claim because unsafe-contact labels are not measured yet.",
|
| 429 |
+
]
|
| 430 |
+
return "\n".join(lines)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
|
| 434 |
+
(out_dir / "config.yaml").write_text(
|
| 435 |
+
"\n".join(f"{key}: {value}" for key, value in sorted(vars(args).items())) + "\n"
|
| 436 |
+
)
|
| 437 |
+
(out_dir / "command.txt").write_text(
|
| 438 |
+
"python scripts/eval_dominance_selector.py " + " ".join(sys.argv[1:]) + "\n"
|
| 439 |
+
)
|
| 440 |
+
(out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
|
| 441 |
+
hashes = {
|
| 442 |
+
"calibration_input": _sha256(args.calibration_input),
|
| 443 |
+
"calibration_target_index": _sha256(args.calibration_target_index),
|
| 444 |
+
"eval_input": _sha256(args.eval_input),
|
| 445 |
+
"eval_target_index": _sha256(args.eval_target_index),
|
| 446 |
+
}
|
| 447 |
+
(out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
|
| 448 |
+
(out_dir / "split_hash.txt").write_text(
|
| 449 |
+
json.dumps(
|
| 450 |
+
{
|
| 451 |
+
"calibration_target_index": _index_hash(args.calibration_target_index),
|
| 452 |
+
"eval_target_index": _index_hash(args.eval_target_index),
|
| 453 |
+
},
|
| 454 |
+
indent=2,
|
| 455 |
+
sort_keys=True,
|
| 456 |
+
)
|
| 457 |
+
+ "\n"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _index_hash(path: Path) -> dict[str, Any]:
|
| 462 |
+
payload = json.loads(path.read_text())
|
| 463 |
+
return {
|
| 464 |
+
"split": payload.get("split"),
|
| 465 |
+
"content_hash": payload.get("content_hash"),
|
| 466 |
+
"split_hash": payload.get("split_hash"),
|
| 467 |
+
"retrieval_index_allowed": payload.get("retrieval_index_allowed"),
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _sha256(path: Path) -> str:
|
| 472 |
+
import hashlib
|
| 473 |
+
|
| 474 |
+
h = hashlib.sha256()
|
| 475 |
+
h.update(path.read_bytes())
|
| 476 |
+
return h.hexdigest()
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _fmt(value: Any) -> str:
|
| 480 |
+
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
| 481 |
+
return "n/a"
|
| 482 |
+
return f"{float(value):.4f}"
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _run(command: list[str]) -> str:
|
| 486 |
+
try:
|
| 487 |
+
return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
|
| 488 |
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 489 |
+
return ""
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
if __name__ == "__main__":
|
| 493 |
+
raise SystemExit(main())
|