File size: 7,914 Bytes
e6aaa60
f1ec9bb
2596f8c
f1ec9bb
 
 
 
e6aaa60
f1ec9bb
2596f8c
f1ec9bb
 
 
2596f8c
 
 
 
 
 
 
 
 
f1ec9bb
 
 
 
 
 
 
 
 
2596f8c
e6aaa60
 
 
2596f8c
e6aaa60
 
 
 
 
 
 
f1ec9bb
e6aaa60
 
 
f1ec9bb
 
e6aaa60
 
 
 
 
 
f1ec9bb
 
 
 
 
 
 
 
 
 
 
e6aaa60
 
 
f1ec9bb
 
e6aaa60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2596f8c
 
e6aaa60
 
2596f8c
f1ec9bb
 
 
 
 
 
 
 
 
 
 
 
2596f8c
e6aaa60
 
 
 
 
 
 
 
 
 
2596f8c
e6aaa60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1ec9bb
e6aaa60
 
2596f8c
e6aaa60
 
 
f1ec9bb
 
 
e6aaa60
f1ec9bb
e6aaa60
 
 
 
f1ec9bb
e6aaa60
 
2596f8c
e6aaa60
 
 
f1ec9bb
 
e6aaa60
 
 
 
 
 
 
f1ec9bb
 
 
e6aaa60
f1ec9bb
e6aaa60
 
 
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
"""
HuggingFace Inference Endpoints handler for the V5 Hierarchical V3-aug cascade.

Loads all 5 stages plus Platt calibrators (Stage 1A, Stage 3) and the 3-seed
Stage 2 ensemble. Routes a single string through the cascade.

Supports two operating points selected via `data["mode"]`:
  - "balanced" (default): F1-optimal balance with Sui-miss / Sui-FP penalty.
  - "safety": stricter Suicidal recall (val Sui->Dep <= 70, calibrated for drift).
"""
import os, json, glob, joblib
import numpy as np
import torch
import torch.nn.functional as F
from transformers import (
    AutoTokenizer,
    RobertaForSequenceClassification,
    BertTokenizerFast, BertForSequenceClassification,
    LongformerTokenizerFast, LongformerForSequenceClassification,
)


def _apply_platt(calibrator, p0, p1, eps=1e-7):
    """Apply Platt scaling to a single binary (p0, p1) probability."""
    p0 = float(np.clip(p0, eps, 1 - eps))
    p1 = float(np.clip(p1, eps, 1 - eps))
    logit = np.log(p1) - np.log(p0)
    cal_p1 = float(calibrator.predict_proba(np.array([[logit]]))[:, 1][0])
    return [1.0 - cal_p1, cal_p1]


class EndpointHandler:
    def __init__(self, path=""):
        with open(os.path.join(path, "config.json"), "r") as f:
            self.cfg = json.load(f)
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.s2_classes = self.cfg["stage2_class_order"]

        # ── Stage 0: Cardiff RoBERTa ──
        s0_path = os.path.join(path, "stage0")
        self.tok0 = AutoTokenizer.from_pretrained(s0_path)
        self.m0   = RobertaForSequenceClassification.from_pretrained(s0_path).to(self.device).eval()

        # ── Stage 1A: MentalBERT + Platt calibrator ──
        s1a_path = os.path.join(path, "stage1a")
        self.tok1a = BertTokenizerFast.from_pretrained(s1a_path)
        self.m1a   = BertForSequenceClassification.from_pretrained(s1a_path).to(self.device).eval()
        cal_s1a = os.path.join(s1a_path, "platt_calibrator.joblib")
        self.platt_s1a = joblib.load(cal_s1a) if os.path.exists(cal_s1a) else None

        # ── Stage 1B: MentalBERT ──
        s1b_path = os.path.join(path, "stage1b")
        self.tok1b = BertTokenizerFast.from_pretrained(s1b_path)
        self.m1b   = BertForSequenceClassification.from_pretrained(s1b_path).to(self.device).eval()

        # ── Stage 2: 3-seed MentalBERT ensemble ──
        seed_dirs = sorted(glob.glob(os.path.join(path, "stage2", "seed_*")))
        if not seed_dirs:
            # Backwards compat: single stage2 folder
            seed_dirs = [os.path.join(path, "stage2")]
        self.tok2_list = [BertTokenizerFast.from_pretrained(d) for d in seed_dirs]
        self.m2_list   = [BertForSequenceClassification.from_pretrained(d).to(self.device).eval()
                          for d in seed_dirs]
        self.n_s2_models = len(self.m2_list)

        # ── Stage 3: Longformer + Platt calibrator ──
        s3_path = os.path.join(path, "stage3")
        self.tok3 = LongformerTokenizerFast.from_pretrained(s3_path)
        self.m3   = LongformerForSequenceClassification.from_pretrained(s3_path).to(self.device).eval()
        cal_s3 = os.path.join(s3_path, "platt_calibrator.joblib")
        self.platt_s3 = joblib.load(cal_s3) if os.path.exists(cal_s3) else None

        st = self.cfg["stages"]
        self.ml0  = st["stage0"]["max_len"]
        self.ml1a = st["stage1a"]["max_len"]
        self.ml1b = st["stage1b"]["max_len"]
        self.ml2  = st["stage2"]["max_len"]
        self.ml3  = st["stage3"]["max_len"]

        thr = self.cfg["thresholds"]
        self.t0        = float(thr["stage0"])
        self.balanced  = {"stage1a": float(thr["balanced"]["stage1a"]),
                          "stage3":  float(thr["balanced"]["stage3"])}
        self.safety    = {"stage1a": float(thr["safety"]["stage1a"]),
                          "stage3":  float(thr["safety"]["stage3"])}
        self.default_mode = thr.get("default_mode", "balanced")

    @torch.no_grad()
    def _probs_bert(self, m, tok, text, max_len):
        enc = tok(text, max_length=max_len, padding="max_length",
                  truncation=True, return_tensors="pt").to(self.device)
        out = m(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"])
        return F.softmax(out.logits, dim=-1)[0].cpu().tolist()

    @torch.no_grad()
    def _probs_s2_ensemble(self, text):
        """Average softmax probs across all stage-2 seed models."""
        acc = None
        for m, tok in zip(self.m2_list, self.tok2_list):
            enc = tok(text, max_length=self.ml2, padding="max_length",
                      truncation=True, return_tensors="pt").to(self.device)
            out = m(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"])
            p   = F.softmax(out.logits, dim=-1)[0].cpu().numpy()
            acc = p if acc is None else acc + p
        return (acc / self.n_s2_models).tolist()

    @torch.no_grad()
    def _probs_lf(self, m, tok, text, max_len):
        enc = tok(text, max_length=max_len, padding="max_length",
                  truncation=True, return_tensors="pt").to(self.device)
        gmask = torch.zeros_like(enc["attention_mask"])
        gmask[0, 0] = 1
        out = m(input_ids=enc["input_ids"],
                attention_mask=enc["attention_mask"],
                global_attention_mask=gmask)
        return F.softmax(out.logits, dim=-1)[0].cpu().tolist()

    def __call__(self, data):
        if isinstance(data, str):
            text = data; mode = self.default_mode
        else:
            text = data.get("inputs", "")
            if isinstance(text, list):
                text = text[0] if len(text) > 0 else ""
            mode = data.get("mode", self.default_mode)

        if mode not in ("balanced", "safety"):
            mode = self.default_mode
        thr = self.safety if mode == "safety" else self.balanced
        t1a = thr["stage1a"]; t3 = thr["stage3"]

        stage_probs = {}

        # Stage 0: DA gate
        p0 = self._probs_bert(self.m0, self.tok0, text, self.ml0)
        stage_probs["stage0"] = p0
        if p0[1] >= self.t0:
            return {"label": "Directed Aggression", "exit_stage": "stage0",
                    "mode": mode, "stage_probs": stage_probs}

        # Stage 1A: Sui gate (Platt-calibrated)
        p1a_raw = self._probs_bert(self.m1a, self.tok1a, text, self.ml1a)
        p1a = _apply_platt(self.platt_s1a, p1a_raw[0], p1a_raw[1]) if self.platt_s1a else p1a_raw
        stage_probs["stage1a"] = p1a
        stage_probs["stage1a_raw"] = p1a_raw
        if p1a[1] >= t1a:
            return {"label": "Suicidal", "exit_stage": "stage1a",
                    "mode": mode, "stage_probs": stage_probs}

        # Stage 1B: Normal vs Distress (argmax)
        p1b = self._probs_bert(self.m1b, self.tok1b, text, self.ml1b)
        stage_probs["stage1b"] = p1b
        if p1b[0] > p1b[1]:
            return {"label": "Normal", "exit_stage": "stage1b",
                    "mode": mode, "stage_probs": stage_probs}

        # Stage 2: 5-class ensemble argmax
        p2 = self._probs_s2_ensemble(text)
        stage_probs["stage2"] = p2
        s2_idx = int(max(range(len(p2)), key=lambda i: p2[i]))
        s2_label = self.s2_classes[s2_idx]
        if s2_label != "Depression":
            return {"label": s2_label, "exit_stage": "stage2",
                    "mode": mode, "stage_probs": stage_probs}

        # Stage 3: Dep vs Sui (Platt-calibrated)
        p3_raw = self._probs_lf(self.m3, self.tok3, text, self.ml3)
        p3 = _apply_platt(self.platt_s3, p3_raw[0], p3_raw[1]) if self.platt_s3 else p3_raw
        stage_probs["stage3"] = p3
        stage_probs["stage3_raw"] = p3_raw
        final = "Suicidal" if p3[1] >= t3 else "Depression"
        return {"label": final, "exit_stage": "stage3",
                "mode": mode, "stage_probs": stage_probs}