deepshield-api / backend /fusion /ensemble.py
vanshpatil16's picture
fusion: double Sightengine's vote in voting strategy
5cdf8b0
Raw
History Blame Contribute Delete
15.2 kB
"""Ensemble fusion β€” combines DetectionResult objects into a final verdict.
Strategy:
1. Filter: drop failed/errored detectors (set confidence = 0 proxy).
2. Bayesian log-odds combination weighted by detector weights Γ— confidence.
3. Conflict detection: flag when high-confidence detectors strongly disagree.
4. Threshold: map final p_fake β†’ REAL / UNCERTAIN / AI_GENERATED.
Why Bayesian log-odds over simple weighted average:
- A detector that says 0.99 should dominate more than one saying 0.6.
- Log-odds is the correct way to combine independent probability estimates.
- We still weight by (detector_weight Γ— confidence) to account for
detector reliability and self-reported uncertainty.
"""
from __future__ import annotations
import math
from typing import Sequence
from backend.core.config import settings
from backend.core.schema import DetectionResult, EnsembleResult, Verdict
from backend.fusion.meta_learner import get_meta_learner
# Weight per detector β€” mirrors config but looked up at fusion time.
_WEIGHTS: dict[str, float] = {
"hive": settings.weight_hive,
"hive_vlm": settings.weight_hive_vlm,
"aiornot": settings.weight_aiornot,
"sightengine": settings.weight_sightengine,
"claude_vision": settings.weight_claude,
"mistral_vision": settings.weight_mistral,
"gpt4_vision": settings.weight_gpt4,
"gemini_vision": settings.weight_gemini,
"gemma_vision": settings.weight_gemma_vision,
"openrouter_gpt4": settings.weight_openrouter_gpt4,
"openrouter_llama": settings.weight_openrouter_llama,
"openrouter_mistral": settings.weight_openrouter_mistral,
"local_forensics": settings.weight_local_forensics,
"local_finetuned": settings.weight_local_finetuned,
"hybrid_model": settings.weight_hybrid_model,
# V3 detectors
"frequency_domain": settings.weight_frequency_domain,
"clip_unifd": settings.weight_clip_unifd,
"dire": settings.weight_dire,
"face_specialist": settings.weight_face_specialist,
# V4 detectors
"siglip2_aigc": settings.weight_siglip2_aigc,
"npr": settings.weight_npr,
}
# Per-detector vote multiplier for the voting strategy. Sightengine is the
# primary commercial signal, so its vote counts double.
_VOTE_WEIGHTS: dict[str, float] = {"sightengine": 2.0}
def _vote_weight(detector: str) -> float:
return _VOTE_WEIGHTS.get(detector, 1.0)
class EnsembleFusion:
def fuse(
self,
results: Sequence[DetectionResult],
processing_time_ms: float = 0.0,
fusion_strategy_override: str | None = None,
) -> EnsembleResult:
valid = [r for r in results if r.error is None and r.confidence > 0]
failed = [r for r in results if r.error is not None or r.confidence == 0]
if not valid:
return EnsembleResult(
verdict=Verdict.UNCERTAIN,
p_fake=0.5,
confidence=0.0,
primary_evidence="All detectors failed or were unavailable",
uncertainty_factors=[f"{r.detector}: {r.error}" for r in failed],
detector_results=list(results),
fusion_details={"method": "none"},
processing_time_ms=processing_time_ms,
)
# Choose fusion strategy: meta-learner β†’ per-request override β†’ server default
active_strategy = fusion_strategy_override or settings.fusion_strategy
ml = get_meta_learner() if settings.use_meta_learner_fusion else None
if ml is not None:
p_fake, method_details = ml.predict(valid)
elif active_strategy == "voting":
p_fake, method_details = self._voting_fusion(valid)
else:
p_fake, method_details = self._bayesian_fusion(valid)
verdict = _verdict_from_p_fake(p_fake, detector_results=valid)
conflicts = self._detect_conflicts(valid)
raw_conf = abs(p_fake - 0.5) * 2.0
conflict_penalty = 0.15 * len(conflicts)
confidence = float(max(0.0, min(1.0, raw_conf - conflict_penalty)))
primary_evidence, supporting = self._summarise_evidence(valid, verdict)
uncertainty_factors = conflicts[:]
if failed:
uncertainty_factors.append(
f"Unavailable detectors: {', '.join(r.detector for r in failed)}"
)
# Extract diffusion_suspicion from local_forensics raw output
diffusion_suspicion = 0.0
for r in results:
if r.detector == "local_forensics" and r.raw:
diffusion_suspicion = float(r.raw.get("diffusion_suspicion", 0.0))
break
# Propagate generator name from whichever detector identified it
generator = next(
(r.generator for r in results if r.generator),
None,
)
return EnsembleResult(
verdict=verdict,
p_fake=p_fake,
confidence=confidence,
primary_evidence=primary_evidence,
supporting_evidence=supporting,
uncertainty_factors=uncertainty_factors,
detector_results=list(results),
fusion_details={
"method": "meta_learner" if ml else active_strategy,
**method_details,
"n_valid": len(valid),
"n_failed": len(failed),
},
processing_time_ms=processing_time_ms,
diffusion_suspicion=diffusion_suspicion,
generator=generator,
)
# ── Bayesian log-odds fusion ────────────────────────────────
def _bayesian_fusion(
self, valid: list[DetectionResult]
) -> tuple[float, dict]:
"""
Combine independent P(fake) estimates via log-odds.
log_odds_posterior = log_odds_prior + Ξ£ w_i * log_odds_i
Prior: 0.5 (no prior knowledge).
"""
prior_log_odds = 0.0
total_weight = 0.0
weighted_log_odds = 0.0
per_detector: dict[str, dict] = {}
for r in valid:
w = _WEIGHTS.get(r.detector, 1.0) * float(r.confidence)
# Dynamic weight adjustment based on generator detection
# If Sightengine detected a specific generator, boost its weight (30% boost)
if r.detector == "sightengine" and hasattr(r, "generator") and r.generator:
w *= 1.3 # 30% confidence boost when generator explicitly detected
weight_reason = f"generator detected ({r.generator})"
else:
weight_reason = "base weight"
p = float(r.p_fake)
p = max(0.01, min(0.99, p))
lo = math.log(p / (1.0 - p))
weighted_log_odds += w * lo
total_weight += w
per_detector[r.detector] = {
"p_fake": r.p_fake,
"confidence": r.confidence,
"weight": _WEIGHTS.get(r.detector, 1.0),
"effective_weight": w,
"log_odds": lo,
"weight_reason": weight_reason,
"generator": getattr(r, "generator", None),
}
if total_weight == 0:
return 0.5, {}
avg_log_odds = prior_log_odds + (weighted_log_odds / total_weight)
p_fake = 1.0 / (1.0 + math.exp(-avg_log_odds))
return float(p_fake), {
"total_weight": total_weight,
"avg_log_odds": avg_log_odds,
"per_detector": per_detector,
}
# ── Voting fusion ───────────────────────────────────────────
def _voting_fusion(
self, valid: list[DetectionResult]
) -> tuple[float, dict]:
"""
Each detector casts one vote based on its own verdict threshold.
p_fake is computed as fraction of AI votes, then sharpened so it
actually crosses the ai_threshold / real_threshold.
voting_threshold (default 0.5): fraction of votes needed to call AI.
"""
votes_ai = [r for r in valid if r.p_fake >= settings.ai_threshold]
votes_real = [r for r in valid if r.p_fake <= settings.real_threshold]
votes_uncertain = [
r for r in valid
if settings.real_threshold < r.p_fake < settings.ai_threshold
]
# Votes are weighted per detector (Sightengine counts double).
total_votes = sum(_vote_weight(r.detector) for r in valid)
ai_votes = sum(_vote_weight(r.detector) for r in votes_ai)
real_votes = sum(_vote_weight(r.detector) for r in votes_real)
ai_fraction = ai_votes / total_votes
# Map fraction β†’ p_fake in a way that crosses thresholds cleanly:
# If ai_fraction >= voting_threshold β†’ p_fake just above ai_threshold
# If ai_fraction == 0 β†’ p_fake just below real_threshold
# Otherwise β†’ UNCERTAIN midpoint
if ai_fraction >= settings.voting_threshold:
# Scale within [ai_threshold, 1.0] based on how overwhelming the vote is
p_fake = settings.ai_threshold + (1.0 - settings.ai_threshold) * ai_fraction
elif real_votes / total_votes >= settings.voting_threshold:
real_fraction = real_votes / total_votes
p_fake = settings.real_threshold * (1.0 - real_fraction)
else:
# Split vote β†’ UNCERTAIN: vote-weighted average of individual p_fakes
p_fake = sum(_vote_weight(r.detector) * r.p_fake for r in valid) / total_votes
return float(p_fake), {
"votes_ai": [r.detector for r in votes_ai],
"votes_real": [r.detector for r in votes_real],
"votes_uncertain": [r.detector for r in votes_uncertain],
"ai_fraction": ai_fraction,
"voting_threshold": settings.voting_threshold,
}
# ── Conflict detection ──────────────────────────────────────
def _detect_conflicts(self, valid: list[DetectionResult]) -> list[str]:
"""Flag when high-confidence detectors strongly disagree.
Also flags significant disagreement between hybrid model versions
(latest vs backup) which may indicate uncertain/ambiguous images.
"""
conflicts: list[str] = []
high_conf = [r for r in valid if r.confidence >= 0.6]
if len(high_conf) < 2:
return conflicts
ai_detectors = [r for r in high_conf if r.verdict == Verdict.AI_GENERATED]
real_detectors = [r for r in high_conf if r.verdict == Verdict.REAL]
if ai_detectors and real_detectors:
ai_names = ", ".join(r.detector for r in ai_detectors)
real_names = ", ".join(r.detector for r in real_detectors)
conflicts.append(
f"Detector disagreement: [{ai_names}] β†’ AI vs [{real_names}] β†’ REAL"
)
# Check for hybrid model version disagreement (both are "hybrid_model" detector)
# This catches cases where latest and backup models give conflicting signals
hybrid_results = [r for r in valid if r.detector == "hybrid_model"]
if len(hybrid_results) >= 2:
# Compare p_fake values across model versions
p_fakes = [float(r.p_fake) for r in hybrid_results]
p_fake_spread = max(p_fakes) - min(p_fakes)
# If models differ by >0.25 in p_fake, flag as uncertainty signal
# e.g., latest=0.48, backup=0.34 β†’ spread=0.14 (not flagged)
# e.g., latest=0.48, backup=0.65 β†’ spread=0.17 (not flagged, both >0.3)
# But if verdicts differ (one AI, one REAL), flag even with smaller spread
verdicts = [r.verdict for r in hybrid_results]
if len(set(verdicts)) > 1: # Different verdicts
conflicts.append(
f"Hybrid model version disagreement: "
f"{hybrid_results[0].detector} versions give conflicting signals "
f"(p_fake range: {min(p_fakes):.2f}-{max(p_fakes):.2f})"
)
return conflicts
# ── Evidence summary ────────────────────────────────────────
def _summarise_evidence(
self, valid: list[DetectionResult], verdict: Verdict
) -> tuple[str, list[str]]:
if verdict == Verdict.AI_GENERATED:
ranked = sorted(valid, key=lambda r: r.p_fake, reverse=True)
elif verdict == Verdict.REAL:
ranked = sorted(valid, key=lambda r: r.p_fake)
else:
ranked = sorted(valid, key=lambda r: r.confidence, reverse=True)
primary = ""
supporting: list[str] = []
for r in ranked:
if not r.evidence:
continue
first = r.evidence[0]
# Make verdict more explicit in evidence summary
if verdict == Verdict.REAL and r.verdict == Verdict.REAL:
summary = f"[{r.detector}] Real image: {(1-r.p_fake)*100:.1f}% confidence"
elif verdict == Verdict.AI_GENERATED and r.verdict == Verdict.AI_GENERATED:
summary = f"[{r.detector}] AI-generated: {r.p_fake*100:.1f}% confidence"
elif verdict == Verdict.MANIPULATED_DEEPFAKE and r.verdict == Verdict.MANIPULATED_DEEPFAKE:
summary = f"[{r.detector}] Face manipulation detected: {r.p_fake*100:.1f}% confidence"
else:
summary = f"[{r.detector}] {first}"
if not primary:
primary = summary
else:
supporting.append(summary)
if len(supporting) >= 4:
break
if not primary:
primary = f"Ensemble confidence: {(1-valid[0].p_fake)*100:.1f}% real from {len(valid)} detectors"
return primary, supporting
def _verdict_from_p_fake(
p_fake: float,
detector_results: list | None = None,
) -> Verdict:
"""
Map ensemble p_fake to a verdict.
If p_fake crosses the AI threshold AND the hybrid_model detector specifically
predicted MANIPULATED_DEEPFAKE with high confidence, return that more specific
verdict instead of the generic AI_GENERATED.
"""
if p_fake >= settings.ai_threshold:
# Check if the V1 model specifically identified a deepfake manipulation
if detector_results:
hybrid = next(
(r for r in detector_results
if r.detector == "hybrid_model" and r.error is None
and r.verdict == Verdict.MANIPULATED_DEEPFAKE
and r.confidence >= 0.5),
None,
)
if hybrid is not None:
return Verdict.MANIPULATED_DEEPFAKE
return Verdict.AI_GENERATED
if p_fake <= settings.real_threshold:
return Verdict.REAL
return Verdict.UNCERTAIN