socratica-companion / bilstm_model.py
crazyaadi's picture
Deploy Socratica Companion without binary files
c4fe4b1
Raw
History Blame Contribute Delete
4.68 kB
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 <unk>
# Pad or truncate
if len(indices) < max_len:
indices += [0] * (max_len - len(indices)) # 0 is <pad>
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
}