Spaces:
Running on Zero
Running on Zero
| """Normalise the source datasets into one turn-level corpus and build the splits. | |
| python -m lighthouse.data.build_splits | |
| Design notes that matter for the results: | |
| * **Dedupe before splitting.** Near-duplicate posts across sources would otherwise land on | |
| both sides of the train/test boundary and inflate every number we report. Dedupe is on a | |
| normalised text hash across the whole corpus, before any split is drawn. | |
| * **Cap per class, per source.** The raw corpus is ~510k rows and wildly imbalanced | |
| (SELF_HARM is plentiful, THREAT is not). Capping keeps training tractable on a laptop | |
| and keeps the imbalance in a range class weighting can actually fix. | |
| * **Stratify on the harm label** so every split sees every class, including the scarce ones. | |
| * **Fixed seed** from ``config.SEED``. Never randomise per run. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import re | |
| import sys | |
| from collections import Counter | |
| import pandas as pd | |
| from sklearn.model_selection import train_test_split | |
| from lighthouse import config | |
| from lighthouse.data.mapping import SOURCES, Source | |
| from lighthouse.taxonomy import HARM_ORDER, Harm | |
| #: Cap per (source, harm) pair. THREAT is nowhere near this, which is the point of | |
| #: reporting per-class support honestly rather than hiding it behind a macro average. | |
| PER_CLASS_CAP = 12_000 | |
| _WS = re.compile(r"\s+") | |
| _URL = re.compile(r"https?://\S+|www\.\S+") | |
| _USER = re.compile(r"@\w+") | |
| def clean_text(raw: str) -> str: | |
| """Light normalisation only. | |
| Deliberately conservative: we do not lowercase, strip punctuation, or remove stop | |
| words. The downstream model is a cased-agnostic transformer that benefits from | |
| ALL-CAPS SHOUTING and punctuation as harm signal, and the TF-IDF baseline should be | |
| compared on the same text the transformer sees. | |
| """ | |
| text = _URL.sub(" [URL] ", raw) | |
| text = _USER.sub(" [USER] ", text) | |
| text = text.replace("\\n", " ").replace("\n", " ") | |
| text = _WS.sub(" ", text).strip() | |
| return text[: config.MAX_TURN_CHARS] | |
| def norm_key(text: str) -> str: | |
| """Aggressive normalisation, used ONLY for dedupe, never for training.""" | |
| k = _WS.sub(" ", text.lower().strip()) | |
| k = re.sub(r"[^a-z0-9 ]", "", k) | |
| return hashlib.sha1(k.encode()).hexdigest() | |
| def load_source(src: Source) -> pd.DataFrame: | |
| from datasets import load_dataset | |
| print(f" loading {src.hf_id} ...", flush=True) | |
| ds = load_dataset(src.hf_id, split=src.split) | |
| rows: list[dict] = [] | |
| dropped = 0 | |
| for row in ds: | |
| harm = src.to_harm(row) | |
| if harm is None: | |
| dropped += 1 | |
| continue | |
| text = clean_text(str(row.get(src.text_column, ""))) | |
| if len(text) < config.MIN_TURN_CHARS: | |
| dropped += 1 | |
| continue | |
| rows.append({"text": text, "harm": harm.value, "source": src.key}) | |
| df = pd.DataFrame(rows) | |
| print(f" kept {len(df):,} dropped {dropped:,}") | |
| print(f" {dict(Counter(df['harm']).most_common())}") | |
| return df | |
| def cap_per_class(df: pd.DataFrame, cap: int, seed: int) -> pd.DataFrame: | |
| out = [] | |
| for (source, harm), group in df.groupby(["source", "harm"]): | |
| if len(group) > cap: | |
| group = group.sample(n=cap, random_state=seed) | |
| out.append(group) | |
| return pd.concat(out, ignore_index=True) | |
| def build() -> None: | |
| config.SPLITS_DIR.mkdir(parents=True, exist_ok=True) | |
| print("== loading sources ==") | |
| frames = [load_source(s) for s in SOURCES] | |
| corpus = pd.concat(frames, ignore_index=True) | |
| print(f"\nraw corpus: {len(corpus):,} rows") | |
| print("\n== dedupe (before splitting, across all sources) ==") | |
| corpus["_key"] = corpus["text"].map(norm_key) | |
| before = len(corpus) | |
| corpus = corpus.drop_duplicates(subset="_key", keep="first").drop(columns="_key") | |
| print(f" removed {before - len(corpus):,} duplicates -> {len(corpus):,} rows") | |
| print(f"\n== cap at {PER_CLASS_CAP:,} per (source, class) ==") | |
| corpus = cap_per_class(corpus, PER_CLASS_CAP, config.SEED) | |
| corpus = corpus.sample(frac=1.0, random_state=config.SEED).reset_index(drop=True) | |
| print(f" corpus: {len(corpus):,} rows") | |
| counts = Counter(corpus["harm"]) | |
| print("\n== class distribution ==") | |
| for harm in HARM_ORDER: | |
| n = counts.get(harm.value, 0) | |
| pct = 100 * n / len(corpus) | |
| print(f" {harm.value:<16} {n:>7,} {pct:5.1f}%") | |
| scarce = [h.value for h in HARM_ORDER if counts.get(h.value, 0) < 1000] | |
| if scarce: | |
| print(f"\n NOTE: low support for {scarce}. Class weighting is on; report") | |
| print(" per-class F1 alongside the macro average, never the macro alone.") | |
| print("\n== splits (stratified on harm, seed " | |
| f"{config.SEED}) ==") | |
| train_val, test = train_test_split( | |
| corpus, | |
| test_size=config.TEST_FRACTION, | |
| random_state=config.SEED, | |
| stratify=corpus["harm"], | |
| ) | |
| val_rel = config.VAL_FRACTION / (1.0 - config.TEST_FRACTION) | |
| train, val = train_test_split( | |
| train_val, | |
| test_size=val_rel, | |
| random_state=config.SEED, | |
| stratify=train_val["harm"], | |
| ) | |
| for name, part in [("train", train), ("val", val), ("test", test)]: | |
| path = config.SPLITS_DIR / f"turns_{name}.parquet" | |
| part.reset_index(drop=True).to_parquet(path, index=False) | |
| pct = 100 * len(part) / len(corpus) | |
| print(f" {name:<6} {len(part):>7,} {pct:4.1f}% -> {path.relative_to(config.REPO_ROOT)}") | |
| print("\n== per-class support by split ==") | |
| header = f" {'class':<16}" + "".join(f"{n:>9}" for n in ("train", "val", "test")) | |
| print(header) | |
| for harm in HARM_ORDER: | |
| cells = "".join( | |
| f"{int((part['harm'] == harm.value).sum()):>9,}" | |
| for part in (train, val, test) | |
| ) | |
| print(f" {harm.value:<16}{cells}") | |
| print("\ndone.") | |
| if __name__ == "__main__": | |
| sys.exit(build()) | |