File size: 6,261 Bytes
22eecb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Which DeBERTa reads the listener's own text better? (HANDOFF §4 item 4)

The same weights read the user's text (Vercel `/api/emotion`) and the songs'
lyrics. A candidate text model has to win on lyrics through the whole system
(`train_fusion compare`) *and* not lose on user text. This measures the second
half, on text neither model trained on:

  tweet_eval   SemEval-2018 emotion tweets, test split (1,421). anger, joy,
               optimism and sadness; optimism counts as joy. Not part of
               dair-ai/emotion, GoEmotions or tweet_eval/irony.
  literal      the register probe's 55 literal statements, the closest thing
               to what people type into the box ("I feel so sad and empty...")

Both models are read the way the route reads them: plain softmax, argmax over
all 8 classes, so a prediction outside the gold set (e.g. `neutral`) is wrong.

    python -m backend.src.user_text_eval --live models/<live>/final --candidate models/<cand>/final --tag full

Ship rule, fixed before the first run: the candidate passes the user-text side
if, on *both* sets, the paired 95% bootstrap interval of its accuracy minus the
live model's has an upper bound >= 0 (not significantly worse). Decided on
unrounded values. Macro-F1 and VAD error are reported, not ruled on.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Dict, List

import numpy as np
import torch
from sklearn.metrics import f1_score
from transformers import AutoModelForSequenceClassification, AutoTokenizer

from .acoustic import EMOTION_VAD
from .fusion import user_vad
from .lyrics_probe import load_probe

ROOT = Path(__file__).resolve().parents[2]
RESULTS = ROOT / "results"
TWEET_EVAL_MAP = {0: "anger", 1: "joy", 2: "joy", 3: "sadness"}  # optimism -> joy
N_BOOT = 10000
SEED = 0


def user_sets() -> Dict[str, List[Dict]]:
    from datasets import load_dataset
    te = load_dataset("tweet_eval", "emotion", split="test")
    return {
        "tweet_eval": [{"text": t, "gold": TWEET_EVAL_MAP[int(l)]} for t, l in zip(te["text"], te["label"])],
        "literal": [{"text": r["text"], "gold": r["gold"]} for r in load_probe() if r["register"] == "literal"],
    }


@torch.no_grad()
def softmax_probs(path: str, texts: List[str], device: torch.device, batch: int = 32) -> List[Dict[str, float]]:
    tok = AutoTokenizer.from_pretrained(path)
    model = AutoModelForSequenceClassification.from_pretrained(path).to(device).eval()
    labels = [model.config.id2label[i] for i in range(model.config.num_labels)]
    out = []
    for i in range(0, len(texts), batch):
        enc = tok(texts[i:i + batch], return_tensors="pt", truncation=True, max_length=256, padding=True).to(device)
        p = torch.softmax(model(**enc).logits.float(), dim=-1).cpu().numpy()
        out.extend({l: float(v) for l, v in zip(labels, row)} for row in p)
    return out


def vad_error(probs: Dict[str, float], gold: str) -> float:
    """Distance from the user's VAD point to the gold emotion's; an all-neutral
    reading sits at neutral, as the app has nothing else to place it by."""
    v = user_vad(probs)
    v = np.asarray(EMOTION_VAD["neutral"]) if v is None else v
    return float(np.linalg.norm(v - np.asarray(EMOTION_VAD[gold])))


def score(probs: List[Dict[str, float]], items: List[Dict]) -> Dict:
    pred = [max(p, key=p.get) for p in probs]
    gold = [it["gold"] for it in items]
    present = sorted(set(gold))
    return {
        "correct": np.array([int(a == b) for a, b in zip(pred, gold)]),
        "accuracy": float(np.mean([a == b for a, b in zip(pred, gold)])),
        "macro_f1": float(f1_score(gold, pred, labels=present, average="macro", zero_division=0)),
        "vad_error": float(np.mean([vad_error(p, g) for p, g in zip(probs, gold)])),
        "neutral_rate": float(np.mean([x == "neutral" for x in pred])),
    }


def paired_ci(a: np.ndarray, b: np.ndarray) -> tuple:
    """95% percentile interval of mean(b) - mean(a), resampling items."""
    rng = np.random.default_rng(SEED)
    idx = rng.integers(0, len(a), size=(N_BOOT, len(a)))
    d = b[idx].mean(axis=1) - a[idx].mean(axis=1)
    return float(np.percentile(d, 2.5)), float(np.percentile(d, 97.5))


def compare(live: str, candidate: str, tag: str) -> Dict:
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    report: Dict = {"live": live, "candidate": candidate, "sets": {}}
    for name, items in user_sets().items():
        texts = [it["text"] for it in items]
        a, b = score(softmax_probs(live, texts, device), items), score(softmax_probs(candidate, texts, device), items)
        lo, hi = paired_ci(a["correct"], b["correct"])
        report["sets"][name] = {
            "n": len(items),
            "live": {k: round(v, 4) for k, v in a.items() if k != "correct"},
            tag: {k: round(v, 4) for k, v in b.items() if k != "correct"},
            "accuracy_diff": round(b["accuracy"] - a["accuracy"], 4),
            "ci": [round(lo, 4), round(hi, 4)],
            "not_worse": bool(hi >= 0),
        }
    report["ship_user_text"] = all(s["not_worse"] for s in report["sets"].values())
    for name, s in report["sets"].items():
        print(f"  {name:10s} n={s['n']:5d}  live {s['live']['accuracy']:.3f}  {tag} {s[tag]['accuracy']:.3f}  "
              f"diff {s['accuracy_diff']:+.3f} [{s['ci'][0]:+.3f}, {s['ci'][1]:+.3f}]  "
              f"macro-F1 {s['live']['macro_f1']:.3f} -> {s[tag]['macro_f1']:.3f}  "
              f"VAD err {s['live']['vad_error']:.2f} -> {s[tag]['vad_error']:.2f}")
    print(f"  user-text side passes: {report['ship_user_text']}")
    out = RESULTS / f"user_text_{tag}.json"
    out.write_text(json.dumps(report, indent=2))
    print(f"wrote {out}")
    return report


def main() -> int:
    parser = argparse.ArgumentParser(description="Live vs candidate DeBERTa on the listener's own text")
    parser.add_argument("--live", required=True, help="local copy of the deployed weights")
    parser.add_argument("--candidate", required=True)
    parser.add_argument("--tag", required=True)
    args = parser.parse_args()
    compare(args.live, args.candidate, args.tag)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())