Nura / src /models /emotion_classifier.py
Marwan-Tamer's picture
Prepare Hugging Face Spaces deployment
ca2008b
Raw
History Blame
6.61 kB
from __future__ import annotations
import argparse
import json
import os
import re
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_MODEL_DIR = PROJECT_ROOT / "src" / "models" / "saved_emotion_model"
DEFAULT_HF_MODEL_ID = ""
def _load_transformer_stack() -> tuple[Any, Any, Any]:
try:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
except ImportError as exc:
raise ImportError(
"Module 2 requires torch and transformers. Install them with "
"`python -m pip install -r requirements.txt`, or run the Colab notebook."
) from exc
return torch, AutoModelForSequenceClassification, AutoTokenizer
class EmotionClassifier:
"""Transformer emotion classifier with confidence and simple word-occlusion explanations."""
def __init__(
self,
model_dir: str | Path | None = None,
) -> None:
self.model_dir = Path(model_dir or os.getenv("EMOTION_MODEL_DIR", DEFAULT_MODEL_DIR))
self.model_id = os.getenv("EMOTION_MODEL_ID", DEFAULT_HF_MODEL_ID).strip()
self.active_model_source = str(self.model_dir if self.model_dir.exists() else self.model_id)
self.torch = None
self.tokenizer = None
self.model = None
self.id2label: dict[int, str] = {}
def load_model(self) -> None:
model_source = self._resolve_model_source()
if not model_source:
raise FileNotFoundError(
"Emotion model is not available. Train Module 2 locally, or set "
"EMOTION_MODEL_ID to a Hugging Face model repository."
)
torch, model_cls, tokenizer_cls = _load_transformer_stack()
self.torch = torch
self.tokenizer = tokenizer_cls.from_pretrained(model_source)
self.model = model_cls.from_pretrained(model_source)
self.model.eval()
self.active_model_source = str(model_source)
config_labels = self.model.config.id2label
self.id2label = {int(key): value for key, value in config_labels.items()}
def _resolve_model_source(self) -> str | Path | None:
if self.model_dir.exists():
return self.model_dir
if self.model_id:
return self.model_id
return None
def _score_text(self, text: str) -> dict[str, Any]:
if self.model is None or self.tokenizer is None or self.torch is None:
self.load_model()
inputs = self.tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=128,
)
inputs.pop("token_type_ids", None)
with self.torch.no_grad():
logits = self.model(**inputs).logits
probabilities = self.torch.softmax(logits, dim=-1)[0]
best_index = int(probabilities.argmax().item())
confidence = float(probabilities[best_index].item())
scores = {self.id2label.get(index, str(index)): float(value.item()) for index, value in enumerate(probabilities)}
return {
"index": best_index,
"emotion": self.id2label.get(best_index, str(best_index)),
"confidence": confidence,
"scores": scores,
}
def predict_with_confidence(self, text: str) -> dict[str, Any]:
clean_text = text.strip()
if not clean_text:
return {
"emotion": "unknown",
"confidence": 0.0,
"is_confident": False,
"message": "Please enter text to classify.",
}
prediction = self._score_text(clean_text)
return {
"emotion": prediction["emotion"],
"confidence": prediction["confidence"],
"is_confident": prediction["confidence"] >= 0.60,
"message": None,
}
def explain(self, text: str, top_k: int = 8) -> dict[str, Any]:
"""Estimate influential words by measuring confidence drop after removing each word."""
clean_text = text.strip()
base_scores = self._score_text(clean_text)
base_prediction = {
"emotion": base_scores["emotion"],
"confidence": base_scores["confidence"],
"is_confident": base_scores["confidence"] >= 0.60,
"message": None,
}
target_emotion = base_prediction["emotion"]
base_confidence = base_prediction["confidence"]
words = list(re.finditer(r"\b[\w']+\b", clean_text))
impacts = []
for match in words:
reduced_text = (clean_text[: match.start()] + clean_text[match.end() :]).strip()
reduced_scores = self._score_text(reduced_text) if reduced_text else {"scores": {target_emotion: 0.0}}
target_confidence_without_word = reduced_scores["scores"].get(target_emotion, 0.0)
confidence_drop = base_confidence - target_confidence_without_word
if confidence_drop > 0.001:
effect = "supports prediction"
elif confidence_drop < -0.001:
effect = "reduces prediction"
else:
effect = "neutral"
impact = round(float(confidence_drop), 4)
if impact == -0.0:
impact = 0.0
impacts.append(
{
"word": match.group(0),
"impact": impact,
"confidence_without_word": round(float(target_confidence_without_word), 4),
"effect": effect,
}
)
impacts = sorted(impacts, key=lambda item: item["impact"], reverse=True)
return {
"prediction": base_prediction,
"top_evidence": impacts[:top_k],
"all_evidence": impacts,
"method": "word occlusion: larger impact means removing the word reduced confidence more",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Module 2 emotion inference.")
parser.add_argument("text", nargs="?", default="I feel anxious and overwhelmed today.")
parser.add_argument("--explain", action="store_true")
parser.add_argument("--model-dir", default=DEFAULT_MODEL_DIR, type=Path)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
classifier = EmotionClassifier(model_dir=args.model_dir)
output = classifier.explain(args.text) if args.explain else classifier.predict_with_confidence(args.text)
print(json.dumps(output, indent=2))