Download scripts/analysis/export_positive_replay_buffer.py from Jack04810/agentic-rl-main: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/Jack04810/agentic-rl-main/resolve/main/scripts/analysis/export_positive_replay_buffer.py
- Command line
-
hf download hf://Jack04810/agentic-rl-main/scripts/analysis/export_positive_replay_buffer.py
-
curl -L -o export_positive_replay_buffer.py https://huggingface.co/Jack04810/agentic-rl-main/resolve/main/scripts/analysis/export_positive_replay_buffer.py
15.2 kB
| #!/usr/bin/env python | |
| """Export teacher-correct candidates as a clean positive replay buffer. | |
| Candidate logs are used only as a verifier/filter signal. Student-visible | |
| targets are rebuilt from the matched dataset row so truncated or privileged | |
| teacher outputs do not become replay supervision. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import glob | |
| import json | |
| import re | |
| import sys | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| ROOT = Path(__file__).resolve().parents[2] | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| from opsd_utils.teacher_sft_repair import ( | |
| build_teacher_sft_repair_target, | |
| teacher_sft_target_quality, | |
| ) | |
| DEFAULT_CANDIDATE_GLOB = ( | |
| "outputs/test-fast/pcd-no-visual/" | |
| "pcd_oracle_teacher_sft_repair_student_hint_short_4epoch/" | |
| "deplot_no_vs_opd_pcd_oracle_hint_teacher_sft_repair_student_hint_short/" | |
| "teacher_probe_candidates/rank*.jsonl" | |
| ) | |
| DEFAULT_DATASET = "data/chartqa/train_medium_vf_full.json" | |
| DEFAULT_OUT_DIR = "outputs/test-fast/positive-replay-buffer/student_hint_short" | |
| def _clean_answer(value: Any) -> str: | |
| return re.sub(r"(?i)^\s*answer\s*:\s*", "", str(value or "").strip()).strip() | |
| def _norm_answer(value: Any) -> str: | |
| return re.sub(r"\s+", " ", _clean_answer(value)).strip().lower() | |
| def _norm_question(value: Any) -> str: | |
| return re.sub(r"\s+", " ", str(value or "").strip()).lower() | |
| def _basename(value: Any) -> str: | |
| return Path(str(value or "")).name | |
| def _load_json(path: Path) -> Any: | |
| with path.open(encoding="utf-8") as f: | |
| return json.load(f) | |
| def _iter_jsonl(path: Path) -> Iterable[dict[str, Any]]: | |
| with path.open(encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| yield json.loads(line) | |
| def _write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None: | |
| with path.open("w", encoding="utf-8") as f: | |
| for row in rows: | |
| f.write(json.dumps(row, ensure_ascii=False) + "\n") | |
| def _preview_text(value: Any, limit: int = 240) -> str: | |
| text = str(value or "").replace("\\n", "\n").strip() | |
| text = re.sub(r"\s+", " ", text) | |
| if len(text) <= limit: | |
| return text | |
| return text[: limit - 3].rstrip() + "..." | |
| class DatasetIndex: | |
| """Lookup ChartQA rows without trusting candidate source_idx.""" | |
| def __init__(self, records: list[dict[str, Any]]): | |
| self.exact: dict[tuple[str, str, str], tuple[int, dict[str, Any]]] = {} | |
| self.by_question_answer: dict[tuple[str, str], tuple[int, dict[str, Any]]] = {} | |
| self.by_question_image: dict[tuple[str, str], tuple[int, dict[str, Any]]] = {} | |
| for idx, record in enumerate(records): | |
| question = _norm_question(record.get("question") or record.get("question_wo_prompt")) | |
| image = _basename(record.get("image")) | |
| answer = _norm_answer(record.get("answer")) | |
| if question and image and answer: | |
| self.exact.setdefault((question, image, answer), (idx, record)) | |
| if question and answer: | |
| self.by_question_answer.setdefault((question, answer), (idx, record)) | |
| if question and image: | |
| self.by_question_image.setdefault((question, image), (idx, record)) | |
| def lookup(self, candidate: dict[str, Any]) -> tuple[int, dict[str, Any], str] | None: | |
| question = _norm_question(candidate.get("question")) | |
| image = _basename(candidate.get("image")) | |
| answer = _norm_answer(candidate.get("reference") or candidate.get("answer")) | |
| if question and image and answer: | |
| found = self.exact.get((question, image, answer)) | |
| if found is not None: | |
| return found[0], found[1], "exact" | |
| if question and answer: | |
| found = self.by_question_answer.get((question, answer)) | |
| if found is not None: | |
| return found[0], found[1], "question_answer" | |
| if question and image: | |
| found = self.by_question_image.get((question, image)) | |
| if found is not None: | |
| return found[0], found[1], "question_image" | |
| return None | |
| def _dataset_records(raw: Any) -> list[dict[str, Any]]: | |
| if isinstance(raw, list): | |
| return [row for row in raw if isinstance(row, dict)] | |
| if isinstance(raw, dict): | |
| for key in ("data", "records", "train"): | |
| value = raw.get(key) | |
| if isinstance(value, list): | |
| return [row for row in value if isinstance(row, dict)] | |
| return [] | |
| def _candidate_scope(candidate: dict[str, Any]) -> str: | |
| if candidate.get("group_all_wrong") is True or candidate.get("is_all_wrong_probe_candidate") is True: | |
| return "all_wrong" | |
| if candidate.get("is_mixed_wrong_probe_candidate") is True: | |
| return "mixed_wrong" | |
| if candidate.get("group_has_correct") is True: | |
| return "mixed_wrong" | |
| return "unknown" | |
| def _candidate_files(pattern: str) -> list[Path]: | |
| return sorted(Path(path) for path in glob.glob(pattern)) | |
| def _load_candidates(pattern: str) -> list[dict[str, Any]]: | |
| rows: list[dict[str, Any]] = [] | |
| for path in _candidate_files(pattern): | |
| rows.extend(_iter_jsonl(path)) | |
| return rows | |
| def _make_replay_row( | |
| *, | |
| candidate: dict[str, Any], | |
| sample: dict[str, Any], | |
| dataset_idx: int, | |
| match_method: str, | |
| target_style: str, | |
| ) -> dict[str, Any]: | |
| answer = _clean_answer(sample.get("answer") or candidate.get("reference")) | |
| raw_text = str(candidate.get("teacher_output") or "") | |
| target = build_teacher_sft_repair_target( | |
| raw_text, | |
| sample=sample, | |
| reference_answer=answer, | |
| target_style=target_style, | |
| sanitize_privileged=True, | |
| ) | |
| quality = teacher_sft_target_quality(target.text, answer) | |
| scope = _candidate_scope(candidate) | |
| return { | |
| "dataset_idx": dataset_idx, | |
| "match_method": match_method, | |
| "question": sample.get("question") or candidate.get("question"), | |
| "image": sample.get("image") or candidate.get("image"), | |
| "image_basename": _basename(sample.get("image") or candidate.get("image")), | |
| "answer": answer, | |
| "target": target.text, | |
| "target_style": target_style, | |
| "target_quality": quality, | |
| "scope": scope, | |
| "global_step": candidate.get("global_step"), | |
| "generation_idx": candidate.get("generation_idx"), | |
| "source_idx": candidate.get("source_idx"), | |
| "rank": candidate.get("rank"), | |
| "route_reason": candidate.get("route_reason"), | |
| "final_route": candidate.get("final_route"), | |
| "group_reward_std": candidate.get("group_reward_std"), | |
| "provider_names": candidate.get("provider_names"), | |
| "student_correct": candidate.get("student_correct"), | |
| "teacher_correct": candidate.get("teacher_correct"), | |
| "teacher_output_preview": _preview_text(candidate.get("teacher_output")), | |
| "student_output_preview": _preview_text(candidate.get("student_output")), | |
| } | |
| def _rate(rows: list[dict[str, Any]], key: str) -> float: | |
| if not rows: | |
| return 0.0 | |
| return sum(1 for row in rows if row.get("target_quality", {}).get(key) is True) / len(rows) | |
| def _summary_row(counters: Counter, rows: list[dict[str, Any]]) -> dict[str, str]: | |
| return { | |
| "candidate_rows": str(counters["candidate_rows"]), | |
| "teacher_correct_candidates": str(counters["teacher_correct_candidates"]), | |
| "filtered_teacher_wrong": str(counters["filtered_teacher_wrong"]), | |
| "filtered_parse_fail": str(counters["filtered_parse_fail"]), | |
| "filtered_missing_answer_flag": str(counters["filtered_missing_answer_flag"]), | |
| "missing_dataset_match": str(counters["missing_dataset_match"]), | |
| "deduplicated": str(counters["deduplicated"]), | |
| "emitted": str(len(rows)), | |
| "student_short_rate": f"{_rate(rows, 'student_short_format'):.4f}", | |
| "answer_only_rate": f"{_rate(rows, 'answer_only_format'):.4f}", | |
| "full_hint_format_rate": f"{_rate(rows, 'full_hint_format'):.4f}", | |
| "exact_reference_answer_line_rate": f"{_rate(rows, 'exact_reference_answer_line'):.4f}", | |
| "privileged_tag_rate": f"{_rate(rows, 'privileged_tag_present'):.4f}", | |
| } | |
| def _write_summary_csv(path: Path, counters: Counter, rows: list[dict[str, Any]]) -> None: | |
| fieldnames = [ | |
| "candidate_rows", | |
| "teacher_correct_candidates", | |
| "filtered_teacher_wrong", | |
| "filtered_parse_fail", | |
| "filtered_missing_answer_flag", | |
| "missing_dataset_match", | |
| "deduplicated", | |
| "emitted", | |
| "student_short_rate", | |
| "answer_only_rate", | |
| "full_hint_format_rate", | |
| "exact_reference_answer_line_rate", | |
| "privileged_tag_rate", | |
| ] | |
| with path.open("w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames) | |
| writer.writeheader() | |
| writer.writerow(_summary_row(counters, rows)) | |
| def _write_by_scope_csv(path: Path, rows: list[dict[str, Any]]) -> None: | |
| by_scope: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| for row in rows: | |
| by_scope[str(row.get("scope") or "unknown")].append(row) | |
| fieldnames = [ | |
| "scope", | |
| "emitted", | |
| "student_short_rate", | |
| "answer_only_rate", | |
| "full_hint_format_rate", | |
| "exact_reference_answer_line_rate", | |
| "privileged_tag_rate", | |
| ] | |
| with path.open("w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames) | |
| writer.writeheader() | |
| for scope in sorted(by_scope): | |
| scoped = by_scope[scope] | |
| writer.writerow( | |
| { | |
| "scope": scope, | |
| "emitted": str(len(scoped)), | |
| "student_short_rate": f"{_rate(scoped, 'student_short_format'):.4f}", | |
| "answer_only_rate": f"{_rate(scoped, 'answer_only_format'):.4f}", | |
| "full_hint_format_rate": f"{_rate(scoped, 'full_hint_format'):.4f}", | |
| "exact_reference_answer_line_rate": f"{_rate(scoped, 'exact_reference_answer_line'):.4f}", | |
| "privileged_tag_rate": f"{_rate(scoped, 'privileged_tag_present'):.4f}", | |
| } | |
| ) | |
| def _target_to_sft_hint(target: str) -> str: | |
| kept: list[str] = [] | |
| for line in str(target or "").splitlines(): | |
| if re.match(r"(?i)^\s*answer\s*:", line): | |
| continue | |
| kept.append(line.rstrip()) | |
| return "\n".join(line for line in kept if line.strip()).strip() | |
| def _write_replay_train(path: Path, rows: list[dict[str, Any]]) -> None: | |
| records = [ | |
| { | |
| "question": row["question"], | |
| "image": row["image"], | |
| "answer": row["answer"], | |
| "hint": _target_to_sft_hint(row["target"]), | |
| "target": row["target"], | |
| "target_style": row["target_style"], | |
| "source": "teacher_correct_positive_replay", | |
| "dataset_idx": row["dataset_idx"], | |
| } | |
| for row in rows | |
| ] | |
| path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") | |
| def export_replay_buffer( | |
| *, | |
| candidate_glob: str, | |
| dataset_path: Path, | |
| target_style: str, | |
| min_step: int | None = None, | |
| max_records: int = 0, | |
| ) -> tuple[list[dict[str, Any]], Counter]: | |
| dataset = DatasetIndex(_dataset_records(_load_json(dataset_path))) | |
| counters: Counter = Counter() | |
| rows: list[dict[str, Any]] = [] | |
| seen: set[tuple[int, str]] = set() | |
| for candidate in _load_candidates(candidate_glob): | |
| counters["candidate_rows"] += 1 | |
| if candidate.get("teacher_correct") is True: | |
| counters["teacher_correct_candidates"] += 1 | |
| else: | |
| counters["filtered_teacher_wrong"] += 1 | |
| continue | |
| if candidate.get("parse_failed") is True: | |
| counters["filtered_parse_fail"] += 1 | |
| continue | |
| if candidate.get("has_answer_flag") is False: | |
| counters["filtered_missing_answer_flag"] += 1 | |
| continue | |
| if min_step is not None and int(candidate.get("global_step") or 0) < min_step: | |
| counters["filtered_min_step"] += 1 | |
| continue | |
| found = dataset.lookup(candidate) | |
| if found is None: | |
| counters["missing_dataset_match"] += 1 | |
| continue | |
| dataset_idx, sample, match_method = found | |
| answer = _clean_answer(sample.get("answer") or candidate.get("reference")) | |
| dedupe_key = (dataset_idx, answer) | |
| if dedupe_key in seen: | |
| counters["deduplicated"] += 1 | |
| continue | |
| seen.add(dedupe_key) | |
| row = _make_replay_row( | |
| candidate=candidate, | |
| sample=sample, | |
| dataset_idx=dataset_idx, | |
| match_method=match_method, | |
| target_style=target_style, | |
| ) | |
| rows.append(row) | |
| if max_records > 0 and len(rows) >= max_records: | |
| break | |
| return rows, counters | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--candidate-glob", default=DEFAULT_CANDIDATE_GLOB) | |
| parser.add_argument("--dataset", default=DEFAULT_DATASET) | |
| parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR) | |
| parser.add_argument( | |
| "--target-style", | |
| default="student_hint_short", | |
| choices=("student_hint_short", "student_short", "answer_only", "chartqa_hint"), | |
| ) | |
| parser.add_argument("--min-step", type=int, default=None) | |
| parser.add_argument("--max-records", type=int, default=0) | |
| parser.add_argument("--preview-records", type=int, default=64) | |
| return parser.parse_args(argv) | |
| def main(argv: list[str] | None = None) -> int: | |
| args = parse_args(argv) | |
| out_dir = Path(args.out_dir) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| rows, counters = export_replay_buffer( | |
| candidate_glob=args.candidate_glob, | |
| dataset_path=Path(args.dataset), | |
| target_style=args.target_style, | |
| min_step=args.min_step, | |
| max_records=max(0, int(args.max_records or 0)), | |
| ) | |
| _write_jsonl(out_dir / "replay.jsonl", rows) | |
| _write_jsonl(out_dir / "preview.jsonl", rows[: max(0, int(args.preview_records or 0))]) | |
| _write_replay_train(out_dir / "replay_train.json", rows) | |
| _write_summary_csv(out_dir / "summary.csv", counters, rows) | |
| _write_by_scope_csv(out_dir / "by_scope.csv", rows) | |
| metadata = { | |
| "candidate_glob": args.candidate_glob, | |
| "dataset": str(args.dataset), | |
| "target_style": args.target_style, | |
| "min_step": args.min_step, | |
| "max_records": args.max_records, | |
| "outputs": [ | |
| "replay.jsonl", | |
| "replay_train.json", | |
| "summary.csv", | |
| "by_scope.csv", | |
| "preview.jsonl", | |
| ], | |
| } | |
| (out_dir / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |