JetLaggedByData/scifi-forge
0
1"""2data/verify_dataset.py3Sanity-check the generated JSONL files before kicking off fine-tuning.4 5Checks:6 - Files exist and are non-empty7 - Every line is valid JSON with required keys8 - No duplicate samples (input hash collision check)9 - Length distribution looks sane10 - Prints 3 random samples for manual inspection11 12Run:13 python data/verify_dataset.py14"""15 16import json17import random18import hashlib19from pathlib import Path20from collections import Counter21 22from dataset_config import TRAIN_JSONL, VAL_JSONL, STATS_JSON23 24 25REQUIRED_KEYS = {"instruction", "input", "output"}26SAMPLE_PREVIEW_N = 327 28 29# ── Loaders ───────────────────────────────────────────────────────────────30 31def load_jsonl(path: Path) -> list[dict]:32 """Load all records from a .jsonl file."""33 records = []34 with path.open("r", encoding="utf-8") as f:35 for lineno, line in enumerate(f, 1):36 line = line.strip()37 if not line:38 continue39 try:40 records.append(json.loads(line))41 except json.JSONDecodeError as e:42 raise ValueError(f"Invalid JSON on line {lineno} of {path}: {e}") from e43 return records44 45 46# ── Checks ────────────────────────────────────────────────────────────────47 48def check_schema(records: list[dict], split: str) -> list[str]:49 """Return list of error messages for schema violations."""50 errors = []51 for i, rec in enumerate(records):52 missing = REQUIRED_KEYS - set(rec.keys())53 if missing:54 errors.append(f"[{split}] Record {i} missing keys: {missing}")55 for key in REQUIRED_KEYS:56 if key in rec and not isinstance(rec[key], str):57 errors.append(f"[{split}] Record {i} key '{key}' is not a string")58 return errors59 60 61def check_duplicates(records: list[dict], split: str) -> tuple[int, list[str]]:62 """Hash input fields and return duplicate count + example messages."""63 hashes = [hashlib.md5(r["input"].encode()).hexdigest() for r in records]64 counts = Counter(hashes)65 dupes = {h: c for h, c in counts.items() if c > 1}66 messages = []67 for h, c in list(dupes.items())[:5]: # show up to 5 examples68 messages.append(f"[{split}] Hash {h[:8]}... appears {c} times")69 return len(dupes), messages70 71 72def length_stats(records: list[dict], split: str) -> dict:73 """Return input/output length percentiles."""74 input_lens = [len(r["input"]) for r in records]75 output_lens = [len(r["output"]) for r in records]76 77 def percentiles(vals: list[int]) -> dict:78 vals_sorted = sorted(vals)79 n = len(vals_sorted)80 return {81 "min": vals_sorted[0],82 "p10": vals_sorted[n // 10],83 "p50": vals_sorted[n // 2],84 "p90": vals_sorted[int(n * 0.9)],85 "max": vals_sorted[-1],86 "mean": round(sum(vals) / n, 1),87 }88 89 return {90 "split": split,91 "n": len(records),92 "input": percentiles(input_lens),93 "output": percentiles(output_lens),94 }95 96 97def check_empty_fields(records: list[dict], split: str) -> list[str]:98 """Flag records where input or output is suspiciously short."""99 errors = []100 for i, rec in enumerate(records):101 if len(rec.get("input", "")) < 50:102 errors.append(f"[{split}] Record {i} input too short ({len(rec['input'])} chars)")103 if len(rec.get("output", "")) < 20:104 errors.append(f"[{split}] Record {i} output too short ({len(rec['output'])} chars)")105 return errors106 107 108def print_samples(records: list[dict], split: str, n: int = SAMPLE_PREVIEW_N) -> None:109 """Print n random samples for manual eyeballing."""110 print(f"\n── Random Samples from {split} ──────────────────────────────")111 for rec in random.sample(records, min(n, len(records))):112 print(f"\n [instruction] {rec['instruction']}")113 print(f" [input] ...{rec['input'][-200:].strip()!r}")114 print(f" [output] {rec['output'][:200].strip()!r}...")115 print(" " + "─" * 60)116 117 118# ── Main ──────────────────────────────────────────────────────────────────119 120def verify(train_path: Path = TRAIN_JSONL, val_path: Path = VAL_JSONL) -> bool:121 """122 Run all checks. Returns True if dataset passes, False if errors found.123 """124 all_errors: list[str] = []125 all_warnings: list[str] = []126 127 for path, split in [(train_path, "train"), (val_path, "val")]:128 print(f"\nChecking {split}: {path}")129 130 if not path.exists():131 all_errors.append(f"[{split}] File not found: {path}")132 continue133 134 records = load_jsonl(path)135 if not records:136 all_errors.append(f"[{split}] File is empty")137 continue138 139 print(f" Records loaded: {len(records):,}")140 141 # Schema142 schema_errors = check_schema(records, split)143 all_errors.extend(schema_errors)144 145 # Duplicates146 dupe_count, dupe_msgs = check_duplicates(records, split)147 if dupe_count > 0:148 all_warnings.append(149 f"[{split}] {dupe_count} duplicate inputs found (may be acceptable for sliding window)"150 )151 all_warnings.extend(dupe_msgs[:3])152 153 # Empty fields154 empty_errors = check_empty_fields(records, split)155 if len(empty_errors) > 10:156 all_errors.append(f"[{split}] {len(empty_errors)} records with suspiciously short fields")157 else:158 all_errors.extend(empty_errors)159 160 # Length stats161 stats = length_stats(records, split)162 print(f" Input length — min:{stats['input']['min']} "163 f"p50:{stats['input']['p50']} p90:{stats['input']['p90']} "164 f"max:{stats['input']['max']} mean:{stats['input']['mean']}")165 print(f" Output length — min:{stats['output']['min']} "166 f"p50:{stats['output']['p50']} p90:{stats['output']['p90']} "167 f"max:{stats['output']['max']} mean:{stats['output']['mean']}")168 169 # Instruction uniformity170 instructions = Counter(r["instruction"] for r in records)171 if len(instructions) > 1:172 all_warnings.append(f"[{split}] Multiple instruction variants: {dict(instructions)}")173 174 print_samples(records, split)175 176 # Load and print saved stats177 if STATS_JSON.exists():178 saved = json.loads(STATS_JSON.read_text())179 print(f"\n── Saved Dataset Stats ({STATS_JSON}) ───────────────────────")180 for k, v in saved.items():181 print(f" {k}: {v}")182 183 # Report184 print("\n── Verification Summary ─────────────────────────────────────")185 if all_warnings:186 print(f" ⚠️ Warnings ({len(all_warnings)}):")187 for w in all_warnings:188 print(f" {w}")189 190 if all_errors:191 print(f" ❌ Errors ({len(all_errors)}):")192 for e in all_errors:193 print(f" {e}")194 print("\nDataset has errors — fix before fine-tuning.")195 return False196 197 print(" ✅ All checks passed — dataset is ready for fine-tuning.")198 return True199 200 201if __name__ == "__main__":202 random.seed(0)203 passed = verify()204 raise SystemExit(0 if passed else 1)205 