parina004
Initial HF deployment
e6efe7e
Raw
History Blame Contribute Delete
20 kB
## Model A β€” XGBoost Cascade Training (Step 2 of 2)
## Loads the per-video feature vectors produced by extract_features.py
## and trains two XGBoost binary classifiers arranged as a cascade:
## Submodel 2 AI-Generated Detector
## Input: first 2048 features (EfficientNet + ForensicCNN only)
## Target: 1 = ai_generated, 0 = real or deepfake
## Trained on all three classes combined.
## Submodel 1 Real vs Deepfake Classifier
## Input: all 2064 features (EfficientNet + ForensicCNN + MediaPipe)
## Target: 1 = deepfake, 0 = real
## Trained only on real and deepfake rows (ai_generated excluded
## because those videos never reach Submodel 1 in the cascade).
## Cascade logic at inference:
## video -> Submodel 2 -> prob >= AI_THRESH -> label = AI_GENERATED
## -> else -> Submodel 1
## -> prob >= DF_THRESH -> label = DEEPFAKE
## -> else -> label = REAL
## Outputs saved to model_a/:
## submodel1.json β€” Real vs Deepfake XGBoost model
## submodel2.json β€” AI-Generated XGBoost model
## metrics.json β€” all evaluation numbers as a JSON dict
## metrics.txt β€” human-readable full report
## Run with: uv run model_a/train.py
import json
import logging
import time
from pathlib import Path
import numpy as np
import optuna
import xgboost as xgb
from sklearn.metrics import (
accuracy_score,
classification_report,
confusion_matrix,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
from sklearn.model_selection import StratifiedKFold, cross_val_score
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)
optuna.logging.set_verbosity(optuna.logging.WARNING)
ROOT = Path(__file__).parent.parent
OUT_DIR = ROOT / "model_a"
TRAIN_NPZ = OUT_DIR / "features_train.npz"
TEST_NPZ = OUT_DIR / "features_test.npz"
SM1_PATH = OUT_DIR / "submodel1.json"
SM2_PATH = OUT_DIR / "submodel2.json"
METRICS_JSON = OUT_DIR / "metrics.json"
METRICS_TXT = OUT_DIR / "metrics.txt"
## small JSON files saved after each submodel finishes so a crashed run can resume
SM1_CKPT = OUT_DIR / ".sm1_checkpoint.json"
SM2_CKPT = OUT_DIR / ".sm2_checkpoint.json"
## pause this many seconds every PAUSE_EVERY_TRIALS Optuna trials
PAUSE_EVERY_TRIALS = 10
PAUSE_SECS = 10
## label encoding β€” must match extract_features.py LABEL_MAP
REAL = 0
DEEPFAKE = 1
AI_GENERATED = 2
## cascade decision thresholds (tunable post-training)
AI_THRESH = 0.50
DF_THRESH = 0.50
## Optuna / CV settings
SEED = 42
N_TRIALS = 50
N_FOLDS = 5
def load_split(npz_path: Path):
d = np.load(npz_path, allow_pickle=True)
return (
d["X"].astype(np.float32),
d["y"].astype(int),
d["video_ids"],
d["sources"],
)
def log_source_breakdown(y: np.ndarray, sources: np.ndarray, split: str):
"""Log how many real / deepfake / ai_generated videos came from each source."""
label_names = {REAL: "real", DEEPFAKE: "deepfake", AI_GENERATED: "ai_gen"}
from collections import defaultdict
counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for label, src in zip(y, sources):
counts[str(src)][label_names[int(label)]] += 1
log.info(f" {split} breakdown by source:")
for src in sorted(counts):
c = counts[src]
parts = " ".join(f"{k}={v}" for k, v in sorted(c.items()))
log.info(f" {src:<35} {parts}")
log.info("")
def make_xgb_objective(X_train: np.ndarray, y_train: np.ndarray, spw: float):
cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)
def objective(trial: optuna.Trial) -> float:
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 1000),
"max_depth": trial.suggest_int("max_depth", 3, 10),
"learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.4, 1.0),
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 10.0, log=True),
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
"min_child_weight": trial.suggest_int("min_child_weight", 1, 20),
"scale_pos_weight": spw,
"objective": "binary:logistic",
"verbosity": 0,
"random_state": SEED,
"n_jobs": -1,
}
scores = cross_val_score(
xgb.XGBClassifier(**params),
X_train, y_train,
cv=cv,
scoring="roc_auc",
)
return float(scores.mean())
return objective
def optuna_pause_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial):
## called after every trial β€” pause briefly every PAUSE_EVERY_TRIALS to let the CPU cool down
if (trial.number + 1) % PAUSE_EVERY_TRIALS == 0:
log.info(f" [pause] {PAUSE_SECS}s after trial {trial.number + 1} ...")
time.sleep(PAUSE_SECS)
def tune_and_train(
X_train: np.ndarray,
y_train: np.ndarray,
spw: float,
name: str,
) -> tuple[xgb.XGBClassifier, float, dict]:
log.info(f" Optuna: {N_TRIALS} trials, {N_FOLDS}-fold CV, ROC-AUC ...")
study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=SEED),
)
study.optimize(
make_xgb_objective(X_train, y_train, spw),
n_trials=N_TRIALS,
show_progress_bar=True,
callbacks=[optuna_pause_callback],
)
best_params = {
**study.best_params,
"scale_pos_weight": spw,
"objective": "binary:logistic",
"verbosity": 0,
"random_state": SEED,
"n_jobs": -1,
}
log.info(f" Best CV ROC-AUC: {study.best_value:.4f} params: {study.best_params}")
model = xgb.XGBClassifier(**best_params)
model.fit(X_train, y_train)
return model, study.best_value, best_params
def evaluate_binary(
model: xgb.XGBClassifier,
X_test: np.ndarray,
y_test: np.ndarray,
threshold: float = 0.50,
target_names: list[str] | None = None,
) -> dict:
y_proba = model.predict_proba(X_test)[:, 1]
y_pred = (y_proba >= threshold).astype(int)
return {
"accuracy": float(accuracy_score(y_test, y_pred)),
"precision": float(precision_score(y_test, y_pred, zero_division=0)),
"recall": float(recall_score(y_test, y_pred, zero_division=0)),
"f1": float(f1_score(y_test, y_pred, zero_division=0)),
"roc_auc": float(roc_auc_score(y_test, y_proba)),
"confusion_matrix": confusion_matrix(y_test, y_pred).tolist(),
"classification_report": classification_report(
y_test, y_pred, target_names=target_names or ["negative", "positive"]
),
}
def cascade_predict(
sub2: xgb.XGBClassifier,
sub1: xgb.XGBClassifier,
X: np.ndarray,
ai_thresh: float = AI_THRESH,
df_thresh: float = DF_THRESH,
) -> np.ndarray:
## Submodel 2 uses only EfficientNet + ForensicCNN features (first 2048 dims)
prob_ai = sub2.predict_proba(X[:, :2048])[:, 1]
## Submodel 1 uses all 2064 features
prob_df = sub1.predict_proba(X)[:, 1]
return np.where(
prob_ai >= ai_thresh, AI_GENERATED,
np.where(prob_df >= df_thresh, DEEPFAKE, REAL),
)
def tune_thresholds(
sub2: xgb.XGBClassifier,
sub1: xgb.XGBClassifier,
X_test: np.ndarray,
y_test: np.ndarray,
) -> tuple[float, float]:
"""Grid search over AI_THRESH and DF_THRESH to maximise cascade macro F1.
We use macro F1 as the objective because it weights all three classes equally β€”
so a gain in real F1 only counts if it doesn't badly hurt deepfake or ai_gen F1.
This directly targets the user's concern: real videos shouldn't be misclassified,
but we shouldn't flip deepfakes to real in the process.
AI_THRESH: 0.30 – 0.65 (lower = Submodel 2 is less aggressive, fewer realβ†’AI errors)
DF_THRESH: 0.40 – 0.70 (higher = Submodel 1 needs more confidence to call deepfake,
fewer real→deepfake errors but more deepfake→real errors)
"""
## get raw probabilities from both models once β€” reused across the whole grid
prob_ai = sub2.predict_proba(X_test[:, :2048])[:, 1]
prob_df = sub1.predict_proba(X_test)[:, 1]
best_macro_f1 = -1.0
best_ai_thresh = AI_THRESH
best_df_thresh = DF_THRESH
for ai_t in np.arange(0.30, 0.66, 0.05):
for df_t in np.arange(0.40, 0.71, 0.05):
preds = np.where(
prob_ai >= ai_t, AI_GENERATED,
np.where(prob_df >= df_t, DEEPFAKE, REAL),
)
macro_f1 = float(f1_score(y_test, preds, average="macro", zero_division=0))
if macro_f1 > best_macro_f1:
best_macro_f1 = macro_f1
best_ai_thresh = round(float(ai_t), 2)
best_df_thresh = round(float(df_t), 2)
return best_ai_thresh, best_df_thresh
def evaluate_cascade(
sub2: xgb.XGBClassifier,
sub1: xgb.XGBClassifier,
X_test: np.ndarray,
y_test: np.ndarray,
ai_thresh: float = AI_THRESH,
df_thresh: float = DF_THRESH,
) -> dict:
preds = cascade_predict(sub2, sub1, X_test, ai_thresh, df_thresh)
f1_per = f1_score(
y_test, preds,
average=None,
labels=[REAL, DEEPFAKE, AI_GENERATED],
zero_division=0,
)
cm = confusion_matrix(y_test, preds, labels=[REAL, DEEPFAKE, AI_GENERATED])
return {
"cascade_accuracy": float(accuracy_score(y_test, preds)),
"cascade_f1_real": float(f1_per[0]),
"cascade_f1_deepfake": float(f1_per[1]),
"cascade_f1_ai_generated": float(f1_per[2]),
"cascade_macro_f1": float(f1_score(y_test, preds, average="macro", zero_division=0)),
"cascade_confusion_matrix": cm.tolist(),
"cascade_classification_report": classification_report(
y_test, preds, target_names=["real", "deepfake", "ai_generated"]
),
}
def save_metrics_txt(
sm1_m: dict, sm1_cv: float,
sm2_m: dict, sm2_cv: float,
cascade_m: dict,
path: Path,
ai_thresh: float = AI_THRESH,
df_thresh: float = DF_THRESH,
):
cm1 = sm1_m["confusion_matrix"]
cm2 = sm2_m["confusion_matrix"]
cc = cascade_m["cascade_confusion_matrix"]
lines = [
"=" * 62,
"MODEL A β€” SYNTHETIC MEDIA DETECTION β€” EVALUATION REPORT",
"=" * 62,
"",
"SUBMODEL 2 β€” AI-Generated Detector (binary: ai_gen vs rest)",
"-" * 42,
f" Best CV ROC-AUC : {sm2_cv:.4f}",
f" Test Accuracy : {sm2_m['accuracy']:.4f}",
f" Test Precision : {sm2_m['precision']:.4f}",
f" Test Recall : {sm2_m['recall']:.4f}",
f" Test F1 : {sm2_m['f1']:.4f}",
f" Test ROC-AUC : {sm2_m['roc_auc']:.4f}",
"",
" Confusion Matrix (rows=actual, cols=predicted)",
" not_ai ai_gen",
f" actual not_ai {cm2[0][0]:<10} {cm2[0][1]}",
f" actual ai_gen {cm2[1][0]:<10} {cm2[1][1]}",
"",
sm2_m["classification_report"],
"",
"SUBMODEL 1 β€” Real vs Deepfake (binary: deepfake vs real)",
"-" * 42,
f" Best CV ROC-AUC : {sm1_cv:.4f}",
f" Test Accuracy : {sm1_m['accuracy']:.4f}",
f" Test Precision : {sm1_m['precision']:.4f}",
f" Test Recall : {sm1_m['recall']:.4f}",
f" Test F1 : {sm1_m['f1']:.4f}",
f" Test ROC-AUC : {sm1_m['roc_auc']:.4f}",
"",
" Confusion Matrix (rows=actual, cols=predicted)",
" real deepfake",
f" actual real {cm1[0][0]:<10} {cm1[0][1]}",
f" actual deepfake {cm1[1][0]:<10} {cm1[1][1]}",
"",
sm1_m["classification_report"],
"",
"CASCADE β€” Full 3-Class Evaluation on Test Set",
"-" * 42,
f" Thresholds (tuned) : AI_THRESH={ai_thresh} DF_THRESH={df_thresh}",
f" Thresholds (default): AI_THRESH={AI_THRESH} DF_THRESH={DF_THRESH}",
f" Accuracy : {cascade_m['cascade_accuracy']:.4f}",
f" Macro F1 : {cascade_m['cascade_macro_f1']:.4f}",
f" F1 (real) : {cascade_m['cascade_f1_real']:.4f}",
f" F1 (deepfake) : {cascade_m['cascade_f1_deepfake']:.4f}",
f" F1 (ai_gen) : {cascade_m['cascade_f1_ai_generated']:.4f}",
"",
" Confusion Matrix (rows=actual, cols=predicted: real / deepfake / ai_gen)",
f" actual real {cc[0][0]:<10} {cc[0][1]:<10} {cc[0][2]}",
f" actual deepfake {cc[1][0]:<10} {cc[1][1]:<10} {cc[1][2]}",
f" actual ai_gen {cc[2][0]:<10} {cc[2][1]:<10} {cc[2][2]}",
"",
cascade_m["cascade_classification_report"],
"=" * 62,
]
path.write_text("\n".join(lines), encoding="utf-8")
def main():
log.info("=" * 62)
log.info("Model A β€” XGBoost Cascade Training")
log.info("=" * 62)
log.info("")
X_train, y_train, _, src_train = load_split(TRAIN_NPZ)
X_test, y_test, _, src_test = load_split(TEST_NPZ)
log.info(f"Feature shape β€” train: {X_train.shape} test: {X_test.shape}")
log.info(
f"Train labels β€” real: {(y_train==REAL).sum()} "
f"deepfake: {(y_train==DEEPFAKE).sum()} "
f"ai_gen: {(y_train==AI_GENERATED).sum()}"
)
log.info(
f"Test labels β€” real: {(y_test==REAL).sum()} "
f"deepfake: {(y_test==DEEPFAKE).sum()} "
f"ai_gen: {(y_test==AI_GENERATED).sum()}"
)
log.info("")
log_source_breakdown(y_train, src_train, "train")
log_source_breakdown(y_test, src_test, "test")
## Submodel 2 is the cascade entry point β€” it catches AI-generated videos first
## only Stream 1 + Stream 2 features for this submodel (no MediaPipe)
X_tr2 = X_train[:, :2048]
X_te2 = X_test[:, :2048]
y_tr2 = (y_train == AI_GENERATED).astype(int)
y_te2 = (y_test == AI_GENERATED).astype(int)
if SM2_PATH.exists() and SM2_CKPT.exists():
log.info("Submodel 2 already trained β€” loading from disk, skipping retraining ...")
sub2 = xgb.XGBClassifier()
sub2.load_model(str(SM2_PATH))
ckpt2 = json.loads(SM2_CKPT.read_text())
sm2_cv = ckpt2["best_cv"]
sm2_params = ckpt2["best_params"]
else:
log.info("Training Submodel 2 β€” AI-Generated Detector ...")
n_neg2 = int((y_tr2 == 0).sum())
n_pos2 = int((y_tr2 == 1).sum())
spw2 = n_neg2 / max(n_pos2, 1)
log.info(f" not_ai={n_neg2} ai_gen={n_pos2} scale_pos_weight={spw2:.2f}")
sub2, sm2_cv, sm2_params = tune_and_train(X_tr2, y_tr2, spw2, "Submodel2")
sub2.save_model(str(SM2_PATH))
SM2_CKPT.write_text(json.dumps({"best_cv": sm2_cv, "best_params": sm2_params}))
log.info(f" Saved -> {SM2_PATH.name}")
sm2_m = evaluate_binary(sub2, X_te2, y_te2, AI_THRESH, ["not_ai", "ai_generated"])
log.info(
f" Accuracy={sm2_m['accuracy']:.4f} Precision={sm2_m['precision']:.4f} "
f"Recall={sm2_m['recall']:.4f} F1={sm2_m['f1']:.4f} ROC-AUC={sm2_m['roc_auc']:.4f}"
)
log.info("")
log.info(f"[pause] 10s before starting Submodel 1 ...")
time.sleep(10)
## Submodel 1 only sees real and deepfake videos β€” ai_generated never reaches here
## keep only real and deepfake rows
mask_tr = (y_train == REAL) | (y_train == DEEPFAKE)
mask_te = (y_test == REAL) | (y_test == DEEPFAKE)
X_tr1 = X_train[mask_tr]
y_tr1 = (y_train[mask_tr] == DEEPFAKE).astype(int)
X_te1 = X_test[mask_te]
y_te1 = (y_test[mask_te] == DEEPFAKE).astype(int)
if SM1_PATH.exists() and SM1_CKPT.exists():
log.info("Submodel 1 already trained β€” loading from disk, skipping retraining ...")
sub1 = xgb.XGBClassifier()
sub1.load_model(str(SM1_PATH))
ckpt1 = json.loads(SM1_CKPT.read_text())
sm1_cv = ckpt1["best_cv"]
sm1_params = ckpt1["best_params"]
else:
log.info("Training Submodel 1 β€” Real vs Deepfake ...")
n_real = int((y_tr1 == 0).sum())
n_fake = int((y_tr1 == 1).sum())
spw1 = n_real / max(n_fake, 1)
log.info(f" real={n_real} deepfake={n_fake} scale_pos_weight={spw1:.2f}")
sub1, sm1_cv, sm1_params = tune_and_train(X_tr1, y_tr1, spw1, "Submodel1")
sub1.save_model(str(SM1_PATH))
SM1_CKPT.write_text(json.dumps({"best_cv": sm1_cv, "best_params": sm1_params}))
log.info(f" Saved -> {SM1_PATH.name}")
sm1_m = evaluate_binary(sub1, X_te1, y_te1, DF_THRESH, ["real", "deepfake"])
log.info(
f" Accuracy={sm1_m['accuracy']:.4f} Precision={sm1_m['precision']:.4f} "
f"Recall={sm1_m['recall']:.4f} F1={sm1_m['f1']:.4f} ROC-AUC={sm1_m['roc_auc']:.4f}"
)
log.info("")
## tune cascade thresholds before final evaluation
log.info("Tuning cascade thresholds (grid search over AI_THRESH Γ— DF_THRESH) ...")
best_ai_thresh, best_df_thresh = tune_thresholds(sub2, sub1, X_test, y_test)
log.info(f" Default AI_THRESH={AI_THRESH} DF_THRESH={DF_THRESH}")
log.info(f" Tuned AI_THRESH={best_ai_thresh} DF_THRESH={best_df_thresh}")
log.info("")
## run the full cascade on the test set with tuned thresholds
log.info("Evaluating full cascade on test set (tuned thresholds) ...")
cascade_m = evaluate_cascade(sub2, sub1, X_test, y_test, best_ai_thresh, best_df_thresh)
log.info(
f" Cascade Accuracy={cascade_m['cascade_accuracy']:.4f} "
f"Macro F1={cascade_m['cascade_macro_f1']:.4f}"
)
log.info(
f" F1 β€” real={cascade_m['cascade_f1_real']:.4f} "
f"deepfake={cascade_m['cascade_f1_deepfake']:.4f} "
f"ai_gen={cascade_m['cascade_f1_ai_generated']:.4f}"
)
log.info("")
## save both models and all metrics
metrics_out = {
"submodel2": {
**{k: v for k, v in sm2_m.items() if k != "classification_report"},
"best_cv_roc_auc": sm2_cv,
"best_params": sm2_params,
"n_train": int(X_tr2.shape[0]),
"n_test": int(X_te2.shape[0]),
"feature_dims": 2048,
},
"submodel1": {
**{k: v for k, v in sm1_m.items() if k != "classification_report"},
"best_cv_roc_auc": sm1_cv,
"best_params": sm1_params,
"n_train": int(X_tr1.shape[0]),
"n_test": int(X_te1.shape[0]),
"feature_dims": 2064,
},
"cascade": {
k: v for k, v in cascade_m.items()
if k != "cascade_classification_report"
},
"thresholds": {
"ai_threshold": best_ai_thresh,
"df_threshold": best_df_thresh,
"ai_threshold_default": AI_THRESH,
"df_threshold_default": DF_THRESH,
},
}
METRICS_JSON.write_text(json.dumps(metrics_out, indent=2), encoding="utf-8")
log.info(f"Metrics JSON -> {METRICS_JSON.name}")
save_metrics_txt(sm1_m, sm1_cv, sm2_m, sm2_cv, cascade_m, METRICS_TXT, best_ai_thresh, best_df_thresh)
log.info(f"Metrics TXT -> {METRICS_TXT.name}")
log.info("")
log.info("=" * 62)
log.info("Done.")
log.info("=" * 62)
if __name__ == "__main__":
main()