anhtld commited on
Commit
71f2269
·
verified ·
1 Parent(s): 951390a

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

Browse files
Files changed (1) hide show
  1. workspace/scripts/eval_ctt_proxy.py +223 -0
workspace/scripts/eval_ctt_proxy.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 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
+ import torch # noqa: E402
17
+
18
+ from cil.metrics import ( # noqa: E402
19
+ macro_micro_summary,
20
+ negative_near_at_threshold,
21
+ positives_closer_than_negatives,
22
+ proxy_positive_tangent_coverage_at_k,
23
+ proxy_support_distance,
24
+ )
25
+ from cil.models import CTTConfig, CausalTangentTransport, ChartEncoder, TangentNormalizer # noqa: E402
26
+ from scripts.train_ctt import load_charts # noqa: E402
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ parser = argparse.ArgumentParser(description="Evaluate CTT support geometry with proxy metrics.")
31
+ parser.add_argument("--checkpoint", type=Path, required=True)
32
+ parser.add_argument("--source-index", type=Path, default=Path("data/cil_charts/train/index.json"))
33
+ parser.add_argument("--target-index", type=Path, default=Path("data/cil_charts/train/index.json"))
34
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_residual_smoke_proxy"))
35
+ parser.add_argument("--k", type=int, default=16)
36
+ parser.add_argument("--thresholds", default="0.20,0.40")
37
+ parser.add_argument("--max-target-charts", type=int, default=64)
38
+ parser.add_argument("--neighbors", type=int, default=8)
39
+ args = parser.parse_args(argv)
40
+
41
+ thresholds = [float(item) for item in args.thresholds.split(",") if item.strip()]
42
+ checkpoint = torch.load(args.checkpoint, map_location="cpu")
43
+ config = CTTConfig(**checkpoint["config"])
44
+ encoder = ChartEncoder(config.chart_feature_dim, output_dim=config.chart_dim)
45
+ ctt = CausalTangentTransport(config)
46
+ encoder.load_state_dict(checkpoint["chart_encoder"])
47
+ ctt.load_state_dict(checkpoint["ctt"])
48
+ encoder.eval()
49
+ ctt.eval()
50
+ normalizer = TangentNormalizer.from_dict(checkpoint["normalizer"])
51
+
52
+ source_charts, source_index = load_charts(args.source_index, max_charts=None)
53
+ target_charts, target_index = load_charts(args.target_index, max_charts=args.max_target_charts)
54
+ rows = []
55
+ log_lines = [
56
+ f"source_charts={len(source_charts)} target_charts={len(target_charts)} k={args.k}",
57
+ f"source_index={args.source_index}",
58
+ f"target_index={args.target_index}",
59
+ ]
60
+ source_by_task: dict[str, list[Any]] = {}
61
+ for chart in source_charts:
62
+ source_by_task.setdefault(chart.task_id, []).append(chart)
63
+
64
+ with torch.no_grad():
65
+ for target in target_charts:
66
+ pool = source_by_task.get(target.task_id, source_charts)
67
+ neighbors = sorted(
68
+ pool,
69
+ key=lambda source: torch.linalg.vector_norm(source.feature - target.feature).item(),
70
+ )[: args.neighbors]
71
+ proposals = []
72
+ z_target = encoder(target.feature.unsqueeze(0))
73
+ for source in neighbors:
74
+ z_source = encoder(source.feature.unsqueeze(0))
75
+ for xi_source in source.positives[: max(1, args.k // max(1, len(neighbors)) + 1)]:
76
+ if len(proposals) >= args.k:
77
+ break
78
+ xi_norm = normalizer.transform(xi_source.unsqueeze(0))
79
+ xi_hat_norm = ctt(z_source, z_target, xi_norm)
80
+ proposals.append(normalizer.inverse_transform(xi_hat_norm).squeeze(0).cpu().tolist())
81
+ if len(proposals) >= args.k:
82
+ break
83
+ positives = target.positives.cpu().tolist()
84
+ negatives = target.negatives.cpu().tolist()
85
+ row: dict[str, Any] = {
86
+ "chart_id": target.chart_id,
87
+ "task_id": target.task_id,
88
+ "seed": "unknown",
89
+ "num_proposals": len(proposals),
90
+ }
91
+ for threshold in thresholds:
92
+ suffix = f"{threshold:.2f}".replace(".", "p")
93
+ row[f"pptc_at_{args.k}_thr_{suffix}"] = proxy_positive_tangent_coverage_at_k(
94
+ proposals,
95
+ positives,
96
+ threshold=threshold,
97
+ k=args.k,
98
+ )
99
+ row[f"negative_near_at_{args.k}_thr_{suffix}"] = negative_near_at_threshold(
100
+ proposals,
101
+ negatives,
102
+ threshold=threshold,
103
+ k=args.k,
104
+ )
105
+ distance = proxy_support_distance(proposals, positives, k=args.k)
106
+ row[f"proxy_support_distance_at_{args.k}"] = distance
107
+ closer = positives_closer_than_negatives(proposals, positives, negatives, k=args.k)
108
+ row[f"pos_closer_than_neg_at_{args.k}"] = closer
109
+ rows.append(row)
110
+
111
+ metric_names = sorted(
112
+ {
113
+ key
114
+ for row in rows
115
+ for key, value in row.items()
116
+ if isinstance(value, (int, float)) and math.isfinite(float(value))
117
+ }
118
+ - {"num_proposals"}
119
+ )
120
+ summary = {name: macro_micro_summary(rows, name, bootstrap_samples=200) for name in metric_names}
121
+ out_dir = args.out_dir
122
+ out_dir.mkdir(parents=True, exist_ok=True)
123
+ _write_run_provenance(out_dir, args, source_index, target_index)
124
+ metrics = {
125
+ "report_type": "ctt_proxy_eval",
126
+ "k": args.k,
127
+ "thresholds": thresholds,
128
+ "num_rows": len(rows),
129
+ "rows": rows,
130
+ "summary": summary,
131
+ "data_hash": source_index.get("content_hash"),
132
+ "split_hash": source_index.get("split_hash"),
133
+ "target_split_hash": target_index.get("split_hash"),
134
+ }
135
+ (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
136
+ (out_dir / "metrics_by_task.json").write_text(json.dumps(_by_task(rows, metric_names), indent=2, sort_keys=True) + "\n")
137
+ (out_dir / "metrics_by_seed.json").write_text("{}\n")
138
+ (out_dir / "eval.log").write_text("\n".join(log_lines) + "\n")
139
+ (out_dir / "train.log").write_text("see checkpoint run\n")
140
+ (out_dir / "table.tex").write_text(_table(summary) + "\n")
141
+ (out_dir / "report.md").write_text(_report(summary, args.k) + "\n")
142
+ print(json.dumps({"out_dir": str(out_dir), "num_rows": len(rows)}, indent=2))
143
+ return 0
144
+
145
+
146
+ def _write_run_provenance(
147
+ out_dir: Path,
148
+ args: argparse.Namespace,
149
+ source_index: dict[str, Any],
150
+ target_index: dict[str, Any],
151
+ ) -> None:
152
+ (out_dir / "config.yaml").write_text("\n".join(f"{k}: {v}" for k, v in sorted(vars(args).items())) + "\n")
153
+ (out_dir / "command.txt").write_text("python scripts/eval_ctt_proxy.py " + " ".join(sys.argv[1:]) + "\n")
154
+ (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
155
+ (out_dir / "data_hash.txt").write_text(str(source_index.get("content_hash", "")) + "\n")
156
+ (out_dir / "split_hash.txt").write_text(str(target_index.get("split_hash", "")) + "\n")
157
+
158
+
159
+ def _run(command: list[str]) -> str:
160
+ try:
161
+ return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
162
+ except (subprocess.CalledProcessError, FileNotFoundError):
163
+ return ""
164
+
165
+
166
+ def _by_task(rows: list[dict[str, Any]], metric_names: list[str]) -> dict[str, dict[str, float]]:
167
+ output: dict[str, dict[str, float]] = {}
168
+ for row in rows:
169
+ task = str(row["task_id"])
170
+ output.setdefault(task, {})
171
+ for task in output:
172
+ task_rows = [row for row in rows if row["task_id"] == task]
173
+ for metric in metric_names:
174
+ values = [float(row[metric]) for row in task_rows if isinstance(row.get(metric), (int, float))]
175
+ if values:
176
+ output[task][metric] = sum(values) / len(values)
177
+ return output
178
+
179
+
180
+ def _table(summary: dict[str, Any]) -> str:
181
+ lines = [
182
+ "% Auto-generated by scripts/eval_ctt_proxy.py",
183
+ "\\begin{tabular}{lrrr}",
184
+ "\\toprule",
185
+ "Metric & N & Mean & CI high \\\\",
186
+ "\\midrule",
187
+ ]
188
+ for name, payload in sorted(summary.items()):
189
+ micro = payload["micro"]
190
+ lines.append(
191
+ f"{_latex_escape(name)} & {micro['n']} & {micro['mean']:.4f} & "
192
+ f"{micro['high']:.4f} \\\\"
193
+ )
194
+ lines.extend(["\\bottomrule", "\\end{tabular}"])
195
+ return "\n".join(lines)
196
+
197
+
198
+ def _latex_escape(value: str) -> str:
199
+ return value.replace("_", "\\_").replace("%", "\\%").replace("&", "\\&")
200
+
201
+
202
+ def _report(summary: dict[str, Any], k: int) -> str:
203
+ lines = [
204
+ "# CTT Proxy Evaluation",
205
+ "",
206
+ f"K: `{k}`",
207
+ "",
208
+ "| Metric | N | Mean | 95% CI |",
209
+ "| --- | ---: | ---: | ---: |",
210
+ ]
211
+ for name, payload in sorted(summary.items()):
212
+ micro = payload["micro"]
213
+ lines.append(
214
+ f"| {name} | {micro['n']} | {micro['mean']:.4f} | "
215
+ f"[{micro['low']:.4f}, {micro['high']:.4f}] |"
216
+ )
217
+ lines.append("")
218
+ lines.append("This is PPTC/proxy support geometry, not OutcomePTR or rollout success.")
219
+ return "\n".join(lines)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())