Download code/training/scripts/build_sft_dataset.py from adhikjoshi/infosec-v1: direct link, hf CLI and curl.
- Browser
- Download file 19.1 kB
-
https://huggingface.co/adhikjoshi/infosec-v1/resolve/main/code/training/scripts/build_sft_dataset.py
- Command line
-
hf download hf://adhikjoshi/infosec-v1/code/training/scripts/build_sft_dataset.py
-
curl -L -o build_sft_dataset.py https://huggingface.co/adhikjoshi/infosec-v1/resolve/main/code/training/scripts/build_sft_dataset.py
19.1 kB
| #!/usr/bin/env python3 | |
| """Build the Stage 1 SFT mix from downloaded raw splits + the manifest. | |
| Pipeline per source key in ``training/configs/datasets.yaml``: | |
| raw.jsonl --adapter--> Example(s) --think routing--> ready / to_synthesize | |
| --dedup, source caps, data card | |
| Outputs (default mix name ``stage1``): | |
| data/processed/stage1.ready.normalized.jsonl rows with a <think> block, ready to split | |
| data/think_synthesis/stage1.to_synthesize.jsonl rows needing rejection-sampled reasoning | |
| reports/data/stage1_data_card.md provenance + counts + license/cap flags | |
| Typical flow: | |
| 1. python training/scripts/hf_download.py --all --profile pilot | |
| 2. python training/scripts/build_sft_dataset.py --profile pilot | |
| 3. python training/scripts/synthesize_think.py --input data/think_synthesis/stage1.to_synthesize.jsonl ... | |
| 4. python training/scripts/build_sft_dataset.py --include-synthesized data/think_synthesis/stage1.synthesized.jsonl | |
| 5. python training/scripts/split_jsonl.py --input data/processed/stage1.ready.normalized.jsonl ... | |
| This script is stdlib-only (plus PyYAML) so it runs on any host. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import random | |
| import re | |
| import sys | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| import yaml | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| from sft_adapters import Example, apply_adapter, has_think # noqa: E402 | |
| WS_RE = re.compile(r"\s+") | |
| WRAP_PLACEHOLDER = ( | |
| "<think>\n" | |
| "Placeholder reasoning inserted for pipeline smoke testing only; replace " | |
| "with a synthesized trace before the real Stage 1 run.\n" | |
| "</think>\n\n" | |
| ) | |
| def read_yaml(path: str | Path) -> dict[str, Any]: | |
| with Path(path).open("r", encoding="utf-8") as fh: | |
| payload = yaml.safe_load(fh) or {} | |
| if not isinstance(payload, dict): | |
| raise TypeError(f"Expected a YAML mapping in {path}") | |
| return payload | |
| def read_jsonl(path: Path) -> Iterable[dict[str, Any]]: | |
| with path.open("r", encoding="utf-8") as fh: | |
| for line_no, line in enumerate(fh, start=1): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| row = json.loads(line) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError(f"Invalid JSON in {path}:{line_no}: {exc}") from exc | |
| if isinstance(row, dict): | |
| yield row | |
| def estimate_tokens(messages: list[dict[str, str]]) -> int: | |
| chars = sum(len(m.get("content", "")) for m in messages) | |
| return max(1, chars // 4) | |
| def dedup_key(messages: list[dict[str, str]]) -> str: | |
| text = " ".join( | |
| m.get("content", "") for m in messages if m.get("role") in {"user", "assistant"} | |
| ) | |
| norm = WS_RE.sub(" ", text).strip().lower() | |
| return hashlib.sha256(norm.encode("utf-8")).hexdigest() | |
| def assistant_turn_count(messages: list[dict[str, str]]) -> int: | |
| return sum(1 for m in messages if m.get("role") == "assistant") | |
| def split_prompt_answer(messages: list[dict[str, str]]) -> tuple[list[dict[str, str]], str]: | |
| """Return (messages_without_final_assistant, final_assistant_content).""" | |
| for i in range(len(messages) - 1, -1, -1): | |
| if messages[i].get("role") == "assistant": | |
| return messages[:i], messages[i].get("content", "") | |
| return messages, "" | |
| def wrap_messages(messages: list[dict[str, str]]) -> list[dict[str, str]]: | |
| out = [] | |
| for m in messages: | |
| if m.get("role") == "assistant" and not has_think(m.get("content", "")): | |
| out.append({"role": "assistant", "content": WRAP_PLACEHOLDER + m.get("content", "")}) | |
| else: | |
| out.append(m) | |
| return out | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| parser.add_argument("--manifest", default="training/configs/datasets.yaml") | |
| parser.add_argument("--mix-name", default="stage1") | |
| parser.add_argument("--profile", choices=["full", "pilot"], default="full") | |
| parser.add_argument("--raw-dir", default=None, help="Override manifest defaults.raw_dir.") | |
| parser.add_argument("--processed-dir", default="data/processed") | |
| parser.add_argument("--synth-dir", default="data/think_synthesis") | |
| parser.add_argument("--report", default=None, help="Data card path (default reports/data/<mix>_data_card.md).") | |
| parser.add_argument( | |
| "--missing-think-policy", | |
| choices=["synthesize", "wrap", "drop"], | |
| default="synthesize", | |
| ) | |
| parser.add_argument("--only", default=None, help="Comma-separated subset of source keys.") | |
| parser.add_argument("--include-disabled", action="store_true") | |
| parser.add_argument("--grounded-think", action="store_true", | |
| help="Compose label-consistent <think> from metadata (no teacher pass needed).") | |
| parser.add_argument("--balance-detection", action="store_true", | |
| help="Downsample majority-class vuln-detection rows to 1:1 so training doesn't collapse to majority.") | |
| parser.add_argument("--enforce-caps", action="store_true", help="Deterministically downsample over-cap sources.") | |
| parser.add_argument( | |
| "--include-synthesized", | |
| default=None, | |
| help="Fold an already-synthesized JSONL (from synthesize_think.py) into the ready mix.", | |
| ) | |
| return parser.parse_args() | |
| def cap_for(source: dict[str, Any], defaults: dict[str, Any], profile: str) -> int | None: | |
| if profile == "pilot": | |
| return source.get("pilot_sample_cap", defaults.get("pilot_sample_cap")) | |
| return source.get("sample_cap", defaults.get("sample_cap")) | |
| def select_keys(sources: dict[str, Any], args: argparse.Namespace) -> list[str]: | |
| if args.only: | |
| wanted = [k.strip() for k in args.only.split(",") if k.strip()] | |
| for k in wanted: | |
| if k not in sources: | |
| raise SystemExit(f"Unknown source key in --only: {k}") | |
| return wanted | |
| return [k for k, s in sources.items() if s.get("enabled", False) or args.include_disabled] | |
| def build() -> int: | |
| args = parse_args() | |
| manifest = read_yaml(args.manifest) | |
| defaults = manifest.get("defaults", {}) | |
| raw_dir = Path(args.raw_dir or defaults.get("raw_dir", "data/download")) | |
| sources = manifest.get("sources", {}) | |
| processed_dir = Path(args.processed_dir) | |
| synth_dir = Path(args.synth_dir) | |
| processed_dir.mkdir(parents=True, exist_ok=True) | |
| synth_dir.mkdir(parents=True, exist_ok=True) | |
| ready_path = processed_dir / f"{args.mix_name}.ready.normalized.jsonl" | |
| synth_path = synth_dir / f"{args.mix_name}.to_synthesize.jsonl" | |
| report_path = Path(args.report or f"reports/data/{args.mix_name}_data_card.md") | |
| report_path.parent.mkdir(parents=True, exist_ok=True) | |
| keys = select_keys(sources, args) | |
| seen: set[str] = set() | |
| per_source: dict[str, dict[str, Any]] = {} | |
| ready_rows: list[dict[str, Any]] = [] | |
| synth_rows: list[dict[str, Any]] = [] | |
| skipped: list[str] = [] | |
| for key in keys: | |
| source = sources[key] | |
| raw_path = raw_dir / key / "raw.jsonl" | |
| stats = per_source.setdefault( | |
| key, | |
| { | |
| "hf_id": source.get("hf_id"), | |
| "group": source.get("group"), | |
| "license": source.get("license"), | |
| "auth": source.get("auth"), | |
| "adapter": source.get("adapter"), | |
| "raw_rows": 0, | |
| "examples": 0, | |
| "ready": 0, | |
| "to_synthesize": 0, | |
| "wrapped": 0, | |
| "dropped_no_think": 0, | |
| "dropped_dup": 0, | |
| "tokens": 0, | |
| }, | |
| ) | |
| if not raw_path.is_file(): | |
| skipped.append(f"{key}: missing {raw_path} (run hf_download.py --key {key})") | |
| continue | |
| cap = cap_for(source, defaults, args.profile) | |
| adapter = source["adapter"] | |
| params = dict(source.get("params", {}) or {}) | |
| if args.grounded_think: | |
| params["grounded_think"] = True | |
| kept_from_source = 0 | |
| for row in read_jsonl(raw_path): | |
| stats["raw_rows"] += 1 | |
| if cap is not None and kept_from_source >= cap: | |
| break | |
| try: | |
| examples = apply_adapter(adapter, row, params) | |
| except Exception as exc: # noqa: BLE001 - one bad row shouldn't kill the build | |
| skipped.append(f"{key}: adapter error on row {stats['raw_rows']}: {exc!r}") | |
| continue | |
| for ex in examples: | |
| stats["examples"] += 1 | |
| key_hash = dedup_key(ex.messages) | |
| if key_hash in seen: | |
| stats["dropped_dup"] += 1 | |
| continue | |
| seen.add(key_hash) | |
| row_id = f"{source['hf_id']}:{key_hash[:16]}" | |
| tokens = estimate_tokens(ex.messages) | |
| if ex.think_status == "present" or all( | |
| has_think(m["content"]) for m in ex.messages if m["role"] == "assistant" | |
| ): | |
| ready_rows.append(_wrap_record(row_id, source, ex, ex.messages)) | |
| stats["ready"] += 1 | |
| stats["tokens"] += tokens | |
| kept_from_source += 1 | |
| continue | |
| # needs reasoning synthesis | |
| if args.missing_think_policy == "drop": | |
| stats["dropped_no_think"] += 1 | |
| continue | |
| if args.missing_think_policy == "wrap": | |
| ready_rows.append(_wrap_record(row_id, source, ex, wrap_messages(ex.messages))) | |
| stats["wrapped"] += 1 | |
| stats["ready"] += 1 | |
| stats["tokens"] += tokens | |
| kept_from_source += 1 | |
| continue | |
| # synthesize: only single-assistant-turn examples are eligible | |
| if assistant_turn_count(ex.messages) != 1: | |
| stats["dropped_no_think"] += 1 | |
| continue | |
| prompt_messages, answer = split_prompt_answer(ex.messages) | |
| synth_rows.append( | |
| { | |
| "id": row_id, | |
| "source": source["hf_id"], | |
| "license": source.get("license", "missing"), | |
| "group": source.get("group"), | |
| "prompt_messages": prompt_messages, | |
| "reference_answer": answer, | |
| "verify": ex.verify or {"mode": "backfill", "answer": answer}, | |
| "metadata": ex.metadata, | |
| } | |
| ) | |
| stats["to_synthesize"] += 1 | |
| kept_from_source += 1 | |
| # Optionally fold in already-synthesized rows. | |
| synthesized_added = 0 | |
| if args.include_synthesized: | |
| synth_in = Path(args.include_synthesized) | |
| if not synth_in.is_file(): | |
| skipped.append(f"--include-synthesized: missing {synth_in}") | |
| else: | |
| for row in read_jsonl(synth_in): | |
| messages = row.get("messages") | |
| if not isinstance(messages, list): | |
| continue | |
| if not any(m.get("role") == "assistant" and has_think(m.get("content", "")) for m in messages): | |
| continue | |
| key_hash = dedup_key(messages) | |
| if key_hash in seen: | |
| continue | |
| seen.add(key_hash) | |
| ready_rows.append(row) | |
| synthesized_added += 1 | |
| bal_log: list[str] = [] | |
| if args.balance_detection: | |
| ready_rows, bal = _balance_detection(ready_rows) | |
| bal_log.append( | |
| f"detection balanced: vulnerable={bal['det_pos']} not_vulnerable={bal['det_neg']} " | |
| f"-> kept {bal['kept_each']} each; {bal['other']} non-detection rows untouched" | |
| ) | |
| if args.enforce_caps: | |
| ready_rows, cap_log = _enforce_source_caps(ready_rows, manifest, per_source) | |
| else: | |
| cap_log = [] | |
| cap_log = bal_log + cap_log | |
| _write_jsonl(ready_path, ready_rows) | |
| _write_jsonl(synth_path, synth_rows) | |
| _write_data_card( | |
| report_path, args, manifest, per_source, ready_rows, synth_rows, skipped, cap_log, synthesized_added | |
| ) | |
| summary = { | |
| "mix": args.mix_name, | |
| "profile": args.profile, | |
| "ready_rows": len(ready_rows), | |
| "to_synthesize_rows": len(synth_rows), | |
| "synthesized_added": synthesized_added, | |
| "ready_path": str(ready_path), | |
| "synth_path": str(synth_path), | |
| "data_card": str(report_path), | |
| "skipped": len(skipped), | |
| } | |
| print(json.dumps(summary, indent=2)) | |
| return 0 | |
| def _wrap_record(row_id: str, source: dict[str, Any], ex: Example, messages: list[dict[str, str]]) -> dict[str, Any]: | |
| return { | |
| "id": row_id, | |
| "source": source["hf_id"], | |
| "license": source.get("license", "missing"), | |
| "group": source.get("group"), | |
| "messages": messages, | |
| "metadata": {**ex.metadata, "think_status": ex.think_status}, | |
| } | |
| def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("w", encoding="utf-8") as out: | |
| for row in rows: | |
| out.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") | |
| def _enforce_source_caps( | |
| ready_rows: list[dict[str, Any]], | |
| manifest: dict[str, Any], | |
| per_source: dict[str, Any], | |
| ) -> tuple[list[dict[str, Any]], list[str]]: | |
| """Deterministically downsample any source above the token-fraction cap.""" | |
| data_mix = manifest.get("source_caps", {}) | |
| max_frac = float(data_mix.get("max_single_source_token_fraction", 0.40)) | |
| by_source: dict[str, list[dict[str, Any]]] = {} | |
| for row in ready_rows: | |
| by_source.setdefault(row.get("source", "?"), []).append(row) | |
| total_tokens = sum(estimate_tokens(r["messages"]) for r in ready_rows) | |
| cap_tokens = int(max_frac * total_tokens) if total_tokens else 0 | |
| log: list[str] = [] | |
| kept: list[dict[str, Any]] = [] | |
| for src, rows in by_source.items(): | |
| rows_sorted = sorted(rows, key=lambda r: r.get("id", "")) | |
| running = 0 | |
| src_kept = [] | |
| for r in rows_sorted: | |
| t = estimate_tokens(r["messages"]) | |
| # Always keep at least one row so capping never zeroes a source; a | |
| # single row larger than the cap is kept rather than dropped whole. | |
| if cap_tokens and src_kept and running + t > cap_tokens: | |
| continue | |
| running += t | |
| src_kept.append(r) | |
| if len(src_kept) < len(rows): | |
| log.append(f"{src}: capped {len(rows)} -> {len(src_kept)} rows (~{max_frac:.0%} token cap)") | |
| kept.extend(src_kept) | |
| return kept, log | |
| def _balance_detection(rows: list[dict[str, Any]], seed: int = 1337) -> tuple[list[dict[str, Any]], dict[str, int]]: | |
| """1:1 downsample vuln-detection rows by label; leave all other rows untouched.""" | |
| det_pos, det_neg, other = [], [], [] | |
| for r in rows: | |
| m = r.get("metadata", {}) or {} | |
| if m.get("task") == "vuln_detection" and m.get("label") in ("vulnerable", "not_vulnerable"): | |
| (det_pos if m["label"] == "vulnerable" else det_neg).append(r) | |
| else: | |
| other.append(r) | |
| rng = random.Random(seed) | |
| rng.shuffle(det_pos) | |
| rng.shuffle(det_neg) | |
| n = min(len(det_pos), len(det_neg)) | |
| combined = other + det_pos[:n] + det_neg[:n] | |
| rng.shuffle(combined) | |
| return combined, {"det_pos": len(det_pos), "det_neg": len(det_neg), "kept_each": n, "other": len(other)} | |
| def _token_fractions(ready_rows: list[dict[str, Any]]) -> dict[str, float]: | |
| by_source: dict[str, int] = {} | |
| for row in ready_rows: | |
| by_source[row.get("source", "?")] = by_source.get(row.get("source", "?"), 0) + estimate_tokens( | |
| row["messages"] | |
| ) | |
| total = sum(by_source.values()) or 1 | |
| return {k: v / total for k, v in sorted(by_source.items(), key=lambda kv: -kv[1])} | |
| def _write_data_card( | |
| path: Path, | |
| args: argparse.Namespace, | |
| manifest: dict[str, Any], | |
| per_source: dict[str, Any], | |
| ready_rows: list[dict[str, Any]], | |
| synth_rows: list[dict[str, Any]], | |
| skipped: list[str], | |
| cap_log: list[str], | |
| synthesized_added: int, | |
| ) -> None: | |
| fractions = _token_fractions(ready_rows) | |
| lines: list[str] = [] | |
| lines.append(f"# Data Card — {args.mix_name} ({args.profile})") | |
| lines.append("") | |
| lines.append(f"- Manifest: `{args.manifest}`") | |
| lines.append(f"- Missing-think policy: `{args.missing_think_policy}`") | |
| lines.append(f"- Ready rows (have `<think>`): **{len(ready_rows)}**") | |
| lines.append(f"- Rows queued for reasoning synthesis: **{len(synth_rows)}**") | |
| lines.append(f"- Synthesized rows folded in this build: **{synthesized_added}**") | |
| lines.append("") | |
| lines.append("## Per-source") | |
| lines.append("") | |
| lines.append("| key | hf_id | adapter | license | auth | raw | examples | ready | to_synth | dup | tokens≈ |") | |
| lines.append("|---|---|---|---|---|---|---|---|---|---|---|") | |
| for key, s in per_source.items(): | |
| lines.append( | |
| f"| {key} | {s['hf_id']} | {s['adapter']} | {s['license']} | {s.get('auth')} | " | |
| f"{s['raw_rows']} | {s['examples']} | {s['ready']} | {s['to_synthesize']} | " | |
| f"{s['dropped_dup']} | {s['tokens']} |" | |
| ) | |
| lines.append("") | |
| lines.append("## Ready-mix token fraction by source") | |
| lines.append("") | |
| cap = manifest.get("source_caps", {}).get("max_single_source_token_fraction", 0.40) | |
| for src, frac in fractions.items(): | |
| flag = " ⚠️ over cap" if frac > float(cap) else "" | |
| lines.append(f"- {src}: {frac:.1%}{flag}") | |
| lines.append("") | |
| lines.append(f"Single-source token cap: {float(cap):.0%}") | |
| if cap_log: | |
| lines.append("") | |
| lines.append("## Cap enforcement") | |
| for entry in cap_log: | |
| lines.append(f"- {entry}") | |
| # License flags | |
| missing_lic = sorted({s["hf_id"] for s in per_source.values() if str(s["license"]).lower() == "missing"}) | |
| if missing_lic: | |
| lines.append("") | |
| lines.append("## ⚠️ Sources with no stated license (record provenance / academic-use only)") | |
| for hf_id in missing_lic: | |
| lines.append(f"- {hf_id}") | |
| if skipped: | |
| lines.append("") | |
| lines.append("## Skipped / warnings") | |
| for entry in skipped[:200]: | |
| lines.append(f"- {entry}") | |
| if len(skipped) > 200: | |
| lines.append(f"- ... and {len(skipped) - 200} more") | |
| lines.append("") | |
| lines.append("> Decontamination against eval splits is a separate required step before training.") | |
| path.write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| if __name__ == "__main__": | |
| raise SystemExit(build()) | |