suicideproject / src /model_compare.py
Antigravity Deploy Agent
Deploy Suicide Risk Detection web application to Hugging Face Spaces
0be18fb
Raw
History Blame Contribute Delete
11.7 kB
# src/model_compare.py
import os
from glob import glob
from typing import Dict
import numpy as np
import pandas as pd
from dotenv import load_dotenv
from sklearn.metrics import (
accuracy_score,
confusion_matrix,
f1_score,
precision_score,
recall_score,
)
load_dotenv()
# ---------------------------
# Helpers: robust CSV reading
# ---------------------------
def _clean_columns(df: pd.DataFrame) -> pd.DataFrame:
df.columns = [str(c).replace("\ufeff", "").strip() for c in df.columns]
return df
def _clean_text_col(df: pd.DataFrame, col: str = "text") -> pd.DataFrame:
if col in df.columns:
df[col] = (
df[col]
.astype(str)
.str.replace("\ufeff", "", regex=False)
.str.replace("\u200b", "", regex=False) # zero-width space, just in case
.str.strip()
)
return df
def _safe_read_csv(path: str) -> pd.DataFrame:
"""
Read CSV in a way that survives UTF-8 BOM and keeps columns consistent.
"""
df = pd.read_csv(path, encoding="utf-8-sig")
df = _clean_columns(df)
df = _clean_text_col(df, "text")
df = _clean_text_col(df, "id")
return df
def _extract_tag(path: str, prefix: str) -> str:
# e.g. chat_with_probs_xlmr.csv -> xlmr
base = os.path.basename(path)
tag = base.replace(prefix, "").replace(".csv", "")
return tag.strip("_").lower()
# ---------------------------
# Label loading + joining
# ---------------------------
def _load_labels(
processed_dir: str, label_file: str = "text_all_clean.csv"
) -> pd.DataFrame:
label_path = os.path.join(processed_dir, label_file)
df = _safe_read_csv(label_path)
print(f"[SUCCESS] Loaded labels: {os.path.basename(label_path)} | shape={df.shape}")
print("[SUCCESS] Label columns:", list(df.columns))
if "label" not in df.columns:
raise ValueError(
f"Label file must have 'label' column. Found: {list(df.columns)}"
)
if "id" not in df.columns and "text" not in df.columns:
raise ValueError(
"Label file must have either 'id' or 'text' column to join predictions."
)
keep = [c for c in ["id", "text", "label", "lang"] if c in df.columns]
out = df[keep].copy()
# Ensure label numeric 0/1
out["label"] = pd.to_numeric(out["label"], errors="coerce")
if out["label"].isna().any():
bad = out[out["label"].isna()].head(5)
raise ValueError(
"Some label values are not numeric (0/1). Example bad rows:\n" f"{bad}"
)
out["label"] = out["label"].astype(int)
return out
def _join_pred_with_labels(
labels_df: pd.DataFrame, pred_df: pd.DataFrame
) -> pd.DataFrame:
"""
Join strategy:
1) if both have 'id' -> join on id
2) else if both have 'text' -> join on text
✅ IMPORTANT FIX:
Prediction files sometimes already contain label columns.
We REMOVE those before merging to avoid label_x/label_y.
"""
pred_df = _clean_columns(pred_df)
pred_df = _clean_text_col(pred_df, "text")
pred_df = _clean_text_col(pred_df, "id")
# ✅ drop any label-like columns in prediction df to avoid merge suffixes
drop_cols = [
c for c in pred_df.columns if c.lower() in {"label", "label_x", "label_y"}
]
if drop_cols:
pred_df = pred_df.drop(columns=drop_cols)
if "id" in labels_df.columns and "id" in pred_df.columns:
out = pred_df.merge(labels_df[["id", "label"]], on="id", how="inner")
return out
if "text" in labels_df.columns and "text" in pred_df.columns:
out = pred_df.merge(labels_df[["text", "label"]], on="text", how="inner")
return out
raise ValueError(
"Cannot join predictions with labels. Need shared 'id' or shared 'text' column."
)
# ---------------------------
# Metrics
# ---------------------------
def _metrics(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, float]:
return {
"accuracy": float(accuracy_score(y_true, y_pred)),
"precision": float(precision_score(y_true, y_pred, zero_division=0)),
"recall": float(recall_score(y_true, y_pred, zero_division=0)),
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
}
def _format_cm(y_true: np.ndarray, y_pred: np.ndarray) -> str:
cm = confusion_matrix(y_true, y_pred, labels=[0, 1])
tn, fp, fn, tp = cm.ravel()
return f"tn={tn}, fp={fp}, fn={fn}, tp={tp}"
# ---------------------------
# Compare logic
# ---------------------------
def compare_models(
processed_dir: str = "data/processed",
reports_dir: str = "outputs/reports",
chat_thr_default: float = 0.50,
) -> Dict[str, str]:
"""
Expects:
- data/processed/chat_with_probs_<tag>.csv with: chat_prob and (id or text)
- data/processed/fusion_final_output_<tag>.csv with: final_risk_score and (id or text)
- data/processed/text_all_clean.csv with: text,label,lang (or id,label)
"""
os.makedirs(reports_dir, exist_ok=True)
labels_df = _load_labels(processed_dir)
chat_files = sorted(glob(os.path.join(processed_dir, "chat_with_probs_*.csv")))
fusion_files = sorted(
glob(os.path.join(processed_dir, "fusion_final_output_*.csv"))
)
if not chat_files:
raise FileNotFoundError(f"No chat_with_probs_*.csv found in: {processed_dir}")
if not fusion_files:
raise FileNotFoundError(
f"No fusion_final_output_*.csv found in: {processed_dir}"
)
# -------- CHAT COMPARISON --------
chat_rows = []
for path in chat_files:
tag = _extract_tag(path, "chat_with_probs_")
df_pred = _safe_read_csv(path)
if "chat_prob" not in df_pred.columns:
raise ValueError(
f"{os.path.basename(path)} missing 'chat_prob'. Found: {list(df_pred.columns)}"
)
joined = _join_pred_with_labels(labels_df, df_pred)
if len(joined) == 0:
raise ValueError(
f"Join produced 0 rows for chat file: {os.path.basename(path)}\n"
f"Labels columns: {list(labels_df.columns)}\n"
f"Pred columns: {list(df_pred.columns)}\n"
"Fix: ensure both sides share 'id' or 'text' with same values."
)
if "label" not in joined.columns:
raise KeyError(
f"After join, 'label' is missing for chat file: {os.path.basename(path)}\n"
f"Joined columns: {list(joined.columns)}"
)
y_true = joined["label"].astype(int).values
# Optional per-model threshold.json
thr = chat_thr_default
thr_path = os.path.join(
"outputs", "models", f"chat_brain_{tag}", "threshold.json"
)
if os.path.exists(thr_path):
try:
import json
with open(thr_path, "r", encoding="utf-8") as f:
thr_obj = json.load(f)
if "thr" in thr_obj:
thr = float(thr_obj["thr"])
except Exception:
pass
y_pred = (joined["chat_prob"].astype(float).values >= thr).astype(int)
m = _metrics(y_true, y_pred)
chat_rows.append(
{
"model_tag": tag,
"n": int(len(joined)),
"threshold_used": float(thr),
**m,
"confusion": _format_cm(y_true, y_pred),
}
)
chat_df = pd.DataFrame(chat_rows).sort_values(["recall", "f1"], ascending=False)
chat_out = os.path.join(reports_dir, "model_comparison_chat.csv")
chat_df.to_csv(chat_out, index=False, encoding="utf-8")
# -------- FUSION COMPARISON --------
fusion_rows = []
for path in fusion_files:
tag = _extract_tag(path, "fusion_final_output_")
df_pred = _safe_read_csv(path)
if "final_risk_score" not in df_pred.columns:
raise ValueError(
f"{os.path.basename(path)} missing 'final_risk_score'. Found: {list(df_pred.columns)}"
)
joined = _join_pred_with_labels(labels_df, df_pred)
if len(joined) == 0:
raise ValueError(
f"Join produced 0 rows for fusion file: {os.path.basename(path)}\n"
f"Labels columns: {list(labels_df.columns)}\n"
f"Pred columns: {list(df_pred.columns)}\n"
"Fix: ensure both sides share 'id' or 'text' with same values."
)
if "label" not in joined.columns:
raise KeyError(
f"After join, 'label' is missing for fusion file: {os.path.basename(path)}\n"
f"Joined columns: {list(joined.columns)}"
)
y_true = joined["label"].astype(int).values
# use same threshold as chat model if available else default
thr = chat_thr_default
thr_path = os.path.join(
"outputs", "models", f"chat_brain_{tag}", "threshold.json"
)
if os.path.exists(thr_path):
try:
import json
with open(thr_path, "r", encoding="utf-8") as f:
thr_obj = json.load(f)
if "thr" in thr_obj:
thr = float(thr_obj["thr"])
except Exception:
pass
y_pred = (joined["final_risk_score"].astype(float).values >= thr).astype(int)
m = _metrics(y_true, y_pred)
fusion_rows.append(
{
"model_tag": tag,
"n": int(len(joined)),
"threshold_used": float(thr),
**m,
"confusion": _format_cm(y_true, y_pred),
}
)
fusion_df = pd.DataFrame(fusion_rows).sort_values(["recall", "f1"], ascending=False)
fusion_out = os.path.join(reports_dir, "model_comparison_fusion.csv")
fusion_df.to_csv(fusion_out, index=False, encoding="utf-8")
# -------- SUMMARY --------
summary = chat_df.merge(
fusion_df,
on="model_tag",
how="outer",
suffixes=("_chat", "_fusion"),
)
summary = summary.sort_values(
["recall_fusion", "f1_fusion", "recall_chat", "f1_chat"],
ascending=False,
na_position="last",
)
summary_out = os.path.join(reports_dir, "model_comparison_summary.csv")
summary.to_csv(summary_out, index=False, encoding="utf-8")
print("\n================= MODEL COMPARISON (CHAT) =================")
print(
chat_df[
[
"model_tag",
"threshold_used",
"recall",
"f1",
"precision",
"accuracy",
"confusion",
]
].to_string(index=False)
)
print("\n================= MODEL COMPARISON (FUSION) =================")
print(
fusion_df[
[
"model_tag",
"threshold_used",
"recall",
"f1",
"precision",
"accuracy",
"confusion",
]
].to_string(index=False)
)
print("\n[SUCCESS] Saved reports:")
print("-", chat_out)
print("-", fusion_out)
print("-", summary_out)
return {
"chat_report": chat_out,
"fusion_report": fusion_out,
"summary_report": summary_out,
}
def main():
processed_dir = os.getenv("PROCESSED_DIR", "data/processed")
reports_dir = os.getenv("REPORTS_DIR", "outputs/reports")
compare_models(processed_dir=processed_dir, reports_dir=reports_dir)
if __name__ == "__main__":
main()