Spaces:
Sleeping
Sleeping
| """ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |