eumora-api / backend /src /data_prep.py
GitHub Action
deploy 3d50547
037f340
Raw History Blame Contribute Delete
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("&amp;", "&").replace("&gt;", ">").replace("&lt;", "<")
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]