deepshield-api / backend /fusion /meta_learner.py
vanshpatil16's picture
DeepShield API β€” 7-detector image deepfake detection
6be0044
Raw
History Blame Contribute Delete
7.67 kB
"""
Meta-learner fusion β€” XGBoost (preferred) or Logistic Regression fallback.
XGBoost model: models/meta_learner/weights_xgb.json (XGBoost native format)
LR fallback: models/meta_learner/weights.json (custom JSON)
XGBoost is loaded when weights_xgb.json exists; otherwise falls back to LR.
Both expose the same .predict(results) β†’ (p_fake, details) interface.
"""
from __future__ import annotations
import json
import logging
import math
from pathlib import Path
from typing import Sequence
from backend.core.schema import DetectionResult
log = logging.getLogger(__name__)
_ROOT = Path(__file__).parent.parent.parent / "models" / "meta_learner"
_XGB_MODEL = _ROOT / "weights_xgb.json"
_XGB_META = _ROOT / "weights_xgb_meta.json"
_LR_WEIGHTS = _ROOT / "weights.json"
class MetaLearnerFusion:
"""
Unified meta-learner: loads XGBoost if available, else Logistic Regression.
Missing detector scores are imputed with 0.5 (neutral prior).
"""
def __init__(self, weights_path: str | Path | None = None) -> None:
self._loaded = False
self._mode = "none" # "xgboost" | "lr" | "none"
self._features: list[str] = []
self.loo_auc = 0.0
# XGBoost state
self._booster = None
# LR state
self._coef: list[float] = []
self._intercept: float = 0.0
self._mean: list[float] = []
self._std: list[float] = []
if weights_path:
# Explicit path β†’ assume LR format
self._load_lr(Path(weights_path))
else:
# Auto-select: prefer XGBoost
if _XGB_MODEL.exists():
self._load_xgb()
if not self._loaded and _LR_WEIGHTS.exists():
self._load_lr(_LR_WEIGHTS)
# ── Loaders ──────────────────────────────────────────────────────────────
def _load_xgb(self) -> None:
try:
import xgboost as xgb
except ImportError:
log.warning("meta_learner: xgboost not installed β€” falling back to LR")
return
try:
booster = xgb.Booster()
booster.load_model(str(_XGB_MODEL))
self._booster = booster
# Read feature list + AUC from metadata sidecar
if _XGB_META.exists():
meta = json.loads(_XGB_META.read_text())
self._features = meta.get("features", [])
self.loo_auc = float(meta.get("cv_auc", 0.0))
else:
# Fallback: read feature names from booster
self._features = booster.feature_names or []
self._mode = "xgboost"
self._loaded = True
log.info(
"meta_learner: XGBoost loaded features=%s cv_auc=%.3f",
self._features, self.loo_auc,
)
except Exception as exc:
log.warning("meta_learner: XGBoost load failed (%s) β€” trying LR", exc)
def _load_lr(self, path: Path) -> None:
try:
w = json.loads(path.read_text())
self._features = w["features"]
self._coef = [float(c) for c in w["coef"]]
self._intercept = float(w["intercept"])
self._mean = [float(m) for m in w["scaler_mean"]]
self._std = [float(s) for s in w["scaler_std"]]
self.loo_auc = float(w.get("loo_auc", 0.0))
self._mode = "lr"
self._loaded = True
log.info(
"meta_learner: LR loaded from %s features=%s LOO-AUC=%.3f",
path, self._features, self.loo_auc,
)
except Exception as exc:
log.warning("meta_learner: LR load failed (%s)", exc)
# ── Public API ────────────────────────────────────────────────────────────
@property
def is_ready(self) -> bool:
return self._loaded
def predict(
self,
results: Sequence[DetectionResult],
) -> tuple[float, dict]:
score_map = {
r.detector: float(r.p_fake)
for r in results
if r.error is None
}
raw_scores: list[float] = []
used: list[str] = []
imputed: list[str] = []
for name in self._features:
if name in score_map:
raw_scores.append(score_map[name])
used.append(name)
else:
raw_scores.append(0.5)
imputed.append(name)
if self._mode == "xgboost":
return self._predict_xgb(raw_scores, used, imputed)
else:
return self._predict_lr(raw_scores, used, imputed)
# ── XGBoost inference ─────────────────────────────────────────────────────
def _predict_xgb(
self,
raw_scores: list[float],
used: list[str],
imputed: list[str],
) -> tuple[float, dict]:
import numpy as np
import xgboost as xgb
X = np.array([raw_scores], dtype=np.float32)
dmat = xgb.DMatrix(X, feature_names=self._features)
p_fake = float(self._booster.predict(dmat)[0])
details = {
"method": "meta_learner_xgb",
"cv_auc": self.loo_auc,
"per_detector": {
name: {"raw_score": round(raw_scores[i], 4), "imputed": name in imputed}
for i, name in enumerate(self._features)
},
"used": used,
"imputed": imputed,
}
return p_fake, details
# ── LR inference ──────────────────────────────────────────────────────────
def _predict_lr(
self,
raw_scores: list[float],
used: list[str],
imputed: list[str],
) -> tuple[float, dict]:
z = [
(x - m) / max(s, 1e-9)
for x, m, s in zip(raw_scores, self._mean, self._std)
]
logit = self._intercept + sum(c * zi for c, zi in zip(self._coef, z))
logit = max(-15.0, min(15.0, logit))
p_fake = 1.0 / (1.0 + math.exp(-logit))
details = {
"method": "meta_learner_lr",
"loo_auc": self.loo_auc,
"logit": round(logit, 4),
"per_detector": {
name: {
"raw_score": round(raw_scores[i], 4),
"z": round(z[i], 4),
"coef": round(self._coef[i], 4),
"contribution": round(self._coef[i] * z[i], 4),
"imputed": name in imputed,
}
for i, name in enumerate(self._features)
},
"used": used,
"imputed": imputed,
}
return float(p_fake), details
# ── Module-level singleton ────────────────────────────────────────────────────
_instance: MetaLearnerFusion | None = None
def get_meta_learner() -> MetaLearnerFusion | None:
global _instance
if _instance is None:
_instance = MetaLearnerFusion()
return _instance if _instance.is_ready else None