#!/usr/bin/env python3 """Build think-SFT datasets using pre-generated reasoning traces. For each compression level (2x/3x/5x/9x), produce ShareGPT-format training data where the assistant target is `{reasoning}{answer}`. Also produces a mixed-compression dataset that concatenates all 4. Input: think_traces_Nk.jsonl with {query, chunk_path, answer, reasoning} Output: per-compression and mixed ShareGPT JSON + dataset_info.json """ from __future__ import annotations import argparse import json import random from pathlib import Path COMPRESSIONS = ["2x", "3x", "5x", "9x"] BASE_DATA = "/scratch/users/zwcolin/cxr_embeds/sft_data" def format_assistant(reasoning: str, answer: str) -> str: # Qwen3 thinking format: ...answer return f"\n{reasoning.strip()}\n\n\n{answer.strip()}" def main(): p = argparse.ArgumentParser() p.add_argument( "--traces", required=True, help="JSONL with {query, chunk_path, answer, reasoning}", ) p.add_argument("--output-dir", default=f"{BASE_DATA}/think_sft") p.add_argument("--seed", type=int, default=42) args = p.parse_args() out = Path(args.output_dir) out.mkdir(parents=True, exist_ok=True) # Load traces with open(args.traces) as f: traces = [json.loads(line) for line in f] traces = [t for t in traces if t.get("reasoning")] print(f"Loaded {len(traces)} traces") # For each compression, build think-SFT examples pointing at the compressed image info = {} rng = random.Random(args.seed) per_comp = {} for c in COMPRESSIONS: c_dir = Path(f"{BASE_DATA}/compressed_{c}/images") data = [] skipped = 0 for t in traces: # Image path: compressed_Nx/images/ src_rel = t["chunk_path"] img_path = str(c_dir / src_rel) if not Path(img_path).exists(): skipped += 1 continue data.append( { "messages": [ {"role": "user", "content": "\n" + t["query"]}, { "role": "assistant", "content": format_assistant(t["reasoning"], t["answer"]), }, ], "images": [img_path], } ) print(f" {c}: {len(data)} ({skipped} skipped)") out_json = out / f"train_{c}.json" out_json.write_text(json.dumps(data, ensure_ascii=False)) per_comp[c] = data info[f"think_train_{c}"] = { "file_name": str(out_json), "formatting": "sharegpt", "columns": {"messages": "messages", "images": "images"}, "tags": { "role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant", }, } # Mixed dataset: concat all 4 mixed = [] for c in COMPRESSIONS: mixed.extend(per_comp[c]) rng.shuffle(mixed) print(f" mixed: {len(mixed)}") mixed_json = out / "train_mixed.json" mixed_json.write_text(json.dumps(mixed, ensure_ascii=False)) info["think_train_mixed"] = { "file_name": str(mixed_json), "formatting": "sharegpt", "columns": {"messages": "messages", "images": "images"}, "tags": { "role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant", }, } # Reuse existing eval sets (no reasoning needed, we still eval on plain Q→A) # ... actually to monitor think loss we should have think eval too. Let's skip for now — # just point eval_dataset at existing compressed_qa_eval per compression if needed. # For simplicity, write a small think_eval (5x only) from first 500 eval examples with reasoning. # Actually: eval on plain answer is fine, training loss monitors itself. # For LF, point eval_dataset to the existing non-think eval of the matching compression. # We reference those via symlink or just list them here as mixed_eval_Nx for c in COMPRESSIONS: info[f"think_eval_{c}"] = { "file_name": f"{BASE_DATA}/compressed_{c}/eval.json", "formatting": "sharegpt", "columns": {"messages": "messages", "images": "images"}, "tags": { "role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant", }, } info_path = out / "dataset_info.json" info_path.write_text(json.dumps(info, indent=2)) print(f"\nDataset info: {info_path}") if __name__ == "__main__": main()