Spaces:
Running
Running
Download backend/src/data_prep.py from VivDubs/eumora-api: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/spaces/VivDubs/eumora-api/resolve/main/backend/src/data_prep.py
- Command line
-
hf download hf://spaces/VivDubs/eumora-api/backend/src/data_prep.py
-
curl -L -o data_prep.py https://huggingface.co/spaces/VivDubs/eumora-api/resolve/main/backend/src/data_prep.py
20.8 kB
| """Dataset construction with soft multi-labels and corrected sarcasm sampling. | |
| Three defects in the baseline pipeline are fixed here. | |
| 1. Multi-label collapse | |
| GoEmotions annotates each comment with *all* emotions the raters marked. | |
| The baseline kept `labels[0]` and discarded the rest, so a comment rated | |
| {grief, sadness} became an arbitrary single class and a comment rated | |
| {love, joy} silently disagreed with an identical one ordered {joy, love}. | |
| That is pure label noise. We build the normalized annotator distribution | |
| instead and train with soft-target cross-entropy, which preserves the | |
| agreement structure and provides data-derived label smoothing. | |
| 2. Broken sarcasm contrast | |
| SARC is a *balanced* corpus: every sarcastic comment is paired with a | |
| non-sarcastic sibling from the same thread on the same topic. The baseline | |
| kept only `label == 1`, so all 25k sarcasm examples shared one surface style | |
| (Reddit comment) with no counterexamples in that style. The model could | |
| satisfy the objective by learning "reads like a Reddit comment" rather than | |
| "means the opposite of what it says" β which is why sarcasm fires on neutral | |
| input like "driving with the windows down". Keeping both members of each pair | |
| restores the contrast the corpus was built to provide. | |
| 3. Distorted sarcasm prior | |
| Oversampling to 25k gave sarcasm a ~30% training prior against a deployment | |
| rate closer to 5-10%. The classifier is therefore biased toward sarcasm at | |
| inference, which the codebase compensates for with an 8% "tripwire" in | |
| predict.py and a 55% threshold in the frontend. We sample to a realistic | |
| prior and record the exact empirical priors so calibration.py can apply a | |
| principled label-shift correction instead. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections import Counter | |
| from typing import Dict, List, Optional, Tuple | |
| import numpy as np | |
| from datasets import Dataset, DatasetDict, load_dataset | |
| from .config import config | |
| TARGET_LABELS = ["sadness", "joy", "love", "anger", "fear", "surprise", "neutral", "sarcasm"] | |
| LABEL2ID = {label: i for i, label in enumerate(TARGET_LABELS)} | |
| NUM_LABELS = len(TARGET_LABELS) | |
| # GoEmotions' 28 fine-grained labels folded into our 8 coarse classes. | |
| # `approval`/`embarrassment` land in neutral because neither maps cleanly onto a | |
| # core affect and both are dominated by low-arousal, informational text. | |
| GOEMOTIONS_MAPPING = { | |
| "joy": "joy", "amusement": "joy", "excitement": "joy", "optimism": "joy", | |
| "pride": "joy", "relief": "joy", "admiration": "joy", | |
| "sadness": "sadness", "grief": "sadness", "disappointment": "sadness", | |
| "remorse": "sadness", | |
| "anger": "anger", "annoyance": "anger", "disapproval": "anger", | |
| "disgust": "anger", | |
| "fear": "fear", "nervousness": "fear", | |
| "love": "love", "caring": "love", "desire": "love", "gratitude": "love", | |
| "surprise": "surprise", "realization": "surprise", "confusion": "surprise", | |
| "curiosity": "surprise", | |
| "neutral": "neutral", "approval": "neutral", "embarrassment": "neutral", | |
| } | |
| def _soft_target(label_ids: List[int]) -> List[float]: | |
| """Normalized distribution over the coarse classes for one sample.""" | |
| dist = np.zeros(NUM_LABELS, dtype=np.float32) | |
| if not label_ids: | |
| return dist.tolist() | |
| counts = Counter(label_ids) | |
| for idx, n in counts.items(): | |
| dist[idx] = n | |
| dist /= dist.sum() | |
| return dist.tolist() | |
| def _hard_from_soft(soft: List[float]) -> int: | |
| return int(np.argmax(soft)) | |
| # ββ GoEmotions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def prepare_goemotions() -> DatasetDict: | |
| """GoEmotions with full annotator distributions preserved.""" | |
| print(" loading GoEmotions...") | |
| raw = load_dataset("google-research-datasets/go_emotions", "simplified") | |
| names = raw["train"].features["labels"].feature.names | |
| def to_soft(example): | |
| mapped = [ | |
| LABEL2ID[GOEMOTIONS_MAPPING[names[i]]] | |
| for i in example["labels"] | |
| if names[i] in GOEMOTIONS_MAPPING | |
| ] | |
| soft = _soft_target(mapped) | |
| return { | |
| "text": example["text"], | |
| "soft_label": soft, | |
| "label": _hard_from_soft(soft) if mapped else -1, | |
| "source": "goemotions", | |
| } | |
| out = {} | |
| for split in ("train", "validation", "test"): | |
| ds = raw[split].map(to_soft, remove_columns=raw[split].column_names) | |
| out[split] = ds.filter(lambda x: x["label"] != -1) | |
| return DatasetDict(out) | |
| # ββ dair-ai/emotion βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def prepare_dair() -> DatasetDict: | |
| """dair-ai/emotion β single-label, already aligned to indices 0-5.""" | |
| print(" loading dair-ai/emotion...") | |
| raw = load_dataset("dair-ai/emotion") | |
| def to_soft(example): | |
| soft = np.zeros(NUM_LABELS, dtype=np.float32) | |
| soft[example["label"]] = 1.0 | |
| return { | |
| "text": example["text"], | |
| "soft_label": soft.tolist(), | |
| "label": example["label"], | |
| "source": "dair", | |
| } | |
| return DatasetDict( | |
| { | |
| split: raw[split].map(to_soft, remove_columns=raw[split].column_names) | |
| for split in ("train", "validation", "test") | |
| } | |
| ) | |
| # ββ SARC (sarcasm), with the pair contrast restored βββββββββββββββββββββββββββ | |
| CONTRAST_AUGMENTATION = [ | |
| "I love it when my tire pops on the highway", | |
| "I love spending 5 hours in traffic.", | |
| "Oh, fantastic! The server is down again.", | |
| "Great, another mandatory team-building exercise.", | |
| "I'm absolutely thrilled that my flight was canceled.", | |
| "Wow, you really outdid yourself. Breaking production on a Friday takes talent.", | |
| "What a wonderful surprise, taking a pay cut.", | |
| "I just adore getting completely ignored.", | |
| "Best day ever, everything went wrong.", | |
| "I love getting stuck in the rain without an umbrella.", | |
| "I love it when people talk over me.", | |
| "It's just fantastic when my coffee spills all over my keyboard.", | |
| "Absolutely amazing how you managed to mess that up.", | |
| "Great job breaking the build.", | |
| "I'm so happy my alarm didn't go off today.", | |
| ] | |
| def prepare_irony_public(seed: int = 42) -> Optional[DatasetDict]: | |
| """Public irony data from `tweet_eval/irony`, used when SARC is absent. | |
| SARC requires a manual Kaggle download, so without it the sarcasm class has | |
| zero training support β and a class with no support cannot be calibrated, | |
| cannot be predicted, and silently poisons the prior-shift computation. | |
| tweet_eval/irony is a reasonable substitute and arguably a better fit: | |
| * It is balanced by construction (1,445 ironic / 1,417 literal), so the | |
| contrast design that SARC's comment pairs provide is preserved. | |
| * Twitter register is closer to the short free-text users actually type | |
| into the recommender than Reddit thread replies are. | |
| * It is public, so `--preset improved` runs end-to-end with no manual step. | |
| It is smaller than SARC (2.9k vs 25k), which lands sarcasm near a 4-5% | |
| training prior β close to the deployment estimate, so little prior correction | |
| is needed. When SARC *is* present it takes priority; this is the fallback. | |
| """ | |
| try: | |
| raw = load_dataset("tweet_eval", "irony") | |
| # The source column is a ClassLabel(num_classes=2); writing our 8-class | |
| # indices into it fails schema validation, so widen it to a plain int. | |
| from datasets import Value | |
| raw = raw.cast_column("label", Value("int64")) | |
| except Exception as exc: | |
| print(f" tweet_eval/irony unavailable ({type(exc).__name__}) β no sarcasm data") | |
| return None | |
| print(f" loading tweet_eval/irony (public SARC substitute)...") | |
| def convert(example): | |
| # label 1 = ironic -> sarcasm; label 0 = literal -> neutral. | |
| # The literal half is what teaches the boundary; dropping it is the | |
| # mistake the SARC pipeline made. | |
| target = LABEL2ID["sarcasm"] if example["label"] == 1 else LABEL2ID["neutral"] | |
| soft = np.zeros(NUM_LABELS, dtype=np.float32) | |
| soft[target] = 1.0 | |
| return { | |
| "text": example["text"], | |
| "label": target, | |
| "soft_label": soft.tolist(), | |
| "source": "tweet_eval_irony", | |
| } | |
| out = {} | |
| for split, key in (("train", "train"), ("validation", "validation"), ("test", "test")): | |
| ds = raw[key].map(convert, remove_columns=raw[key].column_names) | |
| out[split] = ds | |
| # Fold in the handwritten contrast examples on the train split only. | |
| extra_soft = np.zeros(NUM_LABELS, dtype=np.float32) | |
| extra_soft[LABEL2ID["sarcasm"]] = 1.0 | |
| extra = Dataset.from_dict( | |
| { | |
| "text": CONTRAST_AUGMENTATION, | |
| "label": [LABEL2ID["sarcasm"]] * len(CONTRAST_AUGMENTATION), | |
| "soft_label": [extra_soft.tolist()] * len(CONTRAST_AUGMENTATION), | |
| "source": ["contrast_aug"] * len(CONTRAST_AUGMENTATION), | |
| } | |
| ) | |
| from datasets import concatenate_datasets | |
| out["train"] = concatenate_datasets([out["train"], extra]).shuffle(seed=seed) | |
| n_sarc = sum(1 for l in out["train"]["label"] if l == LABEL2ID["sarcasm"]) | |
| print(f" {n_sarc:,} ironic + {len(out['train']) - n_sarc:,} literal (train)") | |
| return DatasetDict(out) | |
| def prepare_news_sarcasm(limit: int = 12000, seed: int = 42) -> Optional[DatasetDict]: | |
| """Satirical headlines from `raquiba/Sarcasm_News_Headline` (Misra & Arora). | |
| 28,619 balanced headlines β The Onion (satirical) against HuffPost (literal). | |
| Public, no Kaggle account, and a genuinely different register from | |
| tweet_eval's conversational irony, which is the point: with only tweet_eval, | |
| sarcasm trains at 2.35% of the data and scores **0.000 F1** β the class is | |
| effectively absent from the model. | |
| Caveat worth stating in any write-up: headline satire is not conversational | |
| sarcasm. An Onion headline is ironic at the level of the proposition, while | |
| "oh great, another Monday" is ironic in tone. Mixing them broadens coverage | |
| but the two are not the same phenomenon, so this is opt-in rather than | |
| default. | |
| """ | |
| try: | |
| raw = load_dataset("raquiba/Sarcasm_News_Headline", split="train") | |
| except Exception as exc: | |
| print(f" news-sarcasm unavailable ({type(exc).__name__}) β skipping") | |
| return None | |
| raw = raw.shuffle(seed=seed).select(range(min(limit, len(raw)))) | |
| def convert(example): | |
| # 1 = satirical -> sarcasm; 0 = a real headline -> neutral. Keeping the | |
| # negative half is what teaches the boundary rather than the register. | |
| target = LABEL2ID["sarcasm"] if int(example["is_sarcastic"]) == 1 else LABEL2ID["neutral"] | |
| soft = np.zeros(NUM_LABELS, dtype=np.float32) | |
| soft[target] = 1.0 | |
| return { | |
| "text": example["headline"], | |
| "label": target, | |
| "soft_label": soft.tolist(), | |
| "source": "news_sarcasm", | |
| } | |
| ds = raw.map(convert, remove_columns=raw.column_names) | |
| n = len(ds) | |
| n_train, n_val = int(0.8 * n), int(0.1 * n) | |
| n_sarc = sum(1 for l in ds["label"][:n_train] if l == LABEL2ID["sarcasm"]) | |
| print(f" loading news-headline sarcasm: {n_train:,} train " | |
| f"({n_sarc:,} satirical + {n_train - n_sarc:,} literal)") | |
| return DatasetDict({ | |
| "train": ds.select(range(n_train)), | |
| "validation": ds.select(range(n_train, n_train + n_val)), | |
| "test": ds.select(range(n_train + n_val, n)), | |
| }) | |
| def prepare_sarcasm( | |
| target_ratio: float = 0.10, | |
| total_reference: int = 60000, | |
| keep_pair_negatives: bool = True, | |
| seed: int = 42, | |
| ) -> Optional[DatasetDict]: | |
| """SARC sarcasm data sampled to a realistic prior, with paired negatives. | |
| Args: | |
| target_ratio: Desired sarcasm share of the combined training set. | |
| Deployment text is roughly 5-10% sarcastic; the baseline's ~30% | |
| forced the inference-time threshold hacks. | |
| total_reference: Approximate size of the non-sarcasm training pool, used | |
| to convert `target_ratio` into a sample count. | |
| keep_pair_negatives: Include each sarcastic comment's non-sarcastic | |
| sibling (labeled neutral). This is what makes the corpus teach irony | |
| rather than Reddit register. | |
| """ | |
| import pandas as pd | |
| csv_path = config.data_dir / "train-balanced-sarcasm.csv" | |
| if not csv_path.exists(): | |
| print(f" SARC not found at {csv_path}") | |
| print(" (optional: https://www.kaggle.com/datasets/danofer/sarcasm)") | |
| return prepare_irony_public(seed=seed) | |
| print(f" loading SARC from {csv_path}...") | |
| df = pd.read_csv(csv_path).dropna(subset=["comment", "label"]) | |
| n_sarcastic = int(total_reference * target_ratio / (1 - target_ratio)) | |
| def build_text(frame): | |
| if "parent_comment" in frame.columns: | |
| return ( | |
| frame["parent_comment"].astype(str).str.slice(0, 300) | |
| + " [SEP] " | |
| + frame["comment"].astype(str) | |
| ) | |
| return frame["comment"].astype(str) | |
| pos = df[df["label"] == 1].sample( | |
| n=min(n_sarcastic, (df["label"] == 1).sum()), random_state=seed | |
| ).copy() | |
| pos["text"] = build_text(pos) | |
| pos["target"] = LABEL2ID["sarcasm"] | |
| frames = [pos[["text", "target"]]] | |
| if keep_pair_negatives: | |
| # Sibling non-sarcastic comments: same threads, same register, opposite | |
| # label. These are the hard negatives that force the model to attend to | |
| # meaning rather than surface style. | |
| neg = df[df["label"] == 0].sample( | |
| n=min(len(pos), (df["label"] == 0).sum()), random_state=seed | |
| ).copy() | |
| neg["text"] = build_text(neg) | |
| neg["target"] = LABEL2ID["neutral"] | |
| frames.append(neg[["text", "target"]]) | |
| print(f" {len(pos):,} sarcastic + {len(neg):,} paired negatives") | |
| else: | |
| print(f" {len(pos):,} sarcastic (no negatives β contrast disabled)") | |
| combined = pd.concat(frames, ignore_index=True).sample(frac=1.0, random_state=seed) | |
| n = len(combined) | |
| n_train, n_val = int(0.8 * n), int(0.1 * n) | |
| splits = { | |
| "train": combined.iloc[:n_train], | |
| "validation": combined.iloc[n_train : n_train + n_val], | |
| "test": combined.iloc[n_train + n_val :], | |
| } | |
| def to_ds(frame, extra_texts=None, extra_label=None): | |
| texts = frame["text"].tolist() | |
| targets = frame["target"].tolist() | |
| if extra_texts: | |
| texts += extra_texts | |
| targets += [extra_label] * len(extra_texts) | |
| softs = [] | |
| for t in targets: | |
| v = np.zeros(NUM_LABELS, dtype=np.float32) | |
| v[t] = 1.0 | |
| softs.append(v.tolist()) | |
| return Dataset.from_dict( | |
| { | |
| "text": texts, | |
| "label": targets, | |
| "soft_label": softs, | |
| "source": ["sarc"] * len(texts), | |
| } | |
| ) | |
| return DatasetDict( | |
| { | |
| "train": to_ds(splits["train"], CONTRAST_AUGMENTATION, LABEL2ID["sarcasm"]), | |
| "validation": to_ds(splits["validation"]), | |
| "test": to_ds(splits["test"]), | |
| } | |
| ) | |
| # ββ Cleaning & assembly βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| URL_RE = re.compile(r"https?://\S+|www\.\S+") | |
| USER_RE = re.compile(r"@[A-Za-z0-9_]{2,}") | |
| SUBREDDIT_RE = re.compile(r"/?r/[A-Za-z0-9_]+") | |
| HASHTAG_RE = re.compile(r"#(\w+)") | |
| def clean_text(text: str) -> str: | |
| """Normalize social-media artifacts that never appear in lyric input. | |
| URLs, @handles and /r/subreddit tokens are strong source cues. Left in, they | |
| let the model identify the *corpus* a sample came from, which inflates test | |
| accuracy while doing nothing for lyric input. Removing them narrows the | |
| train/deploy gap. | |
| Hashtags lose their `#` but keep the word. dair-ai ships with `#` already | |
| stripped, so in training it survived almost only in tweet_eval/irony, the | |
| sarcasm source: the full-data model learned "#" -> sarcasm and called 62% of | |
| SemEval emotion tweets sarcastic (results/user_text_full.json). | |
| """ | |
| text = URL_RE.sub(" ", text) | |
| text = USER_RE.sub(" ", text) | |
| text = SUBREDDIT_RE.sub(" ", text) | |
| text = HASHTAG_RE.sub(r"\1", text) | |
| text = text.replace("&", "&").replace(">", ">").replace("<", "<") | |
| return re.sub(r"\s+", " ", text).strip() | |
| def build_dataset( | |
| use_sarcasm: bool = True, | |
| sarcasm_ratio: float = 0.10, | |
| clean: bool = True, | |
| dedup: bool = True, | |
| seed: int = 42, | |
| use_news_sarcasm: bool = False, | |
| ) -> Tuple[DatasetDict, Dict]: | |
| """Assemble the full training corpus. | |
| Returns: | |
| (dataset, meta) where meta carries empirical class priors and per-source | |
| counts β both needed for class weights and for the calibration step. | |
| """ | |
| print("\nBuilding dataset") | |
| parts = [prepare_dair(), prepare_goemotions()] | |
| non_sarcasm_train = sum(len(p["train"]) for p in parts) | |
| if use_sarcasm: | |
| sarc = prepare_sarcasm( | |
| target_ratio=sarcasm_ratio, total_reference=non_sarcasm_train, seed=seed | |
| ) | |
| if sarc is not None: | |
| parts.append(sarc) | |
| # Opt-in: sarcasm reaches only 2.35% and 0.000 F1 on tweet_eval alone. | |
| # Default off so a change here cannot silently break comparability | |
| # between ablation arms trained at different times. | |
| if use_news_sarcasm: | |
| news = prepare_news_sarcasm(seed=seed) | |
| if news is not None: | |
| parts.append(news) | |
| combined = {} | |
| for split in ("train", "validation", "test"): | |
| texts, labels, softs, sources = [], [], [], [] | |
| for part in parts: | |
| texts.extend(part[split]["text"]) | |
| labels.extend(part[split]["label"]) | |
| softs.extend(part[split]["soft_label"]) | |
| sources.extend(part[split]["source"]) | |
| if clean: | |
| texts = [clean_text(t) for t in texts] | |
| if dedup: | |
| seen, keep = set(), [] | |
| for i, t in enumerate(texts): | |
| key = t.lower() | |
| if key and key not in seen: | |
| seen.add(key) | |
| keep.append(i) | |
| texts = [texts[i] for i in keep] | |
| labels = [labels[i] for i in keep] | |
| softs = [softs[i] for i in keep] | |
| sources = [sources[i] for i in keep] | |
| combined[split] = Dataset.from_dict( | |
| {"text": texts, "label": labels, "soft_label": softs, "source": sources} | |
| ).shuffle(seed=seed) | |
| dataset = DatasetDict(combined) | |
| train_labels = dataset["train"]["label"] | |
| counts = Counter(train_labels) | |
| total = len(train_labels) | |
| priors = [counts.get(i, 0) / total for i in range(NUM_LABELS)] | |
| meta = { | |
| "label_names": TARGET_LABELS, | |
| "num_labels": NUM_LABELS, | |
| "train_size": total, | |
| "val_size": len(dataset["validation"]), | |
| "test_size": len(dataset["test"]), | |
| "class_counts": {TARGET_LABELS[i]: counts.get(i, 0) for i in range(NUM_LABELS)}, | |
| "class_priors": {TARGET_LABELS[i]: round(priors[i], 5) for i in range(NUM_LABELS)}, | |
| "priors": priors, | |
| "source_counts": dict(Counter(dataset["train"]["source"])), | |
| } | |
| print(f"\n train {total:,} | val {meta['val_size']:,} | test {meta['test_size']:,}") | |
| print(" class distribution (train):") | |
| for i, name in enumerate(TARGET_LABELS): | |
| c = counts.get(i, 0) | |
| print(f" {name:>9}: {c:>7,} ({priors[i]:6.2%})") | |
| return dataset, meta | |
| def deployment_priors() -> List[float]: | |
| """Expected class frequencies for lyric/journal input at deployment. | |
| Estimated from the recommender's own traffic: users describe negative and | |
| reflective states far more often than they report surprise, and explicit | |
| sarcasm is uncommon in the free-text box. Used by calibration.fit_prior_shift | |
| to correct the training mix, replacing the hardcoded thresholds. | |
| Revisit these against real request logs before the final paper run. | |
| """ | |
| priors = { | |
| "sadness": 0.30, "joy": 0.18, "love": 0.12, "anger": 0.10, | |
| "fear": 0.12, "surprise": 0.05, "neutral": 0.08, "sarcasm": 0.05, | |
| } | |
| return [priors[name] for name in TARGET_LABELS] | |