""" Trains the meta-model that predicts whether the BERT sentiment classifier will get a given input wrong. Why RandomForest + calibration + a tuned threshold, instead of the original plain LogisticRegression with class_weight="balanced": - The original setup flagged "may fail" on ~20% of inputs when real BERT failures only happen ~4% of the time (precision 0.16 on the failure class -- 84% of warnings were false alarms). - class_weight="balanced" overcorrects for that 96/4 imbalance and makes the model trigger-happy instead of well-calibrated. - A calibrated classifier with a threshold tuned on a held-out validation set (rather than the default 0.5 cutoff) gives a controllable precision/ recall tradeoff instead of an arbitrary one. - RandomForest captures the (modest) nonlinear signal in the disagreement/ confidence features better than linear LR for this problem, based on head-to-head comparison (see notes in README/commit message). This script: 1. Splits data into train / val / test (not just train / test). 2. Trains RandomForest wrapped in CalibratedClassifierCV (sigmoid/Platt scaling) so predict_proba outputs are meaningful probabilities. 3. Picks a decision threshold on the validation set that maximizes F1 (a balanced precision/recall tradeoff), then locks it in. 4. Reports honest metrics on the untouched test set. 5. Saves both the model and the chosen threshold so serving code (src/predict.py) doesn't hardcode 0.5. """ import json import numpy as np import pandas as pd import joblib from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.calibration import CalibratedClassifierCV from sklearn.metrics import classification_report, precision_recall_curve from src.config import ( BASE_DATASET_PATH, META_MODEL_PATH, META_THRESHOLD_PATH, METRICS_PATH ) # Load dataset df = pd.read_csv(BASE_DATASET_PATH) # Meta-features feature_columns = [ "vader_pred", "vader_score", "lr_pred", "lr_confidence", "bert_pred", "bert_confidence", "bert_entropy", "vader_lr_disagreement", "lr_bert_disagreement", "vader_bert_disagreement", "has_negative_intensifier" ] X = df[feature_columns] # Target: did the transformer's prediction not match the real label y = df["bert_failed"] # 3-way split: train (60%) / val (20%) / test (20%). # val is used ONLY for threshold tuning, test is touched only at the end, # so the reported metrics aren't optimistic. X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.4, random_state=42, stratify=y ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42, stratify=y_temp ) # ---------------- Meta-model: calibrated RandomForest ---------------- base_model = RandomForestClassifier( n_estimators=200, max_depth=5, random_state=42 ) meta_model = CalibratedClassifierCV( base_model, method="sigmoid", cv=5 ) meta_model.fit(X_train, y_train) # ---------------- Threshold tuning on validation set ---------------- val_probs = meta_model.predict_proba(X_val)[:, 1] precision, recall, thresholds = precision_recall_curve(y_val, val_probs) f1_scores = 2 * precision * recall / (precision + recall + 1e-12) # precision_recall_curve returns one more point than thresholds (the last # point is precision=1, recall=0 with no corresponding threshold), so drop it best_idx = np.argmax(f1_scores[:-1]) best_threshold = thresholds[best_idx] print(f"Chosen threshold (max F1 on validation set): {best_threshold:.4f}") print( f"At that threshold on validation -> " f"precision: {precision[best_idx]:.3f}, " f"recall: {recall[best_idx]:.3f}, " f"f1: {f1_scores[best_idx]:.3f}" ) # ---------------- Honest evaluation on held-out test set ---------------- test_probs = meta_model.predict_proba(X_test)[:, 1] test_preds = (test_probs >= best_threshold).astype(int) print("\nTest set classification report (threshold applied):\n") print(classification_report( y_test, test_preds, target_names=["No Failure", "Failure"] )) # ---------------- Export metrics + PR curve for the web UI ---------------- # # The frontend needs real numbers to render the precision-recall curve and # the headline stats -- rather than hand-typing values into a template # (which is how the old UI ended up showing fabricated 0.87/0.93 numbers # that didn't match the actual model), we compute everything here, on the # untouched test set, and write it to a small JSON file that app.py reads # at startup. Retraining the model automatically updates what the UI shows. test_precision, test_recall, test_pr_thresholds = precision_recall_curve( y_test, test_probs ) # Downsample the curve to ~50 points for a lightweight chart -- the full # curve can have hundreds of points (one per unique probability value), # which is overkill for a UI chart and bloats the JSON for no visual gain. n_points = len(test_precision) if n_points > 50: sample_idx = np.linspace(0, n_points - 1, 50).astype(int) else: sample_idx = np.arange(n_points) pr_curve_points = [ { "precision": round(float(test_precision[i]), 4), "recall": round(float(test_recall[i]), 4) } for i in sample_idx ] test_precision_at_threshold = float( (test_preds[y_test == 1] == 1).sum() / max(test_preds.sum(), 1) ) test_recall_at_threshold = float( (test_preds[y_test == 1] == 1).sum() / max((y_test == 1).sum(), 1) ) test_f1_at_threshold = ( 2 * test_precision_at_threshold * test_recall_at_threshold / max(test_precision_at_threshold + test_recall_at_threshold, 1e-12) ) metrics_export = { "threshold": round(float(best_threshold), 4), "test_set_size": int(len(y_test)), "test_failure_rate": round(float(y_test.mean()), 4), "precision": round(test_precision_at_threshold, 4), "recall": round(test_recall_at_threshold, 4), "f1": round(test_f1_at_threshold, 4), "pr_curve": pr_curve_points } with open(METRICS_PATH, "w") as f: json.dump(metrics_export, f, indent=2) print(f"\nMetrics + PR curve exported to {METRICS_PATH}") # ---------------- Save model + threshold ---------------- joblib.dump(meta_model, META_MODEL_PATH) joblib.dump(best_threshold, META_THRESHOLD_PATH) print(f"\nMeta-model saved to {META_MODEL_PATH}") print(f"Decision threshold saved to {META_THRESHOLD_PATH}")