| """ |
| MindCheck Flask Backend β serves PHQ-8 predictions from the trained |
| MPNetMultiOutputRegressor model. |
| |
| Endpoints: |
| POST /predict β accepts {"answers": ["...", ...]} (8 text answers) |
| GET /health β health check |
| """ |
|
|
| import os |
| import re |
| import numpy as np |
| import torch |
| import torch.nn as nn |
| from flask import Flask, request, jsonify |
| from flask_cors import CORS |
| from transformers import AutoTokenizer, AutoModel |
|
|
| |
| |
| |
|
|
| MODEL_PATH = os.environ.get( |
| "MODEL_PATH", |
| os.path.join(os.path.dirname(__file__), "..", "NOTEBOOK&MODEL", "model.pt"), |
| ) |
| MODEL_NAME = "sentence-transformers/all-mpnet-base-v2" |
| MAX_LEN = 512 |
| HEAD_TOKENS = 256 |
| TAIL_TOKENS = 256 |
| NV_SIZE = 18 |
| N_TARGETS = 8 |
| DEVICE = "cpu" |
|
|
| PHQ_ITEM_NAMES = [ |
| "NoInterest", |
| "Depressed", |
| "Sleep", |
| "Tired", |
| "Appetite", |
| "Failure", |
| "Concentration", |
| "Psychomotor", |
| ] |
|
|
| NEGATIVE_WORDS = { |
| "hopeless", "worthless", "empty", "tired", "sad", "lonely", |
| "fail", "useless", "burden", "numb", "anxious", "hate", |
| "depressed", "miserable", "terrible", "awful", "horrible", |
| "struggling", "suffering", "pain", "hurt", "crying", |
| "helpless", "frustrated", "angry", "guilty", "ashamed", |
| "exhausted", "overwhelmed", "stressed", "worried", "afraid", |
| } |
|
|
| POSITIVE_WORDS = { |
| "happy", "good", "fine", "great", "better", "enjoy", |
| "love", "hope", "okay", "well", "improving", "grateful", |
| "excited", "motivated", "confident", "peaceful", "calm", |
| "cheerful", "satisfied", "content", "wonderful", "amazing", |
| } |
|
|
| FILLER_PATTERN = re.compile(r"\b(um|uh|uhm|hmm|hm|erm|er|ah|like|you know)\b", re.I) |
|
|
|
|
| |
| |
| |
|
|
| class MPNetMultiOutputRegressor(nn.Module): |
| """ |
| MPNet encoder with attention pooling + 8-head multi-output regressor. |
| |
| Architecture: |
| Encoder β last_hidden_state (B, L, 768) |
| AttnPool β pooled (B, 768) |
| Concat β [pooled, nv] (B, 768 + NV_SIZE) |
| SharedMLP β shared_repr (B, 256) |
| Heads β [h1..h8] 8 Γ Linear(256 β 1) |
| """ |
|
|
| def __init__(self, model_name=MODEL_NAME, nv_size=NV_SIZE, |
| n_targets=N_TARGETS, dropout=0.1): |
| super().__init__() |
| self.encoder = AutoModel.from_pretrained(model_name) |
| hidden = self.encoder.config.hidden_size |
|
|
| |
| self.token_attn = nn.Linear(hidden, 1) |
|
|
| |
| self.shared_mlp = nn.Sequential( |
| nn.Linear(hidden + nv_size, 512), |
| nn.LayerNorm(512), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(512, 256), |
| nn.LayerNorm(256), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| ) |
|
|
| |
| self.heads = nn.ModuleList([nn.Linear(256, 1) for _ in range(n_targets)]) |
| self.dropout = nn.Dropout(dropout) |
|
|
| def forward(self, input_ids, attention_mask, nv_features): |
| out = self.encoder(input_ids=input_ids, attention_mask=attention_mask) |
| hidden = out.last_hidden_state |
|
|
| |
| scores = self.token_attn(hidden).squeeze(-1) |
| scores = scores.masked_fill(attention_mask == 0, -1e9) |
| weights = torch.softmax(scores, dim=1).unsqueeze(-1) |
| pooled = (hidden * weights).sum(dim=1) |
| pooled = self.dropout(pooled) |
|
|
| |
| x = torch.cat([pooled, nv_features], dim=-1) |
| x = self.shared_mlp(x) |
|
|
| |
| preds = torch.cat([head(x) for head in self.heads], dim=-1) |
| return preds |
|
|
|
|
| |
| |
| |
|
|
| def tokenize_head_tail(text, tokenizer, max_len=MAX_LEN, |
| head=HEAD_TOKENS, tail=TAIL_TOKENS): |
| """Tokenize with head-tail truncation strategy.""" |
| ids = tokenizer( |
| text, |
| add_special_tokens=False, |
| return_attention_mask=False, |
| return_token_type_ids=False, |
| )["input_ids"] |
|
|
| bos = tokenizer.cls_token_id or tokenizer.bos_token_id |
| eos = tokenizer.sep_token_id or tokenizer.eos_token_id |
| inner = max_len - 2 |
|
|
| if len(ids) > inner: |
| h = min(head, inner // 2) |
| t = inner - h |
| ids = ids[:h] + ids[-t:] |
|
|
| ids = [bos] + ids + [eos] |
| pad = max_len - len(ids) |
| mask = [1] * len(ids) + [0] * pad |
| ids = ids + [tokenizer.pad_token_id] * pad |
|
|
| return ids, mask |
|
|
|
|
| |
| |
| |
|
|
| def extract_nv_features(text: str) -> np.ndarray: |
| """ |
| Extract the 18-dim NV feature vector from text input. |
| |
| Since we're processing typed text (not DAIC-WOZ audio transcripts), |
| most nonverbal features will be zero. The lexical features (neg_ratio, |
| pos_ratio, sentiment_gap) and text length features are still informative. |
| |
| Feature order (must match training): |
| [nv_laugh, nv_sigh, nv_cough, nv_breath, nv_sniff, nv_groan, |
| nv_pause, nv_um, nv_uh, nv_total, filler_count, |
| word_count, utterance_count, avg_utt_len, response_brevity, |
| neg_ratio, pos_ratio, sentiment_gap] |
| """ |
| words = text.lower().split() |
| word_set = set(words) |
| total_words = len(words) + 1 |
|
|
| |
| nv_tags = [0.0] * 9 |
| nv_total = 0.0 |
|
|
| |
| filler_count = len(FILLER_PATTERN.findall(text)) |
|
|
| |
| sentences = [s.strip() for s in re.split(r"[.!?\n]+", text) if s.strip()] |
| word_count = len(words) |
| utterance_count = max(len(sentences), 1) |
| avg_utt_len = word_count / utterance_count |
| response_brevity = 1.0 if utterance_count < 50 else 0.0 |
|
|
| |
| neg_count = len(word_set & NEGATIVE_WORDS) |
| pos_count = len(word_set & POSITIVE_WORDS) |
| neg_ratio = neg_count / total_words |
| pos_ratio = pos_count / total_words |
| sentiment_gap = (neg_count - pos_count) / total_words |
|
|
| features = np.array( |
| nv_tags + [nv_total, filler_count, |
| word_count, utterance_count, avg_utt_len, response_brevity, |
| neg_ratio, pos_ratio, sentiment_gap], |
| dtype=np.float32, |
| ) |
| return features |
|
|
|
|
| def severity_category(score: float) -> dict: |
| """Return severity category info based on PHQ-8 total score.""" |
| score = round(score) |
| if score <= 4: |
| return {"label": "Minimal", "color": "green", "description": "Minimal depression β no treatment typically needed."} |
| elif score <= 9: |
| return {"label": "Mild", "color": "amber", "description": "Mild depression β watchful waiting; repeat screening at follow-up."} |
| elif score <= 14: |
| return {"label": "Moderate", "color": "orange", "description": "Moderate depression β consultation with a mental health professional is recommended."} |
| elif score <= 19: |
| return {"label": "Moderately Severe", "color": "red-orange", "description": "Moderately severe depression β active treatment with pharmacotherapy and/or psychotherapy is recommended."} |
| else: |
| return {"label": "Severe", "color": "red", "description": "Severe depression β immediate initiation of pharmacotherapy and referral to a mental health specialist."} |
|
|
|
|
| |
| |
| |
|
|
| print(f"Loading tokenizer: {MODEL_NAME}") |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) |
|
|
| print(f"Building model architecture...") |
| model = MPNetMultiOutputRegressor( |
| model_name=MODEL_NAME, |
| nv_size=NV_SIZE, |
| n_targets=N_TARGETS, |
| dropout=0.0, |
| ) |
|
|
| print(f"Loading weights from: {MODEL_PATH}") |
| state_dict = torch.load(MODEL_PATH, map_location=DEVICE, weights_only=True) |
| model.load_state_dict(state_dict) |
| model.eval() |
| model.to(DEVICE) |
| print("β
Model loaded successfully!") |
|
|
|
|
| |
| |
| |
|
|
| app = Flask(__name__) |
| CORS(app) |
|
|
|
|
| @app.route("/health", methods=["GET"]) |
| def health(): |
| return jsonify({"status": "ok", "model": MODEL_NAME, "targets": N_TARGETS}) |
|
|
|
|
| @app.route("/predict", methods=["POST"]) |
| def predict(): |
| """ |
| Predict PHQ-8 scores from 8 interview answers. |
| |
| Request body: |
| {"answers": ["answer1", "answer2", ..., "answer8"]} |
| |
| Response: |
| { |
| "scores": [0-3, ...], # 8 per-item scores |
| "total_score": 0-24, # sum of items |
| "severity": {...}, # category info |
| "filler_count": int, |
| "negative_words": [str, ...], |
| "positive_words": [str, ...], |
| "item_names": [str, ...] |
| } |
| """ |
| data = request.get_json(force=True) |
| answers = data.get("answers", []) |
|
|
| if len(answers) != 8: |
| return jsonify({"error": "Exactly 8 answers required"}), 400 |
|
|
| |
| transcript = " ".join(answers) |
|
|
| |
| ids, mask = tokenize_head_tail(transcript, tokenizer) |
| input_ids = torch.tensor([ids], dtype=torch.long, device=DEVICE) |
| attention_mask = torch.tensor([mask], dtype=torch.long, device=DEVICE) |
|
|
| |
| nv = extract_nv_features(transcript) |
| nv_tensor = torch.tensor([nv], dtype=torch.float, device=DEVICE) |
|
|
| |
| with torch.no_grad(): |
| preds = model(input_ids, attention_mask, nv_tensor) |
|
|
| |
| scores_raw = preds[0].cpu().numpy() |
| scores_clipped = np.clip(scores_raw, 0, 3).tolist() |
| scores_rounded = [round(s, 2) for s in scores_clipped] |
| total_score = round(sum(scores_rounded), 2) |
| severity = severity_category(total_score) |
|
|
| |
| words_lower = set(transcript.lower().split()) |
| detected_neg = sorted(words_lower & NEGATIVE_WORDS) |
| detected_pos = sorted(words_lower & POSITIVE_WORDS) |
| filler_count = len(FILLER_PATTERN.findall(transcript)) |
|
|
| return jsonify({ |
| "scores": scores_rounded, |
| "total_score": total_score, |
| "severity": severity, |
| "filler_count": filler_count, |
| "negative_words": detected_neg, |
| "positive_words": detected_pos, |
| "item_names": PHQ_ITEM_NAMES, |
| }) |
|
|
|
|
| if __name__ == "__main__": |
| app.run(host="0.0.0.0", port=8000, debug=False) |
|
|