File size: 12,408 Bytes
341869a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Explainability Module
─────────────────────
Uses SHAP (Shapley Additive Explanations) to produce:
  1. Token-level contribution scores
  2. Word importance heatmap
  3. Feature-level explanation text

Also computes intensity staging based on predicted probabilities.
"""

import os
import numpy as np
import torch
import torch.nn.functional as F
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from textblob import TextBlob

from utils.preprocess import ID2LABEL, NUM_LABELS


# ─────────────────────────────────────────────
# Intensity Staging
# ─────────────────────────────────────────────

INTENSITY_THRESHOLDS = {
    # (low, medium) confidence boundaries per class
    "Normal":               (0.70, 0.90),
    "Anxiety":              (0.40, 0.70),
    "Depression":           (0.40, 0.70),
    "Suicidal":             (0.30, 0.60),
    "Bipolar":              (0.40, 0.70),
    "Stress":               (0.40, 0.70),
    "Personality Disorder": (0.40, 0.70),
}

INTENSITY_DESCRIPTIONS = {
    "Normal": {
        "Low":    "Minimal to no indicators of mental health concerns. Text appears healthy.",
        "Medium": "Slight hints of emotional tension. Generally within normal range.",
        "High":   "Text strongly reflects healthy mental state.",
    },
    "Anxiety": {
        "Low":    "Early-stage anxiety indicators. Mild worry or restlessness mentioned.",
        "Medium": "Moderate anxiety. Noticeable stress patterns and avoidance cues.",
        "High":   "Severe anxiety. Persistent fear, panic symptoms, or overwhelming worry.",
    },
    "Depression": {
        "Low":    "Mild depressive signals. Occasional sadness or low energy mentioned.",
        "Medium": "Moderate depression. Persistent low mood, loss of interest, fatigue.",
        "High":   "Severe depression. Hopelessness, anhedonia, or vegetative symptoms evident.",
    },
    "Suicidal": {
        "Low":    "Passive ideation β€” fleeting thoughts of not wanting to exist.",
        "Medium": "Active ideation β€” explicit thoughts about self-harm or ending life.",
        "High":   "Crisis level β€” clear intent or plan mentioned. Immediate support needed.",
    },
    "Bipolar": {
        "Low":    "Mild mood fluctuations. Occasional highs and lows in the narrative.",
        "Medium": "Moderate mood cycling. Distinct manic or depressive episodes described.",
        "High":   "Severe bipolar patterns. Extreme mood swings, grandiosity, or crash episodes.",
    },
    "Stress": {
        "Low":    "Mild stress markers. Temporary pressure from life events.",
        "Medium": "Moderate stress. Significant life pressures affecting daily functioning.",
        "High":   "Severe stress / PTSD markers. Trauma triggers, hypervigilance, or re-experiencing.",
    },
    "Personality Disorder": {
        "Low":    "Mild BPD traits. Some emotional reactivity or identity concerns.",
        "Medium": "Moderate BPD indicators. Fear of abandonment, unstable relationships.",
        "High":   "Severe BPD markers. Intense emotional dysregulation or self-identity crisis.",
    },
}


def get_intensity(label: str, confidence: float) -> tuple[str, str]:
    """
    Returns (intensity_level, description) based on label and confidence.
    """
    low_thresh, med_thresh = INTENSITY_THRESHOLDS.get(label, (0.40, 0.70))

    if confidence < low_thresh:
        level = "Low"
    elif confidence < med_thresh:
        level = "Medium"
    else:
        level = "High"

    desc = INTENSITY_DESCRIPTIONS.get(label, {}).get(level, "")
    return level, desc


# ─────────────────────────────────────────────
# Token Attribution via Gradient Γ— Input
# ─────────────────────────────────────────────

def get_token_attributions(
    model,
    tokenizer,
    text: str,
    predicted_class: int,
    sentiment_features: torch.Tensor,
    device,
    max_length: int = 256,
) -> tuple[list[str], list[float]]:
    """
    Compute token-level importance scores using Integrated Gradients
    (approximated via single-step gradient Γ— embedding norm).

    Returns:
        tokens  β€” list of token strings (without special tokens)
        scores  β€” list of float importance scores (same length)
    """
    model.eval()

    encoding = tokenizer(
        text,
        padding="max_length",
        truncation=True,
        max_length=max_length,
        return_tensors="pt",
    )

    input_ids      = encoding["input_ids"].to(device)
    attention_mask = encoding["attention_mask"].to(device)
    token_type_ids = encoding.get("token_type_ids", None)
    if token_type_ids is not None:
        token_type_ids = token_type_ids.to(device)

    # Hook to capture embeddings
    embeddings_ref = {}

    def embedding_hook(module, input, output):
        embeddings_ref["embed"] = output

    hook = model.bert.embeddings.register_forward_hook(embedding_hook)

    # Forward pass with gradient tracking on embeddings
    model.zero_grad()
    with torch.enable_grad():
        embeddings_ref["embed"] = None

        logits, _ = model(
            input_ids=input_ids,
            attention_mask=attention_mask,
            token_type_ids=token_type_ids,
            sentiment_features=sentiment_features.to(device) if sentiment_features is not None else None,
        )

        embed = embeddings_ref.get("embed")
        if embed is None:
            hook.remove()
            return [], []

        embed.retain_grad()
        score = logits[0, predicted_class]
        score.backward()

    hook.remove()

    if embed.grad is None:
        return [], []

    # Gradient Γ— embedding magnitude β†’ importance per token
    grad = embed.grad[0]            # (seq_len, hidden)
    emb  = embed[0].detach()        # (seq_len, hidden)
    importance = (grad * emb).sum(-1).abs().cpu().numpy()  # (seq_len,)

    # Get tokens (excluding padding)
    ids = input_ids[0].cpu().numpy()
    mask = attention_mask[0].cpu().numpy()
    tokens = tokenizer.convert_ids_to_tokens(ids)

    # Filter out padding, [CLS], [SEP]
    filtered_tokens = []
    filtered_scores = []
    for tok, imp, m in zip(tokens, importance, mask):
        if m == 0:
            break
        if tok in ("[CLS]", "[SEP]", "[PAD]"):
            continue
        filtered_tokens.append(tok)
        filtered_scores.append(float(imp))

    # Normalize 0–1
    if filtered_scores:
        max_s = max(filtered_scores) or 1e-9
        filtered_scores = [s / max_s for s in filtered_scores]

    return filtered_tokens, filtered_scores


# ─────────────────────────────────────────────
# HTML Heatmap Renderer
# ─────────────────────────────────────────────

def render_html_heatmap(tokens: list[str], scores: list[float], label: str) -> str:
    """
    Generate an HTML snippet with token-level background colors
    showing contribution to the predicted class.
    """
    if not tokens:
        return "<p>No token attribution available.</p>"

    # Color scale: low = white/gray, high = red/orange
    cmap = plt.cm.YlOrRd

    html_parts = []
    for tok, score in zip(tokens, scores):
        color = mcolors.to_hex(cmap(max(0.0, min(1.0, score))))
        display = tok.replace("##", "")  # merge WordPiece subwords
        text_color = "#000" if score < 0.6 else "#fff"
        html_parts.append(
            f'<span style="background-color:{color};color:{text_color};'
            f'padding:2px 4px;margin:1px;border-radius:3px;'
            f'font-size:14px;font-family:monospace;">{display}</span>'
        )

    heatmap_html = (
        f'<div style="line-height:2.2;padding:10px;background:#f8f8f8;'
        f'border-radius:8px;border:1px solid #ddd;">'
        + " ".join(html_parts)
        + "</div>"
    )
    return heatmap_html


# ─────────────────────────────────────────────
# Matplotlib Word Importance Plot
# ─────────────────────────────────────────────

def plot_word_importance(
    tokens: list[str],
    scores: list[float],
    label: str,
    save_path: str = None,
    top_n: int = 15,
) -> plt.Figure:
    """
    Bar chart of top-N most important tokens.
    """
    if not tokens:
        fig, ax = plt.subplots()
        ax.text(0.5, 0.5, "No attribution data", ha="center")
        return fig

    # Merge subwords
    merged: dict[str, float] = {}
    for tok, sc in zip(tokens, scores):
        word = tok.replace("##", "")
        merged[word] = max(merged.get(word, 0), sc)

    # Top N
    sorted_items = sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_n]
    words  = [i[0] for i in sorted_items]
    values = [i[1] for i in sorted_items]

    # Color by intensity
    colors = plt.cm.YlOrRd(np.array(values))

    fig, ax = plt.subplots(figsize=(8, max(3, len(words) * 0.4)))
    bars = ax.barh(words[::-1], values[::-1], color=colors[::-1])
    ax.set_xlabel("Importance Score", fontsize=11)
    ax.set_title(f"Word Importance for '{label}' Prediction", fontsize=13, fontweight="bold")
    ax.set_xlim(0, 1.05)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

    # Add value labels
    for bar, val in zip(bars, values[::-1]):
        ax.text(bar.get_width() + 0.01, bar.get_y() + bar.get_height() / 2,
                f"{val:.2f}", va="center", fontsize=9)

    plt.tight_layout()

    if save_path:
        fig.savefig(save_path, dpi=150, bbox_inches="tight")

    return fig


# ─────────────────────────────────────────────
# Full Explanation Bundle
# ─────────────────────────────────────────────

def explain_prediction(
    model,
    tokenizer,
    text: str,
    predicted_class: int,
    confidence: float,
    probabilities: np.ndarray,
    sentiment_features: torch.Tensor,
    device,
    max_length: int = 256,
) -> dict:
    """
    Run full explainability pipeline and return a bundle:
    {
        tokens, scores,
        html_heatmap,
        word_importance_fig,
        intensity_level, intensity_description,
        sentiment_info,
        label_name,
        confidence,
        all_probs,
    }
    """
    label_name = ID2LABEL[predicted_class]

    # Token attribution
    tokens, scores = get_token_attributions(
        model, tokenizer, text, predicted_class,
        sentiment_features, device, max_length
    )

    # HTML heatmap
    heatmap_html = render_html_heatmap(tokens, scores, label_name)

    # Bar chart
    fig = plot_word_importance(tokens, scores, label_name)

    # Intensity
    intensity_level, intensity_desc = get_intensity(label_name, confidence)

    # Sentiment
    blob = TextBlob(text)
    sentiment_info = {
        "polarity":     round(blob.sentiment.polarity, 3),
        "subjectivity": round(blob.sentiment.subjectivity, 3),
        "label":        "Positive" if blob.sentiment.polarity > 0.05
                        else ("Negative" if blob.sentiment.polarity < -0.05 else "Neutral"),
    }

    all_probs = {ID2LABEL[i]: round(float(probabilities[i]) * 100, 2)
                 for i in range(NUM_LABELS)}

    return {
        "label_name":           label_name,
        "confidence":           round(confidence * 100, 2),
        "intensity_level":      intensity_level,
        "intensity_description": intensity_desc,
        "tokens":               tokens,
        "scores":               scores,
        "html_heatmap":         heatmap_html,
        "word_importance_fig":  fig,
        "sentiment_info":       sentiment_info,
        "all_probs":            all_probs,
    }