Spaces:
Running
Running
| """Phase 2 data cleaning and splitting. | |
| Loads raw questions, removes duplicates, and writes train/val/test splits. | |
| """ | |
| import json | |
| import os | |
| import sys | |
| import pandas as pd | |
| from sklearn.model_selection import train_test_split | |
| sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| import config # noqa: E402 | |
| class DatasetProcessor: | |
| """Clean, deduplicate, and split the raw dataset.""" | |
| def __init__(self, processing_config): | |
| """Store processing config.""" | |
| self.cfg = processing_config | |
| def load_raw(self): | |
| """Load raw JSONL into a DataFrame.""" | |
| records = [] | |
| with open(self.cfg.raw_path, "r", encoding="utf-8") as handle: | |
| for line in handle: | |
| line = line.strip() | |
| if line: | |
| records.append(json.loads(line)) | |
| frame = pd.DataFrame(records) | |
| print(f"Loaded {len(frame)} raw questions.") | |
| return frame | |
| def _normalise(text): | |
| """Normalize text for exact duplicate checks.""" | |
| return " ".join(text.lower().split()) | |
| def remove_exact_duplicates(self, frame): | |
| """Drop exact duplicates and keep the first one.""" | |
| before = len(frame) | |
| frame = frame.copy() | |
| frame["_norm"] = frame["question"].map(self._normalise) | |
| frame = frame.drop_duplicates(subset="_norm", keep="first") | |
| frame = frame.drop(columns="_norm").reset_index(drop=True) | |
| print(f"Exact dedup: {before} -> {len(frame)} " | |
| f"({before - len(frame)} removed).") | |
| return frame | |
| def remove_near_duplicates(self, frame): | |
| """Remove near-duplicate questions with embedding similarity.""" | |
| from sentence_transformers import SentenceTransformer, util | |
| before = len(frame) | |
| questions = frame["question"].tolist() | |
| print(f"Encoding {before} questions for near-dup detection " | |
| f"(model: {self.cfg.embed_model})...") | |
| model = SentenceTransformer(self.cfg.embed_model) | |
| embeddings = model.encode( | |
| questions, convert_to_tensor=True, show_progress_bar=True | |
| ) | |
| # Returns [score, i, j] sorted by score. | |
| pairs = util.paraphrase_mining_embeddings(embeddings) | |
| removed = set() | |
| for score, i, j in pairs: | |
| if score < self.cfg.near_dup_threshold: | |
| break | |
| low, high = (i, j) if i < j else (j, i) | |
| if low not in removed and high not in removed: | |
| removed.add(high) | |
| kept_mask = [idx not in removed for idx in range(before)] | |
| frame = frame[kept_mask].reset_index(drop=True) | |
| print(f"Near-dup removal (threshold {self.cfg.near_dup_threshold}): " | |
| f"{before} -> {len(frame)} ({before - len(frame)} removed).") | |
| return frame | |
| def split_in_domain(self, frame): | |
| """Split in-domain rows into train/val/test.""" | |
| in_domain = frame[frame["split"] == "train"].copy().reset_index(drop=True) | |
| ood = frame[frame["split"] == "ood"].copy().reset_index(drop=True) | |
| strat_key = in_domain["domain"] + "|" + in_domain["bloom_class"] | |
| # Split off test first. | |
| train_val, test = train_test_split( | |
| in_domain, | |
| test_size=self.cfg.test_size, | |
| stratify=strat_key, | |
| random_state=self.cfg.random_state, | |
| ) | |
| # Then split validation from the rest. | |
| strat_key_tv = train_val["domain"] + "|" + train_val["bloom_class"] | |
| relative_val = self.cfg.val_size / (1.0 - self.cfg.test_size) | |
| train, val = train_test_split( | |
| train_val, | |
| test_size=relative_val, | |
| stratify=strat_key_tv, | |
| random_state=self.cfg.random_state, | |
| ) | |
| splits = { | |
| "train": train.reset_index(drop=True), | |
| "val": val.reset_index(drop=True), | |
| "test": test.reset_index(drop=True), | |
| "ood_test": ood, | |
| } | |
| return splits | |
| def save_splits(self, splits): | |
| """Save each split as a JSONL file.""" | |
| os.makedirs(self.cfg.processed_dir, exist_ok=True) | |
| keep_cols = [ | |
| "question", "bloom_sublevel", "bloom_class", | |
| "domain", "topic", "source", | |
| ] | |
| for name, frame in splits.items(): | |
| path = os.path.join(self.cfg.processed_dir, f"{name}.jsonl") | |
| with open(path, "w", encoding="utf-8") as handle: | |
| for _, row in frame.iterrows(): | |
| record = {col: row[col] for col in keep_cols if col in row} | |
| handle.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| print(f" wrote {len(frame):4d} -> {path}") | |
| def print_breakdown(splits): | |
| """Print split sizes and class/domain counts.""" | |
| print("\nSplit sizes:") | |
| for name, frame in splits.items(): | |
| print(f" {name:9s} {len(frame)}") | |
| print("\nClass balance per split:") | |
| for name, frame in splits.items(): | |
| counts = frame["bloom_class"].value_counts().to_dict() | |
| print(f" {name:9s} {counts}") | |
| print("\nPer-(domain x class) counts (all splits combined):") | |
| combined = pd.concat(splits.values()) | |
| table = combined.groupby(["domain", "bloom_class"]).size().unstack(fill_value=0) | |
| print(table.to_string()) | |
| def run(self): | |
| """Run the full Phase 2 pipeline.""" | |
| frame = self.load_raw() | |
| frame = self.remove_exact_duplicates(frame) | |
| frame = self.remove_near_duplicates(frame) | |
| splits = self.split_in_domain(frame) | |
| self.save_splits(splits) | |
| self.print_breakdown(splits) | |
| print("\nPhase 2 complete.") | |
| def main(): | |
| """Run Phase 2 processing.""" | |
| processor = DatasetProcessor(config.ProcessingConfig()) | |
| processor.run() | |
| if __name__ == "__main__": | |
| main() | |