"""Train and evaluate meta-classifiers over base detector scores. Loads the score CSV produced by scripts/run_inference.py, performs a stratified 60/20/20 split on (domain, generator, label), trains three meta-classifier variants (logistic regression, XGBoost, small MLP), and reports the evaluation metrics from Section 3.6 of the dissertation: accuracy, macro-F1, AUROC, FPR at TPR=0.95 and TPR=0.99, and Expected Calibration Error. Outputs: outputs/metrics_summary.csv -- one row per system, all metrics outputs/per_domain.csv -- breakdown by domain outputs/per_generator.csv -- breakdown by generator outputs/predictions_test.csv -- raw test-set predictions for plotting outputs/models/*.pkl -- saved trained models """ from __future__ import annotations import argparse import pickle from pathlib import Path import numpy as np import pandas as pd import xgboost as xgb from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( accuracy_score, f1_score, roc_auc_score, roc_curve, ) from sklearn.model_selection import train_test_split from sklearn.neural_network import MLPClassifier from sklearn.preprocessing import StandardScaler # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- def fpr_at_tpr(y_true: np.ndarray, y_score: np.ndarray, target_tpr: float) -> float: """Return the false positive rate at a fixed true positive rate.""" fpr, tpr, _ = roc_curve(y_true, y_score) # Find smallest threshold that achieves at least target_tpr. idx = np.searchsorted(tpr, target_tpr, side="left") if idx >= len(fpr): idx = len(fpr) - 1 return float(fpr[idx]) def expected_calibration_error(y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 15) -> float: """Compute ECE with equal-width probability bins.""" bins = np.linspace(0.0, 1.0, n_bins + 1) bin_ids = np.digitize(y_prob, bins) - 1 bin_ids = np.clip(bin_ids, 0, n_bins - 1) ece = 0.0 n = len(y_true) for b in range(n_bins): mask = bin_ids == b if not mask.any(): continue bin_acc = y_true[mask].mean() bin_conf = y_prob[mask].mean() ece += (mask.sum() / n) * abs(bin_acc - bin_conf) return float(ece) def all_metrics(y_true: np.ndarray, y_prob: np.ndarray, threshold: float = 0.5) -> dict[str, float]: """Compute the full set of metrics from Section 3.6.""" y_pred = (y_prob >= threshold).astype(int) return { "accuracy": float(accuracy_score(y_true, y_pred)), "f1_macro": float(f1_score(y_true, y_pred, average="macro")), "auroc": float(roc_auc_score(y_true, y_prob)), "fpr_at_tpr_95": fpr_at_tpr(y_true, y_prob, 0.95), "fpr_at_tpr_99": fpr_at_tpr(y_true, y_prob, 0.99), "ece": expected_calibration_error(y_true, y_prob), } # --------------------------------------------------------------------------- # Data loading and splitting # --------------------------------------------------------------------------- def load_scores(path: str) -> pd.DataFrame: df = pd.read_csv(path) # Drop rows where any detector failed (None -> NaN). before = len(df) df = df.dropna(subset=["binoculars", "fast_detect_gpt", "roberta"]).reset_index(drop=True) after = len(df) if before != after: print(f"Dropped {before - after} rows with NaN scores. {after} remain.") return df def stratified_three_way_split( df: pd.DataFrame, test_frac: float = 0.20, val_frac: float = 0.20, seed: int = 42, ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """60/20/20 split stratified on (domain, generator, label). Sparsely populated joint strata (<3 samples) get merged into the dominant stratum for that (domain, label) pair so each stratum can appear in all splits. """ df = df.copy() df["stratum"] = ( df["domain"].astype(str) + "_" + df["generator"].astype(str) + "_" + df["label"].astype(str) ) counts = df["stratum"].value_counts() sparse = counts[counts < 3].index df.loc[df["stratum"].isin(sparse), "stratum"] = ( df.loc[df["stratum"].isin(sparse), "domain"].astype(str) + "_merged_" + df.loc[df["stratum"].isin(sparse), "label"].astype(str) ) train_val, test = train_test_split( df, test_size=test_frac, stratify=df["stratum"], random_state=seed ) val_relative = val_frac / (1.0 - test_frac) train, val = train_test_split( train_val, test_size=val_relative, stratify=train_val["stratum"], random_state=seed ) return ( train.reset_index(drop=True), val.reset_index(drop=True), test.reset_index(drop=True), ) # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- FEATURES = ["binoculars", "fast_detect_gpt", "roberta"] def train_logistic(X_train, y_train) -> LogisticRegression: model = LogisticRegression(max_iter=1000, random_state=42) model.fit(X_train, y_train) return model def train_xgboost(X_train, y_train, X_val, y_val) -> xgb.XGBClassifier: model = xgb.XGBClassifier( n_estimators=300, max_depth=4, learning_rate=0.1, objective="binary:logistic", eval_metric="logloss", random_state=42, early_stopping_rounds=20, ) model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False) return model def train_mlp(X_train, y_train) -> MLPClassifier: model = MLPClassifier( hidden_layer_sizes=(32, 32), activation="relu", solver="adam", max_iter=500, early_stopping=True, validation_fraction=0.15, random_state=42, ) model.fit(X_train, y_train) return model # --------------------------------------------------------------------------- # Evaluation orchestration # --------------------------------------------------------------------------- def evaluate_system(name: str, y_true: np.ndarray, y_prob: np.ndarray) -> dict: metrics = all_metrics(y_true, y_prob) metrics["system"] = name return metrics def per_group_metrics( df_test: pd.DataFrame, y_prob: np.ndarray, group_col: str, system_name: str ) -> pd.DataFrame: rows = [] for value, group in df_test.groupby(group_col): y_true = group["label"].to_numpy() scores = y_prob[group.index] if len(np.unique(y_true)) < 2: # Need both classes present for AUROC/FPR. row = {"system": system_name, group_col: value, "n": len(group)} row.update({k: float("nan") for k in [ "accuracy", "f1_macro", "auroc", "fpr_at_tpr_95", "fpr_at_tpr_99", "ece" ]}) else: row = {"system": system_name, group_col: value, "n": len(group)} row.update(all_metrics(y_true, scores)) rows.append(row) return pd.DataFrame(rows) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--scores", type=str, default="data/raid_scores.csv") parser.add_argument("--outdir", type=str, default="outputs") parser.add_argument("--seed", type=int, default=42) args = parser.parse_args() outdir = Path(args.outdir) (outdir / "models").mkdir(parents=True, exist_ok=True) print(f"Loading {args.scores}...") df = load_scores(args.scores) print(f"Loaded {len(df)} samples.") print("\nSplitting 60/20/20 stratified on (domain, generator, label)...") train_df, val_df, test_df = stratified_three_way_split(df, seed=args.seed) print(f" train: {len(train_df)} | val: {len(val_df)} | test: {len(test_df)}") X_train = train_df[FEATURES].to_numpy() y_train = train_df["label"].to_numpy() X_val = val_df[FEATURES].to_numpy() y_val = val_df["label"].to_numpy() X_test = test_df[FEATURES].to_numpy() y_test = test_df["label"].to_numpy() # Per-detector standardisation using training distribution only. scaler = StandardScaler().fit(X_train) X_train_s = scaler.transform(X_train) X_val_s = scaler.transform(X_val) X_test_s = scaler.transform(X_test) # Save the scaler for reproducibility. with (outdir / "models" / "scaler.pkl").open("wb") as f: pickle.dump(scaler, f) summary_rows = [] per_domain_rows = [] per_generator_rows = [] test_predictions = test_df[["id", "domain", "generator", "label"]].copy() # ----- Base detectors as standalone systems ----- # For each base detector we treat its raw score as the prediction. We # min-max normalise to [0, 1] so it can be compared against the meta # classifier probabilities. Polarity differs across detectors: # - Binoculars: high = human (label 0). Invert. # - Fast-DetectGPT: high = AI. Keep as-is. # - RoBERTa: P(AI). Keep as-is. base_score_funcs = { "binoculars_solo": lambda x: 1.0 - _minmax(x[:, 0], train_df["binoculars"]), "fast_detect_gpt_solo": lambda x: _minmax(x[:, 1], train_df["fast_detect_gpt"]), "roberta_solo": lambda x: x[:, 2], # already 0-1 } for name, fn in base_score_funcs.items(): probs = fn(X_test) summary_rows.append(evaluate_system(name, y_test, probs)) test_predictions[name] = probs per_domain_rows.append(per_group_metrics(test_df, probs, "domain", name)) per_generator_rows.append(per_group_metrics(test_df, probs, "generator", name)) print(f" {name}: AUROC={summary_rows[-1]['auroc']:.4f} " f"FPR@TPR95={summary_rows[-1]['fpr_at_tpr_95']:.4f}") # ----- Meta classifiers ----- print("\nTraining logistic regression...") lr = train_logistic(X_train_s, y_train) lr_prob = lr.predict_proba(X_test_s)[:, 1] summary_rows.append(evaluate_system("meta_logistic", y_test, lr_prob)) test_predictions["meta_logistic"] = lr_prob per_domain_rows.append(per_group_metrics(test_df, lr_prob, "domain", "meta_logistic")) per_generator_rows.append(per_group_metrics(test_df, lr_prob, "generator", "meta_logistic")) with (outdir / "models" / "logistic.pkl").open("wb") as f: pickle.dump(lr, f) print(f" meta_logistic: AUROC={summary_rows[-1]['auroc']:.4f} " f"FPR@TPR95={summary_rows[-1]['fpr_at_tpr_95']:.4f}") print(f" coefficients: {dict(zip(FEATURES, lr.coef_[0].tolist()))}") print("\nTraining XGBoost (with early stopping on val)...") xgb_model = train_xgboost(X_train_s, y_train, X_val_s, y_val) xgb_prob = xgb_model.predict_proba(X_test_s)[:, 1] summary_rows.append(evaluate_system("meta_xgboost", y_test, xgb_prob)) test_predictions["meta_xgboost"] = xgb_prob per_domain_rows.append(per_group_metrics(test_df, xgb_prob, "domain", "meta_xgboost")) per_generator_rows.append(per_group_metrics(test_df, xgb_prob, "generator", "meta_xgboost")) with (outdir / "models" / "xgboost.pkl").open("wb") as f: pickle.dump(xgb_model, f) print(f" meta_xgboost: AUROC={summary_rows[-1]['auroc']:.4f} " f"FPR@TPR95={summary_rows[-1]['fpr_at_tpr_95']:.4f}") print("\nTraining MLP...") mlp = train_mlp(X_train_s, y_train) mlp_prob = mlp.predict_proba(X_test_s)[:, 1] summary_rows.append(evaluate_system("meta_mlp", y_test, mlp_prob)) test_predictions["meta_mlp"] = mlp_prob per_domain_rows.append(per_group_metrics(test_df, mlp_prob, "domain", "meta_mlp")) per_generator_rows.append(per_group_metrics(test_df, mlp_prob, "generator", "meta_mlp")) with (outdir / "models" / "mlp.pkl").open("wb") as f: pickle.dump(mlp, f) print(f" meta_mlp: AUROC={summary_rows[-1]['auroc']:.4f} " f"FPR@TPR95={summary_rows[-1]['fpr_at_tpr_95']:.4f}") # ----- Save outputs ----- pd.DataFrame(summary_rows).to_csv(outdir / "metrics_summary.csv", index=False) pd.concat(per_domain_rows).to_csv(outdir / "per_domain.csv", index=False) pd.concat(per_generator_rows).to_csv(outdir / "per_generator.csv", index=False) test_predictions.to_csv(outdir / "predictions_test.csv", index=False) print(f"\nSaved metrics summary to {outdir / 'metrics_summary.csv'}") print(f"Saved per-domain to {outdir / 'per_domain.csv'}") print(f"Saved per-generator to {outdir / 'per_generator.csv'}") print(f"Saved test predictions to {outdir / 'predictions_test.csv'}") print(f"Saved models to {outdir / 'models/'}") print("\n=== SUMMARY (test set) ===") print(pd.DataFrame(summary_rows)[["system", "auroc", "fpr_at_tpr_95", "fpr_at_tpr_99", "f1_macro", "ece"]] .to_string(index=False, float_format=lambda x: f"{x:.4f}")) def _minmax(values: np.ndarray, train_values: pd.Series) -> np.ndarray: """Min-max normalise values to [0, 1] using the training distribution.""" lo = float(train_values.min()) hi = float(train_values.max()) if hi - lo < 1e-12: return np.zeros_like(values, dtype=float) out = (values - lo) / (hi - lo) return np.clip(out, 0.0, 1.0) if __name__ == "__main__": main()