suicideproject / src /predict.py
Antigravity Deploy Agent
Deploy Suicide Risk Detection web application to Hugging Face Spaces
0be18fb
Raw
History Blame Contribute Delete
6.85 kB
import json
import os
import joblib
import numpy as np
import pandas as pd
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
def _prob_to_level(p: float, thr_high: float, thr_med: float) -> str:
if p >= thr_high:
return "High"
if p >= thr_med:
return "Medium"
return "Low"
def _fuse_level(chat_level: str, prof_level: str, s: float, thr_high: float, thr_med: float) -> str:
# Safety-first overrides (reduce false negatives)
if chat_level == "High":
return "High"
if chat_level == "Medium" and prof_level == "High":
return "High"
if s >= thr_high:
return "High"
if s >= thr_med:
return "Medium"
return "Low"
class SuicideRiskPredictor:
"""
Real-time predictor for ONE user input:
- text (string)
- profile (dict of structured features matching your BD columns)
"""
def __init__(
self,
model_name: str = "banglabert",
models_dir: str = "outputs/models",
artifacts_dir: str = "outputs/artifacts",
fusion_meta_path: str = None,
profile_meta_path: str = "outputs/artifacts/profile_risk_meta.joblib",
):
self.model_name = model_name
# ---- Load fusion config (weights/thresholds) ----
if not fusion_meta_path:
fusion_meta_path = os.path.join(artifacts_dir, f"fusion_meta_{model_name}.json")
if os.path.exists(fusion_meta_path):
with open(fusion_meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
else:
meta = {}
# Safely extract weights (with fallback)
weights = meta.get("weights", {})
self.w_chat = float(weights.get("w_chat", weights.get("chat", 0.60)))
self.w_prof = float(weights.get("w_prof", weights.get("profile", 0.40)))
# Safely extract thresholds
chat_thrs = meta.get("chat_thresholds", {"high": 0.70, "medium": 0.40})
self.chat_thr_high = float(chat_thrs.get("high", 0.70))
self.chat_thr_med = float(chat_thrs.get("medium", 0.40))
final_thrs = meta.get("final_thresholds", {"high": 0.65, "medium": 0.40})
self.final_thr_high = float(final_thrs.get("high", 0.65))
self.final_thr_med = float(final_thrs.get("medium", 0.40))
self.max_len = int(meta.get("max_len", 64))
# ---- Chat model ----
self.chat_model_dir = os.path.join(models_dir, f"chat_brain_{model_name}")
self.tokenizer = AutoTokenizer.from_pretrained(self.chat_model_dir)
self.chat_model = AutoModelForSequenceClassification.from_pretrained(self.chat_model_dir)
self.chat_model.eval()
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.chat_model.to(self.device)
# ---- Profile artifacts ----
self.pre = joblib.load(os.path.join(artifacts_dir, "profile_preprocessor.joblib"))
self.gmm = joblib.load(os.path.join(artifacts_dir, "gmm.joblib"))
self.kde = joblib.load(os.path.join(artifacts_dir, "kde.joblib"))
self.profile_meta = joblib.load(profile_meta_path) if os.path.exists(profile_meta_path) else None
# calibration percentiles for risk conversion
if self.profile_meta:
self.low_gmm, self.high_gmm = self.profile_meta["calibration_ll_percentiles_gmm"]
self.low_kde, self.high_kde = self.profile_meta["calibration_ll_percentiles_kde"]
self.p70 = self.profile_meta["p70"]
self.p90 = self.profile_meta["p90"]
self.kde_bandwidth = self.profile_meta["kde_bandwidth"]
self.w_gmm = self.profile_meta["w_gmm"]
self.w_kde = self.profile_meta["w_kde"]
else:
# fallback if meta not found
self.low_gmm, self.high_gmm = (-50.0, -5.0)
self.low_kde, self.high_kde = (-50.0, -5.0)
self.p70, self.p90 = (0.70, 0.90)
self.w_gmm, self.w_kde = (0.6, 0.4)
def _chat_prob(self, text: str) -> float:
enc = self.tokenizer(
[str(text)],
truncation=True,
padding=True,
max_length=self.max_len,
return_tensors="pt",
).to(self.device)
with torch.no_grad():
logits = self.chat_model(**enc).logits
p = torch.softmax(logits, dim=1)[:, 1].detach().cpu().numpy()[0]
return float(p)
def _ll_to_risk(self, ll: np.ndarray, low: float, high: float) -> np.ndarray:
norm = np.clip((ll - low) / (high - low + 1e-9), 0, 1)
return 1 - norm
def _profile_risk(self, profile_dict: dict) -> tuple[float, str]:
"""
profile_dict keys should match your BD structured columns AFTER preprocessing.
Missing keys are allowed (will become NaN and get imputed).
"""
expected_cols = [
"age_group", "age", "gender", "profession_group", "religion",
"hometown", "reason", "reason_description", "time", "temperature",
"feels_like", "temp_min", "temp_max", "air_pressure", "air_humidity",
"wind_speed", "wind_deg", "clouds_sky", "weather_main", "weather_description"
]
# Populate missing columns with NaN
filled_dict = {}
for col in expected_cols:
filled_dict[col] = profile_dict.get(col, np.nan)
df = pd.DataFrame([filled_dict])
X = self.pre.transform(df)
ll_gmm = self.gmm.score_samples(X)
ll_kde = self.kde.score_samples(X)
risk_gmm = self._ll_to_risk(ll_gmm, self.low_gmm, self.high_gmm)[0]
risk_kde = self._ll_to_risk(ll_kde, self.low_kde, self.high_kde)[0]
risk_profile = float(self.w_gmm * risk_gmm + self.w_kde * risk_kde)
# level using stored percentiles
if risk_profile >= self.p90:
level = "High"
elif risk_profile >= self.p70:
level = "Medium"
else:
level = "Low"
return risk_profile, level
def predict_one(self, text: str, profile: dict) -> dict:
chat_prob = self._chat_prob(text)
chat_level = _prob_to_level(chat_prob, self.chat_thr_high, self.chat_thr_med)
risk_profile, risk_level_profile = self._profile_risk(profile)
final_risk_score = float(self.w_chat * chat_prob + self.w_prof * risk_profile)
final_risk_level = _fuse_level(
chat_level, risk_level_profile, final_risk_score,
self.final_thr_high, self.final_thr_med
)
return {
"chat_prob": chat_prob,
"chat_level": chat_level,
"risk_profile": risk_profile,
"risk_level_profile": risk_level_profile,
"final_risk_score": final_risk_score,
"final_risk_level": final_risk_level,
}