anhtld commited on
Commit
aab697b
·
verified ·
1 Parent(s): 664892c

ctt artifacts 2026-07-02 workspace/scripts/eval_metrics.py

Browse files
Files changed (1) hide show
  1. workspace/scripts/eval_metrics.py +337 -0
workspace/scripts/eval_metrics.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import sys
8
+ from collections import defaultdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
13
+ if str(PROJECT_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(PROJECT_ROOT))
15
+
16
+ from cil.metrics import ( # noqa: E402
17
+ MetricInputError,
18
+ branch_car,
19
+ macro_micro_summary,
20
+ measured_support_gap,
21
+ negative_near_at_threshold,
22
+ outcome_ptr_at_k,
23
+ pairwise_causal_dominance_ece,
24
+ positives_closer_than_negatives,
25
+ proxy_positive_tangent_coverage_at_k,
26
+ proxy_support_distance,
27
+ selector_regret_at_k,
28
+ )
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = argparse.ArgumentParser(
33
+ description=(
34
+ "Evaluate CIL/CTT metrics while keeping measured outcome metrics "
35
+ "separate from distance-only proxy metrics."
36
+ )
37
+ )
38
+ parser.add_argument("--input", type=Path, required=True)
39
+ parser.add_argument("--out-dir", type=Path, required=True)
40
+ parser.add_argument("--mode", choices=("measured", "proxy"), required=True)
41
+ parser.add_argument("--k", type=int, default=16)
42
+ parser.add_argument("--epsilon", type=float, default=0.0)
43
+ parser.add_argument("--thresholds", default="0.20,0.40")
44
+ parser.add_argument("--bootstrap-samples", type=int, default=1000)
45
+ parser.add_argument("--confidence", type=float, default=0.95)
46
+ args = parser.parse_args(argv)
47
+
48
+ if args.k <= 0:
49
+ parser.error("--k must be positive")
50
+ thresholds = _parse_thresholds(args.thresholds)
51
+ payload = json.loads(args.input.read_text())
52
+ rows = payload.get("rows", payload) if isinstance(payload, dict) else payload
53
+ if not isinstance(rows, list):
54
+ parser.error("input must be a JSON list or an object with a rows list")
55
+
56
+ metric_rows = []
57
+ for index, row in enumerate(rows):
58
+ if not isinstance(row, dict):
59
+ raise MetricInputError(f"row {index} must be an object")
60
+ metric_rows.append(
61
+ _measured_row(row, k=args.k, epsilon=args.epsilon)
62
+ if args.mode == "measured"
63
+ else _proxy_row(row, k=args.k, thresholds=thresholds)
64
+ )
65
+
66
+ metric_names = sorted(
67
+ {
68
+ key
69
+ for row in metric_rows
70
+ for key, value in row.items()
71
+ if key not in {"task_id", "seed", "chart_id", "mode"}
72
+ and isinstance(value, (int, float))
73
+ and math.isfinite(float(value))
74
+ }
75
+ )
76
+ summary = {
77
+ name: macro_micro_summary(
78
+ metric_rows,
79
+ name,
80
+ bootstrap_samples=args.bootstrap_samples,
81
+ confidence=args.confidence,
82
+ )
83
+ for name in metric_names
84
+ }
85
+
86
+ out_dir = args.out_dir
87
+ out_dir.mkdir(parents=True, exist_ok=True)
88
+ (out_dir / "metrics.json").write_text(
89
+ json.dumps(
90
+ {
91
+ "mode": args.mode,
92
+ "k": args.k,
93
+ "epsilon": args.epsilon,
94
+ "thresholds": thresholds,
95
+ "num_rows": len(metric_rows),
96
+ "rows": metric_rows,
97
+ "summary": summary,
98
+ },
99
+ indent=2,
100
+ sort_keys=True,
101
+ )
102
+ + "\n"
103
+ )
104
+ (out_dir / "metrics_by_task.json").write_text(
105
+ json.dumps(_group_means(metric_rows, "task_id", metric_names), indent=2, sort_keys=True)
106
+ + "\n"
107
+ )
108
+ (out_dir / "metrics_by_seed.json").write_text(
109
+ json.dumps(_group_means(metric_rows, "seed", metric_names), indent=2, sort_keys=True)
110
+ + "\n"
111
+ )
112
+ (out_dir / "table.tex").write_text(_latex_table(summary) + "\n")
113
+ (out_dir / "report.md").write_text(_markdown_report(args.mode, args.k, summary) + "\n")
114
+ print(json.dumps({"out_dir": str(out_dir), "num_rows": len(metric_rows)}, indent=2))
115
+ return 0
116
+
117
+
118
+ def _measured_row(row: dict[str, Any], *, k: int, epsilon: float) -> dict[str, Any]:
119
+ if not bool(row.get("candidates_evaluated", False)):
120
+ raise MetricInputError(
121
+ "measured mode requires candidates_evaluated=true for every row; "
122
+ "distance-only rows must use --mode proxy"
123
+ )
124
+ utilities = _numbers(row, "generated_utilities")
125
+ if not utilities:
126
+ raise MetricInputError("measured rows require generated_utilities")
127
+ selected_index = int(row.get("selected_index", 0))
128
+ hidden = _numbers(row, "hidden_chart_utilities", required=False)
129
+ selected_utility = utilities[selected_index]
130
+ prefix = utilities[:k]
131
+ output = _base_row(row, mode="measured")
132
+ output[f"outcome_ptr_at_{k}"] = outcome_ptr_at_k(
133
+ utilities,
134
+ _number(row, "base_utility"),
135
+ epsilon=epsilon,
136
+ k=k,
137
+ candidates_evaluated=True,
138
+ )
139
+ output[f"selector_regret_at_{k}"] = selector_regret_at_k(
140
+ utilities,
141
+ selected_index=selected_index,
142
+ k=k,
143
+ candidates_evaluated=True,
144
+ )
145
+ output[f"branch_car_at_{k}"] = branch_car(max(prefix), selected_utility)
146
+ if hidden:
147
+ output[f"support_gap_at_{k}"] = measured_support_gap(
148
+ max(hidden),
149
+ max(prefix),
150
+ candidates_evaluated=True,
151
+ )
152
+ predicted = _numbers(row, "predicted_scores", required=False)
153
+ if predicted and len(predicted) >= len(utilities):
154
+ ece = pairwise_causal_dominance_ece(predicted[: len(utilities)], utilities)
155
+ output["pairwise_causal_calibration_ece"] = ece["ece"]
156
+ return output
157
+
158
+
159
+ def _proxy_row(row: dict[str, Any], *, k: int, thresholds: list[float]) -> dict[str, Any]:
160
+ generated = _matrix(row, "generated_tangents")
161
+ positives = _matrix(row, "positive_tangents")
162
+ negatives = _matrix(row, "negative_tangents", required=False)
163
+ output = _base_row(row, mode="proxy")
164
+ for threshold in thresholds:
165
+ suffix = _threshold_suffix(threshold)
166
+ output[f"pptc_at_{k}_thr_{suffix}"] = proxy_positive_tangent_coverage_at_k(
167
+ generated,
168
+ positives,
169
+ threshold=threshold,
170
+ k=k,
171
+ )
172
+ output[f"negative_near_at_{k}_thr_{suffix}"] = negative_near_at_threshold(
173
+ generated,
174
+ negatives,
175
+ threshold=threshold,
176
+ k=k,
177
+ )
178
+ distance = proxy_support_distance(generated, positives, k=k)
179
+ if distance is not None:
180
+ output[f"proxy_support_distance_at_{k}"] = distance
181
+ closer = positives_closer_than_negatives(generated, positives, negatives, k=k)
182
+ if closer is not None:
183
+ output[f"pos_closer_than_neg_at_{k}"] = closer
184
+ output[f"candidate_diversity_at_{k}"] = _mean_pairwise_distance(generated[:k])
185
+ output[f"collapse_rate_at_{k}"] = _collapse_rate(generated[:k])
186
+ return output
187
+
188
+
189
+ def _base_row(row: dict[str, Any], *, mode: str) -> dict[str, Any]:
190
+ return {
191
+ "mode": mode,
192
+ "chart_id": str(row.get("chart_id", row.get("group_id", "unknown"))),
193
+ "task_id": str(row.get("task_id", "unknown")),
194
+ "seed": str(row.get("seed", "unknown")),
195
+ }
196
+
197
+
198
+ def _numbers(row: dict[str, Any], key: str, *, required: bool = True) -> list[float]:
199
+ values = row.get(key)
200
+ if values is None:
201
+ if required:
202
+ raise MetricInputError(f"row requires {key}")
203
+ return []
204
+ if not isinstance(values, list):
205
+ raise MetricInputError(f"{key} must be a list")
206
+ return [float(value) for value in values]
207
+
208
+
209
+ def _number(row: dict[str, Any], key: str) -> float:
210
+ if key not in row:
211
+ raise MetricInputError(f"row requires {key}")
212
+ return float(row[key])
213
+
214
+
215
+ def _matrix(row: dict[str, Any], key: str, *, required: bool = True) -> list[list[float]]:
216
+ values = row.get(key)
217
+ if values is None:
218
+ if required:
219
+ raise MetricInputError(f"row requires {key}")
220
+ return []
221
+ if not isinstance(values, list):
222
+ raise MetricInputError(f"{key} must be a list of vectors")
223
+ return [[float(item) for item in vector] for vector in values]
224
+
225
+
226
+ def _parse_thresholds(raw: str) -> list[float]:
227
+ values = [float(item.strip()) for item in raw.split(",") if item.strip()]
228
+ if not values or any(value < 0.0 for value in values):
229
+ raise ValueError("--thresholds must contain non-negative values")
230
+ return values
231
+
232
+
233
+ def _threshold_suffix(value: float) -> str:
234
+ return f"{value:.2f}".replace(".", "p")
235
+
236
+
237
+ def _group_means(
238
+ rows: list[dict[str, Any]],
239
+ key: str,
240
+ metric_names: list[str],
241
+ ) -> dict[str, dict[str, float]]:
242
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
243
+ for row in rows:
244
+ grouped[str(row.get(key, "unknown"))].append(row)
245
+ output: dict[str, dict[str, float]] = {}
246
+ for group, group_rows in sorted(grouped.items()):
247
+ payload: dict[str, float] = {}
248
+ for metric in metric_names:
249
+ values = [
250
+ float(row[metric])
251
+ for row in group_rows
252
+ if isinstance(row.get(metric), (int, float))
253
+ and math.isfinite(float(row[metric]))
254
+ ]
255
+ if values:
256
+ payload[metric] = sum(values) / len(values)
257
+ output[group] = payload
258
+ return output
259
+
260
+
261
+ def _latex_table(summary: dict[str, Any]) -> str:
262
+ lines = [
263
+ "% Auto-generated by scripts/eval_metrics.py",
264
+ "\\begin{tabular}{lrrrr}",
265
+ "\\toprule",
266
+ "Metric & N & Micro mean & CI low & CI high \\\\",
267
+ "\\midrule",
268
+ ]
269
+ for name, payload in sorted(summary.items()):
270
+ micro = payload["micro"]
271
+ lines.append(
272
+ f"{_latex_escape(name)} & {micro['n']} & {_fmt(micro['mean'])} & "
273
+ f"{_fmt(micro['low'])} & {_fmt(micro['high'])} \\\\"
274
+ )
275
+ lines.extend(["\\bottomrule", "\\end{tabular}"])
276
+ return "\n".join(lines)
277
+
278
+
279
+ def _markdown_report(mode: str, k: int, summary: dict[str, Any]) -> str:
280
+ lines = [
281
+ f"# Metric Evaluation ({mode})",
282
+ "",
283
+ f"K: `{k}`",
284
+ "",
285
+ "| Metric | N | Micro mean | 95% CI | Task macro | Seed macro |",
286
+ "| --- | ---: | ---: | ---: | ---: | ---: |",
287
+ ]
288
+ for name, payload in sorted(summary.items()):
289
+ micro = payload["micro"]
290
+ task_mean = payload["macro_by_task"]["mean"]
291
+ seed_mean = payload["macro_by_seed"]["mean"]
292
+ lines.append(
293
+ f"| {name} | {micro['n']} | {_fmt(micro['mean'])} | "
294
+ f"[{_fmt(micro['low'])}, {_fmt(micro['high'])}] | "
295
+ f"{_fmt(task_mean)} | {_fmt(seed_mean)} |"
296
+ )
297
+ return "\n".join(lines)
298
+
299
+
300
+ def _mean_pairwise_distance(vectors: list[list[float]]) -> float:
301
+ if len(vectors) < 2:
302
+ return 0.0
303
+ distances = []
304
+ for left_index, left in enumerate(vectors):
305
+ for right in vectors[left_index + 1 :]:
306
+ distances.append(_rms_l2(left, right))
307
+ return sum(distances) / len(distances)
308
+
309
+
310
+ def _collapse_rate(vectors: list[list[float]], *, threshold: float = 1.0e-6) -> float:
311
+ if not vectors:
312
+ return 0.0
313
+ first = vectors[0]
314
+ collapsed = sum(1 for vector in vectors if _rms_l2(first, vector) <= threshold)
315
+ return collapsed / len(vectors)
316
+
317
+
318
+ def _rms_l2(left: list[float], right: list[float]) -> float:
319
+ if len(left) != len(right):
320
+ raise MetricInputError("vectors must have matching dimensions")
321
+ if not left:
322
+ return 0.0
323
+ return math.sqrt(sum((a - b) ** 2 for a, b in zip(left, right, strict=True)) / len(left))
324
+
325
+
326
+ def _fmt(value: Any) -> str:
327
+ if not isinstance(value, (int, float)):
328
+ return "n/a"
329
+ return f"{float(value):.4f}"
330
+
331
+
332
+ def _latex_escape(value: str) -> str:
333
+ return value.replace("_", "\\_").replace("%", "\\%").replace("&", "\\&")
334
+
335
+
336
+ if __name__ == "__main__":
337
+ raise SystemExit(main())