vla / workspace /scripts /eval_chart_positive_memory_proxy.py
anhtld's picture
auto-sync 2026-07-04T05:22:54Z workspace (part 3)
36ff02f verified
Raw
History Blame Contribute Delete
11.8 kB
#!/usr/bin/env python
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 # noqa: E402
from cil.metrics import ( # noqa: E402
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 # noqa: E402
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())