File size: 13,261 Bytes
e382dce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Explainability module for misinformation detection models.
Provides LIME explanations and attention-based saliency mapping.
"""

from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from lime.lime_text import LimeTextExplainer

from src.config import LABEL_NAMES, RESULTS_DIR


class ModelExplainer:
    """Explainability tools for misinformation detection models."""

    def __init__(self, class_names: List[str] = None):
        self.class_names = class_names or list(LABEL_NAMES.values())
        self.lime_explainer = LimeTextExplainer(
            class_names=self.class_names, split_expression=r"\W+", bow=True
        )

    def explain_with_lime(
        self, text: str, predict_fn, num_features: int = 10, num_samples: int = 500
    ) -> Dict[str, Any]:
        """
        Generate LIME explanation for a prediction.

        Args:
            text: Input text to explain
            predict_fn: Prediction function that returns probabilities
            num_features: Number of top features to include
            num_samples: Number of perturbed samples for LIME

        Returns:
            Dictionary with explanation details
        """
        explanation = self.lime_explainer.explain_instance(
            text, predict_fn, num_features=num_features, num_samples=num_samples
        )

        # Get prediction
        probs = predict_fn([text])[0]
        predicted_class = np.argmax(probs)

        # Extract feature weights
        feature_weights = explanation.as_list(label=predicted_class)

        return {
            "text": text,
            "predicted_class": self.class_names[predicted_class],
            "confidence": float(probs[predicted_class]),
            "probabilities": {self.class_names[i]: float(p) for i, p in enumerate(probs)},
            "feature_weights": feature_weights,
            "explanation": explanation,
        }

    def visualize_lime_explanation(
        self, explanation_result: Dict[str, Any], save_path: Optional[Path] = None
    ) -> plt.Figure:
        """
        Visualize LIME explanation as a bar chart.

        Args:
            explanation_result: Result from explain_with_lime
            save_path: Path to save the figure

        Returns:
            Matplotlib figure
        """
        feature_weights = explanation_result["feature_weights"]

        # Separate positive and negative weights
        words = [fw[0] for fw in feature_weights]
        weights = [fw[1] for fw in feature_weights]

        colors = ["green" if w > 0 else "red" for w in weights]

        fig, ax = plt.subplots(figsize=(10, 6))

        y_pos = np.arange(len(words))
        ax.barh(y_pos, weights, color=colors)
        ax.set_yticks(y_pos)
        ax.set_yticklabels(words)
        ax.invert_yaxis()
        ax.set_xlabel("Feature Importance")
        ax.set_title(
            f"LIME Explanation\nPredicted: {explanation_result['predicted_class']} "
            f"(Confidence: {explanation_result['confidence']:.2%})"
        )

        # Add legend
        ax.axvline(x=0, color="black", linestyle="-", linewidth=0.5)

        plt.tight_layout()

        if save_path:
            fig.savefig(save_path, dpi=150, bbox_inches="tight")
            print(f"Saved LIME visualization to {save_path}")

        return fig

    def highlight_text_lime(self, explanation_result: Dict[str, Any]) -> str:
        """
        Create HTML highlighted text based on LIME explanation.

        Args:
            explanation_result: Result from explain_with_lime

        Returns:
            HTML string with highlighted text
        """
        text = explanation_result["text"]
        feature_weights = dict(explanation_result["feature_weights"])

        words = text.split()
        highlighted_words = []

        for word in words:
            clean_word = "".join(c for c in word.lower() if c.isalnum())

            if clean_word in feature_weights:
                weight = feature_weights[clean_word]
                if weight > 0:
                    color = f"rgba(255, 0, 0, {min(abs(weight) * 2, 0.8)})"
                else:
                    color = f"rgba(0, 128, 0, {min(abs(weight) * 2, 0.8)})"
                highlighted_words.append(f'<span style="background-color: {color}">{word}</span>')
            else:
                highlighted_words.append(word)

        return " ".join(highlighted_words)

    def explain_attention(self, bert_model, text: str, top_k: int = 20) -> Dict[str, Any]:
        """
        Get attention-based explanation from BERT model.

        Args:
            bert_model: Trained BertClassifier instance
            text: Input text to explain
            top_k: Number of top attended tokens to highlight

        Returns:
            Dictionary with attention explanation
        """
        attention_info = bert_model.get_attention_weights(text)

        tokens = attention_info["tokens"]
        cls_attention = attention_info["cls_attention"]

        # Filter out special tokens and padding
        valid_indices = []
        valid_tokens = []
        valid_attention = []

        for i, (token, attn) in enumerate(zip(tokens, cls_attention)):
            if token not in ["[CLS]", "[SEP]", "[PAD]"] and not token.startswith("##"):
                valid_indices.append(i)
                valid_tokens.append(token)
                valid_attention.append(attn)

        # Normalize attention
        valid_attention = np.array(valid_attention)
        if valid_attention.sum() > 0:
            valid_attention = valid_attention / valid_attention.sum()

        # Get top-k attended tokens
        top_indices = np.argsort(valid_attention)[-top_k:][::-1]
        top_tokens = [(valid_tokens[i], float(valid_attention[i])) for i in top_indices]

        return {
            "text": text,
            "tokens": valid_tokens,
            "attention_scores": valid_attention.tolist(),
            "top_attended_tokens": top_tokens,
        }

    def visualize_attention(
        self, attention_result: Dict[str, Any], save_path: Optional[Path] = None
    ) -> plt.Figure:
        """
        Visualize attention scores.

        Args:
            attention_result: Result from explain_attention
            save_path: Path to save the figure

        Returns:
            Matplotlib figure
        """
        top_tokens = attention_result["top_attended_tokens"]

        words = [t[0] for t in top_tokens[:15]]  # Show top 15
        scores = [t[1] for t in top_tokens[:15]]

        fig, ax = plt.subplots(figsize=(10, 6))

        y_pos = np.arange(len(words))
        ax.barh(y_pos, scores, color="steelblue")
        ax.set_yticks(y_pos)
        ax.set_yticklabels(words)
        ax.invert_yaxis()
        ax.set_xlabel("Attention Score")
        ax.set_title("Top Attended Tokens (BERT Attention)")

        plt.tight_layout()

        if save_path:
            fig.savefig(save_path, dpi=150, bbox_inches="tight")
            print(f"Saved attention visualization to {save_path}")

        return fig

    def get_feature_importance_tfidf(
        self, model, top_n: int = 20
    ) -> Dict[str, List[Tuple[str, float]]]:
        """
        Get feature importance from TF-IDF + Logistic Regression model.

        Args:
            model: TfidfLogisticClassifier instance
            top_n: Number of top features per class

        Returns:
            Dictionary with feature importance per class
        """
        fi = model.get_feature_importance(top_n=top_n)

        return {
            "misinformation_indicators": list(
                zip(fi["misinformation_features"].tolist(), fi["misinformation_scores"].tolist())
            ),
            "credible_indicators": list(
                zip(fi["credible_features"].tolist(), fi["credible_scores"].tolist())
            ),
        }

    def visualize_feature_importance(
        self, feature_importance: Dict[str, Any], save_path: Optional[Path] = None
    ) -> plt.Figure:
        """
        Visualize TF-IDF feature importance.

        Args:
            feature_importance: Result from get_feature_importance_tfidf
            save_path: Path to save the figure

        Returns:
            Matplotlib figure
        """
        fig, axes = plt.subplots(1, 2, figsize=(14, 6))

        # Misinformation indicators
        misinfo = feature_importance["misinformation_indicators"][:10]
        words_m = [f[0] for f in misinfo]
        scores_m = [f[1] for f in misinfo]

        axes[0].barh(range(len(words_m)), scores_m, color="red", alpha=0.7)
        axes[0].set_yticks(range(len(words_m)))
        axes[0].set_yticklabels(words_m)
        axes[0].invert_yaxis()
        axes[0].set_xlabel("Coefficient")
        axes[0].set_title("Top Misinformation Indicators")

        # Credible indicators
        credible = feature_importance["credible_indicators"][:10]
        words_c = [f[0] for f in credible]
        scores_c = [abs(f[1]) for f in credible]  # Use absolute values for display

        axes[1].barh(range(len(words_c)), scores_c, color="green", alpha=0.7)
        axes[1].set_yticks(range(len(words_c)))
        axes[1].set_yticklabels(words_c)
        axes[1].invert_yaxis()
        axes[1].set_xlabel("Coefficient (absolute)")
        axes[1].set_title("Top Credible Indicators")

        plt.tight_layout()

        if save_path:
            fig.savefig(save_path, dpi=150, bbox_inches="tight")
            print(f"Saved feature importance visualization to {save_path}")

        return fig

    def generate_explanation_report(
        self, text: str, traditional_model, bert_model=None, save_dir: Optional[Path] = None
    ) -> Dict[str, Any]:
        """
        Generate comprehensive explanation report for a text.

        Args:
            text: Input text to explain
            traditional_model: Traditional ML model (e.g., TfidfLogisticClassifier)
            bert_model: Optional BERT model for attention-based explanation
            save_dir: Directory to save visualizations

        Returns:
            Dictionary with all explanations
        """
        save_dir = save_dir or RESULTS_DIR
        save_dir = Path(save_dir)
        save_dir.mkdir(exist_ok=True)

        report = {"text": text}

        # LIME explanation for traditional model
        print("Generating LIME explanation...")
        lime_result = self.explain_with_lime(
            text, lambda x: traditional_model.predict_proba(pd.Series(x))
        )
        report["lime_explanation"] = lime_result

        self.visualize_lime_explanation(lime_result, save_path=save_dir / "lime_explanation.png")

        # Attention explanation for BERT
        if bert_model is not None and bert_model.is_trained:
            print("Generating attention explanation...")
            attention_result = self.explain_attention(bert_model, text)
            report["attention_explanation"] = attention_result

            self.visualize_attention(
                attention_result, save_path=save_dir / "attention_explanation.png"
            )

        # Feature importance
        if hasattr(traditional_model, "get_feature_importance"):
            print("Extracting feature importance...")
            fi = self.get_feature_importance_tfidf(traditional_model)
            report["feature_importance"] = fi

            self.visualize_feature_importance(fi, save_path=save_dir / "feature_importance.png")

        return report


def explain_prediction(text: str, model, model_type: str = "traditional") -> Dict[str, Any]:
    """
    Quick function to explain a single prediction.

    Args:
        text: Input text
        model: Trained model
        model_type: 'traditional' or 'bert'

    Returns:
        Explanation dictionary
    """
    explainer = ModelExplainer()

    if model_type == "traditional":
        result = explainer.explain_with_lime(text, lambda x: model.predict_proba(pd.Series(x)))
    else:
        result = explainer.explain_attention(model, text)

    return result


if __name__ == "__main__":
    from src.data_preprocessing import prepare_data
    from src.traditional_ml import TfidfLogisticClassifier

    print("Preparing data...")
    train_df, val_df, test_df = prepare_data()

    print("\nTraining model...")
    model = TfidfLogisticClassifier()
    model.fit(train_df["combined_text"], train_df["label"])

    print("\nGenerating explanations...")
    explainer = ModelExplainer()

    # Test with a sample
    sample_text = test_df.iloc[0]["combined_text"]
    print(f"\nSample text: {sample_text[:200]}...")

    # LIME explanation
    lime_result = explainer.explain_with_lime(
        sample_text, lambda x: model.predict_proba(pd.Series(x))
    )

    print(f"\nPrediction: {lime_result['predicted_class']}")
    print(f"Confidence: {lime_result['confidence']:.2%}")
    print("\nTop features:")
    for word, weight in lime_result["feature_weights"][:5]:
        print(f"  {word}: {weight:.4f}")

    # Feature importance
    fi = explainer.get_feature_importance_tfidf(model)
    print("\nGlobal Misinformation Indicators:")
    for word, score in fi["misinformation_indicators"][:5]:
        print(f"  {word}: {score:.4f}")