| |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import subprocess |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| if str(PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| import torch |
|
|
| from cil.metrics import ( |
| candidate_diversity, |
| collapse_rate, |
| macro_micro_summary, |
| mean_nearest_distance_to_set, |
| negative_near_at_threshold, |
| positives_closer_than_negatives, |
| proxy_positive_tangent_coverage_at_k, |
| proxy_support_distance, |
| ) |
| from scripts.train_ctt import Chart, load_charts |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| description=( |
| "Evaluate train-only positive tangent memory baselines on evaluator " |
| "chart positives. This is PPTC/proxy support geometry, not OutcomePTR." |
| ) |
| ) |
| parser.add_argument("--source-index", type=Path, default=Path("data/cil_charts/train/index.json")) |
| parser.add_argument("--target-index", type=Path, default=Path("data/cil_charts/val/index.json")) |
| parser.add_argument("--out-dir", type=Path, default=Path("runs/local_atlas_val_proxy")) |
| parser.add_argument( |
| "--mode", |
| choices=("local_atlas", "task_memory", "global_memory"), |
| default="local_atlas", |
| ) |
| parser.add_argument("--k", type=int, default=16) |
| parser.add_argument("--neighbors", type=int, default=8) |
| parser.add_argument("--max-target-charts", type=int, default=100000) |
| parser.add_argument("--thresholds", default="0.20,0.40") |
| parser.add_argument( |
| "--no-markdown-report", |
| action="store_true", |
| help="Do not write report.md; persistent prose is consolidated in README.md.", |
| ) |
| args = parser.parse_args(argv) |
|
|
| thresholds = [float(item) for item in args.thresholds.split(",") if item.strip()] |
| if args.k <= 0: |
| parser.error("--k must be positive") |
| if args.neighbors <= 0: |
| parser.error("--neighbors must be positive") |
| if any(threshold < 0.0 for threshold in thresholds): |
| parser.error("--thresholds must be non-negative") |
|
|
| source_charts, source_index = load_charts(args.source_index, max_charts=None) |
| target_charts, target_index = load_charts(args.target_index, max_charts=args.max_target_charts) |
| _validate_indexes(args.source_index, source_index, args.target_index, target_index) |
| source_by_task: dict[str, list[Chart]] = {} |
| for chart in source_charts: |
| source_by_task.setdefault(chart.task_id, []).append(chart) |
|
|
| rows = [] |
| for target in target_charts: |
| proposals = _propose( |
| target, |
| source_charts=source_charts, |
| source_by_task=source_by_task, |
| mode=args.mode, |
| k=args.k, |
| neighbors=args.neighbors, |
| ) |
| rows.append( |
| _metric_row( |
| target=target, |
| proposals=[proposal.cpu().tolist() for proposal in proposals], |
| thresholds=thresholds, |
| k=args.k, |
| ) |
| ) |
|
|
| metric_names = sorted( |
| { |
| key |
| for row in rows |
| for key, value in row.items() |
| if isinstance(value, (int, float)) and math.isfinite(float(value)) |
| } |
| - {"num_proposals"} |
| ) |
| summary = {name: macro_micro_summary(rows, name, bootstrap_samples=500) for name in metric_names} |
| out_dir = args.out_dir |
| out_dir.mkdir(parents=True, exist_ok=True) |
| _write_run_provenance(out_dir, args, source_index, target_index) |
| metrics = { |
| "report_type": "positive_memory_proxy_eval", |
| "method": args.mode, |
| "k": args.k, |
| "thresholds": thresholds, |
| "num_rows": len(rows), |
| "rows": rows, |
| "summary": summary, |
| "data_hash": source_index.get("content_hash"), |
| "split_hash": target_index.get("split_hash"), |
| "target_data_hash": target_index.get("content_hash"), |
| "target_split_hash": target_index.get("split_hash"), |
| } |
| (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") |
| (out_dir / "metrics_by_task.json").write_text( |
| json.dumps(_by_group(rows, metric_names, "task_id"), indent=2, sort_keys=True) + "\n" |
| ) |
| (out_dir / "metrics_by_seed.json").write_text( |
| json.dumps(_by_group(rows, metric_names, "seed"), indent=2, sort_keys=True) + "\n" |
| ) |
| (out_dir / "train.log").write_text("train-only measured positive tangent memory; no learned training\n") |
| (out_dir / "eval.log").write_text( |
| "\n".join( |
| [ |
| f"source_charts={len(source_charts)} target_charts={len(target_charts)} k={args.k}", |
| f"mode={args.mode} neighbors={args.neighbors}", |
| f"source_index={args.source_index}", |
| f"target_index={args.target_index}", |
| ] |
| ) |
| + "\n" |
| ) |
| (out_dir / "table.tex").write_text(_table(summary) + "\n") |
| _write_markdown_report( |
| out_dir, |
| args.mode, |
| args.k, |
| summary, |
| no_markdown_report=args.no_markdown_report, |
| ) |
| print(json.dumps({"out_dir": str(out_dir), "num_rows": len(rows)}, indent=2)) |
| return 0 |
|
|
|
|
| def _propose( |
| target: Chart, |
| *, |
| source_charts: list[Chart], |
| source_by_task: dict[str, list[Chart]], |
| mode: str, |
| k: int, |
| neighbors: int, |
| ) -> list[torch.Tensor]: |
| if mode == "global_memory": |
| pool = source_charts |
| else: |
| pool = source_by_task.get(target.task_id, source_charts) |
| if mode == "task_memory": |
| ranked = sorted(pool, key=lambda chart: chart.chart_id) |
| else: |
| ranked = sorted( |
| pool, |
| key=lambda chart: torch.linalg.vector_norm(chart.feature - target.feature).item(), |
| ) |
| if mode == "local_atlas": |
| ranked = ranked[:neighbors] |
|
|
| proposals: list[torch.Tensor] = [] |
| for chart in ranked: |
| for positive in chart.positives: |
| proposals.append(positive) |
| if len(proposals) >= k: |
| return proposals |
| return proposals |
|
|
|
|
| def _metric_row( |
| *, |
| target: Chart, |
| proposals: list[list[float]], |
| thresholds: list[float], |
| k: int, |
| ) -> dict[str, Any]: |
| positives = target.positives.cpu().tolist() |
| negatives = target.negatives.cpu().tolist() |
| row: dict[str, Any] = { |
| "chart_id": target.chart_id, |
| "task_id": target.task_id, |
| "seed": target.seed, |
| "num_proposals": len(proposals), |
| } |
| for threshold in thresholds: |
| suffix = f"{threshold:.2f}".replace(".", "p") |
| row[f"pptc_at_{k}_thr_{suffix}"] = proxy_positive_tangent_coverage_at_k( |
| proposals, |
| positives, |
| threshold=threshold, |
| k=k, |
| ) |
| row[f"negative_near_at_{k}_thr_{suffix}"] = negative_near_at_threshold( |
| proposals, |
| negatives, |
| threshold=threshold, |
| k=k, |
| ) |
| row[f"proxy_support_distance_at_{k}"] = proxy_support_distance(proposals, positives, k=k) |
| row[f"mean_positive_distance_at_{k}"] = mean_nearest_distance_to_set(proposals, positives, k=k) |
| row[f"mean_negative_distance_at_{k}"] = mean_nearest_distance_to_set(proposals, negatives, k=k) |
| row[f"pos_closer_than_neg_at_{k}"] = positives_closer_than_negatives( |
| proposals, |
| positives, |
| negatives, |
| k=k, |
| ) |
| row[f"candidate_diversity_at_{k}"] = candidate_diversity(proposals, k=k) |
| row[f"collapse_rate_at_{k}"] = collapse_rate(proposals, k=k) |
| return row |
|
|
|
|
| def _validate_indexes( |
| source_path: Path, |
| source_index: dict[str, Any], |
| target_path: Path, |
| target_index: dict[str, Any], |
| ) -> None: |
| if source_index.get("split") != "train" or not source_index.get("retrieval_index_allowed"): |
| raise SystemExit(f"{source_path} must be the train-only retrieval index") |
| if not source_index.get("include_outcomes"): |
| raise SystemExit(f"{source_path} must include train outcomes for positive memory") |
| if not target_index.get("include_outcomes"): |
| raise SystemExit(f"{target_path} must include evaluator outcomes for PPTC labels") |
| if target_index.get("split") != "train" and target_index.get("retrieval_index_allowed"): |
| raise SystemExit(f"{target_path} is non-train but marked retrieval_index_allowed") |
|
|
|
|
| def _write_run_provenance( |
| out_dir: Path, |
| args: argparse.Namespace, |
| source_index: dict[str, Any], |
| target_index: dict[str, Any], |
| ) -> None: |
| (out_dir / "config.yaml").write_text("\n".join(f"{k}: {v}" for k, v in sorted(vars(args).items())) + "\n") |
| (out_dir / "command.txt").write_text( |
| "python scripts/eval_chart_positive_memory_proxy.py " + " ".join(sys.argv[1:]) + "\n" |
| ) |
| (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n") |
| (out_dir / "data_hash.txt").write_text(str(source_index.get("content_hash", "")) + "\n") |
| (out_dir / "split_hash.txt").write_text(str(target_index.get("split_hash", "")) + "\n") |
|
|
|
|
| def _run(command: list[str]) -> str: |
| try: |
| return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip() |
| except (subprocess.CalledProcessError, FileNotFoundError): |
| return "" |
|
|
|
|
| def _by_group( |
| rows: list[dict[str, Any]], |
| metric_names: list[str], |
| group_key: str, |
| ) -> dict[str, dict[str, float]]: |
| output: dict[str, dict[str, float]] = {} |
| for row in rows: |
| group = str(row[group_key]) |
| output.setdefault(group, {}) |
| for group in output: |
| group_rows = [row for row in rows if str(row[group_key]) == group] |
| for metric in metric_names: |
| values = [float(row[metric]) for row in group_rows if isinstance(row.get(metric), (int, float))] |
| if values: |
| output[group][metric] = sum(values) / len(values) |
| return output |
|
|
|
|
| def _table(summary: dict[str, Any]) -> str: |
| lines = [ |
| "% Auto-generated by scripts/eval_chart_positive_memory_proxy.py", |
| "\\begin{tabular}{lrrr}", |
| "\\toprule", |
| "Metric & N & Mean & CI high \\\\", |
| "\\midrule", |
| ] |
| for name, payload in sorted(summary.items()): |
| micro = payload["micro"] |
| lines.append( |
| f"{_latex_escape(name)} & {micro['n']} & {micro['mean']:.4f} & " |
| f"{micro['high']:.4f} \\\\" |
| ) |
| lines.extend(["\\bottomrule", "\\end{tabular}"]) |
| return "\n".join(lines) |
|
|
|
|
| def _report(method: str, k: int, summary: dict[str, Any]) -> str: |
| lines = [ |
| "# Positive Memory Proxy Evaluation", |
| "", |
| f"Method: `{method}`", |
| f"K: `{k}`", |
| "", |
| "| Metric | N | Mean | 95% CI |", |
| "| --- | ---: | ---: | ---: |", |
| ] |
| for name, payload in sorted(summary.items()): |
| micro = payload["micro"] |
| lines.append( |
| f"| {name} | {micro['n']} | {micro['mean']:.4f} | " |
| f"[{micro['low']:.4f}, {micro['high']:.4f}] |" |
| ) |
| lines.append("") |
| lines.append("This is PPTC/proxy support geometry, not OutcomePTR or rollout success.") |
| return "\n".join(lines) |
|
|
|
|
| def _write_markdown_report( |
| out_dir: Path, |
| method: str, |
| k: int, |
| summary: dict[str, Any], |
| *, |
| no_markdown_report: bool, |
| ) -> None: |
| report_path = out_dir / "report.md" |
| if no_markdown_report: |
| report_path.unlink(missing_ok=True) |
| return |
| report_path.write_text(_report(method, k, summary) + "\n") |
|
|
|
|
| def _latex_escape(value: str) -> str: |
| return value.replace("_", "\\_").replace("%", "\\%").replace("&", "\\&") |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|