import torch import torch.nn as nn import re import json import os # Define the 5 target emotions in alphabetical order for consistency EMOTIONS = ["Bored", "Confident", "Confused", "Curious", "Frustrated"] class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=64, hidden_dim=64, output_dim=5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, output_dim) def forward(self, x): # x shape: [batch, seq_len] embedded = self.embedding(x) # [batch, seq_len, embedding_dim] lstm_out, _ = self.lstm(embedded) # [batch, seq_len, hidden_dim * 2] # Mean pooling over sequence dimension pooled = torch.mean(lstm_out, dim=1) # [batch, hidden_dim * 2] logits = self.fc(pooled) # [batch, output_dim] return logits def clean_text(text): """ Cleans text by lowercasing, removing non-alphanumeric characters except spaces, and removing extra whitespaces. """ if not text: return "" text = text.lower() text = re.sub(r'[^a-z0-9\s]', '', text) text = re.sub(r'\s+', ' ', text).strip() return text def tokenize(text): """ Tokenizes clean text into a list of words. """ return clean_text(text).split() def text_to_indices(text, vocab, max_len=50): """ Converts a text string into a list of vocabulary indices with padding/truncation. """ words = tokenize(text) indices = [] for w in words: indices.append(vocab.get(w, 1)) # 1 is # Pad or truncate if len(indices) < max_len: indices += [0] * (max_len - len(indices)) # 0 is else: indices = indices[:max_len] return indices class BiLSTMWrapper: def __init__(self, model_path="bilstm_model.pth", vocab_path="vocab.json"): self.model_path = model_path self.vocab_path = vocab_path self.model = None self.vocab = None self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if os.path.exists(model_path) and os.path.exists(vocab_path): self.load() def load(self): # Load vocabulary with open(self.vocab_path, "r", encoding="utf-8") as f: self.vocab = json.load(f) # Initialize and load model weights vocab_size = len(self.vocab) self.model = BiLSTMClassifier(vocab_size) self.model.load_state_dict(torch.load(self.model_path, map_location=self.device, weights_only=True)) self.model.to(self.device) self.model.eval() def predict(self, text): """ Runs inference on the input text and returns predictions in the unified schema. """ if self.model is None or self.vocab is None: # If not loaded, return default flat predictions flat_score = 1.0 / len(EMOTIONS) all_emotions = {e: flat_score for e in EMOTIONS} return { "primary_emotion": EMOTIONS[0], "primary_confidence": round(flat_score, 4), "all_emotions": {e: round(flat_score, 4) for e in EMOTIONS}, "mixed_emotions": [] } indices = text_to_indices(text, self.vocab) tensor = torch.tensor([indices], dtype=torch.long).to(self.device) with torch.no_grad(): logits = self.model(tensor) probs = torch.softmax(logits, dim=1).squeeze().cpu().numpy() all_emotions = {} for emotion, prob in zip(EMOTIONS, probs): all_emotions[emotion] = round(float(prob), 4) # Find primary emotion primary_emotion = max(all_emotions, key=all_emotions.get) primary_confidence = all_emotions[primary_emotion] # Mixed emotions: any emotion other than the top one with confidence >= 15% mixed_emotions = [] for emotion, prob in all_emotions.items(): if emotion != primary_emotion and prob >= 0.15: mixed_emotions.append([emotion, round(prob, 4)]) # Sort mixed emotions descending by score mixed_emotions.sort(key=lambda x: x[1], reverse=True) return { "primary_emotion": primary_emotion, "primary_confidence": primary_confidence, "all_emotions": all_emotions, "mixed_emotions": mixed_emotions }