""" Loads the fine-tuned DistilBERT model (winner of Objective O2's three-way comparison, tables/T13_full_model_comparison.csv) and provides: - predict_proba(texts) -> class probabilities, in the shape LIME expects - classify(text) -> verdict + confidence for a single email - explain(text) -> LIME word-level attribution for the verdict Label convention (fixed at training time in the Colab notebook, must match here): 0 = Legitimate, 1 = Phishing. """ from pathlib import Path import numpy as np import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification from lime.lime_text import LimeTextExplainer MODEL_DIR = Path(__file__).resolve().parents[1] / "models" / "distilbert_final_model" MAX_LENGTH = 512 LABEL_NAMES = {0: "Legitimate", 1: "Phishing"} _tokenizer = None _model = None _explainer = LimeTextExplainer(class_names=[LABEL_NAMES[0], LABEL_NAMES[1]]) def load(): global _tokenizer, _model if _model is None: if not MODEL_DIR.exists(): raise FileNotFoundError( f"Model directory not found at {MODEL_DIR}. " "Download 'distilbert_final_model' from the Colab notebook's Drive output first." ) _tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR)) _model = AutoModelForSequenceClassification.from_pretrained(str(MODEL_DIR)) _model.eval() return _tokenizer, _model def predict_proba(texts, batch_size=32): """Returns an (n_samples, 2) array of [P(Legitimate), P(Phishing)]. Required shape for LIME.""" tokenizer, model = load() all_probs = [] with torch.no_grad(): for i in range(0, len(texts), batch_size): batch = list(texts[i:i + batch_size]) enc = tokenizer( batch, truncation=True, max_length=MAX_LENGTH, padding=True, return_tensors="pt" ) logits = model(**enc).logits probs = torch.softmax(logits, dim=1).numpy() all_probs.append(probs) return np.concatenate(all_probs, axis=0) def classify(text: str) -> dict: probs = predict_proba([text])[0] pred_idx = int(np.argmax(probs)) return { "verdict": LABEL_NAMES[pred_idx], "confidence": float(probs[pred_idx]), "phishing_probability": float(probs[1]), "legitimate_probability": float(probs[0]), } def explain(text: str, num_features: int = 10, num_samples: int = 100): """ Returns a list of (word, weight) tuples for the Phishing class. Positive weight = pushed the model toward "Phishing". Negative weight = pushed the model toward "Legitimate". Only the first ~500 words are passed to LIME: the model itself truncates at 512 tokens, so perturbing words beyond that window would not affect the prediction and would only slow down the explanation. """ words = text.split() truncated_text = " ".join(words[:500]) exp = _explainer.explain_instance( truncated_text, predict_proba, num_features=num_features, num_samples=num_samples, labels=[1] ) return exp.as_list(label=1)