| """Shared helpers for the MindSignal prototype.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Iterable |
|
|
| import torch |
| from transformers import AutoModelForSequenceClassification, AutoTokenizer |
|
|
|
|
| LABELS = [ |
| "informational", |
| "emotional_support_needed", |
| "escalation_required", |
| ] |
|
|
| LABEL_TO_ID = {label: idx for idx, label in enumerate(LABELS)} |
| ID_TO_LABEL = {idx: label for label, idx in LABEL_TO_ID.items()} |
|
|
| MODEL_NAME = "distilbert-base-uncased" |
| DEFAULT_MODEL_DIR = Path("models/mindsignal-distilbert") |
|
|
| HIGH_RISK_PHRASES = [ |
| "kill myself", |
| "end my life", |
| "not worth living", |
| "hurt myself", |
| "suicide", |
| "not safe", |
| "better off without me", |
| "can't keep myself safe", |
| "cant keep myself safe", |
| ] |
|
|
|
|
| @dataclass |
| class Prediction: |
| """A simple prediction result used by both evaluation and Streamlit.""" |
|
|
| label: str |
| confidence: float |
| used_safety_override: bool |
|
|
|
|
| def validate_dataset_columns(columns: Iterable[str]) -> None: |
| """Raise a helpful error if the CSV does not have the expected columns.""" |
|
|
| required = {"id", "text", "label", "test_type", "split", "notes"} |
| missing = required.difference(columns) |
| if missing: |
| missing_list = ", ".join(sorted(missing)) |
| raise ValueError(f"Dataset is missing required columns: {missing_list}") |
|
|
|
|
| def safety_override_label(text: str) -> str | None: |
| """Return escalation_required when high-risk wording is detected.""" |
|
|
| normalized_text = text.lower() |
| for phrase in HIGH_RISK_PHRASES: |
| if phrase in normalized_text: |
| return "escalation_required" |
| return None |
|
|
|
|
| def load_model_and_tokenizer(model_dir: Path = DEFAULT_MODEL_DIR): |
| """Load the fine-tuned model if present, otherwise explain what to run.""" |
|
|
| if not model_dir.exists(): |
| raise FileNotFoundError( |
| f"Model directory not found: {model_dir}. Run `python train.py` first." |
| ) |
|
|
| tokenizer = AutoTokenizer.from_pretrained(model_dir) |
| model = AutoModelForSequenceClassification.from_pretrained(model_dir) |
| return tokenizer, model |
|
|
|
|
| def predict_text( |
| text: str, |
| tokenizer, |
| model, |
| max_length: int = 160, |
| device: str | torch.device | None = None, |
| ) -> Prediction: |
| """Classify one text message with a safety override before model inference.""" |
|
|
| override = safety_override_label(text) |
| if override is not None: |
| return Prediction( |
| label=override, |
| confidence=1.0, |
| used_safety_override=True, |
| ) |
|
|
| device = device or ("cuda" if torch.cuda.is_available() else "cpu") |
| model.to(device) |
| model.eval() |
|
|
| encoded = tokenizer( |
| text, |
| truncation=True, |
| padding=True, |
| max_length=max_length, |
| return_tensors="pt", |
| ) |
| encoded = {key: value.to(device) for key, value in encoded.items()} |
|
|
| with torch.no_grad(): |
| outputs = model(**encoded) |
| probabilities = torch.softmax(outputs.logits, dim=-1)[0] |
| predicted_id = int(torch.argmax(probabilities).item()) |
|
|
| return Prediction( |
| label=ID_TO_LABEL[predicted_id], |
| confidence=float(probabilities[predicted_id].item()), |
| used_safety_override=False, |
| ) |
|
|