File size: 20,848 Bytes
037f340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
"""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]