Spaces:
Running
Running
Download app/services/inference_xai.py from Rthur2003/crowncode-backend: direct link, hf CLI and curl.
- Browser
- Download file 35.8 kB
-
https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/app/services/inference_xai.py
- Command line
-
hf download hf://spaces/Rthur2003/crowncode-backend/app/services/inference_xai.py
-
curl -L -o inference_xai.py https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/app/services/inference_xai.py
35.8 kB
| """ | |
| XAI (Explainable AI) inference service for AURIS. | |
| Loads the trained XGBoost classifier and produces rich predictions with: | |
| - Calibrated probability + confidence band | |
| - SHAP-based per-feature contributions | |
| - Population-level z-scores (where the sample sits vs training distribution) | |
| - Human-readable explanations per feature | |
| Designed to replace the legacy 3-scalar output with a full 49-feature | |
| explainable analysis that surfaces to the UI. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pickle | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| import numpy as np | |
| from .feature_extractor import AudioFeatures | |
| from .vocal_analyzer import VocalFeatures | |
| from .logging_config import get_logger | |
| logger = get_logger(__name__) | |
| _MODEL_DIR = Path(__file__).resolve().parents[2] / "models" | |
| _MODEL_PATH = _MODEL_DIR / "auris_classifier_v1.pkl" | |
| _SCALER_PATH = _MODEL_DIR / "feature_scaler_v1.pkl" | |
| _COLUMNS_PATH = _MODEL_DIR / "feature_columns_v1.json" | |
| _RESULTS_PATH = _MODEL_DIR / "training_results.json" | |
| _STATS_PATH = _MODEL_DIR / "feature_stats_v1.json" | |
| # All pkl models available for ensemble voting | |
| _ML_MODEL_FILES = { | |
| "Logistic Regression": _MODEL_DIR / "model_logistic_regression.pkl", | |
| "Random Forest": _MODEL_DIR / "model_random_forest.pkl", | |
| "Gradient Boosting": _MODEL_DIR / "model_gradient_boosting.pkl", | |
| "SVM (RBF)": _MODEL_DIR / "model_svm_rbf.pkl", | |
| "MLP Neural Network": _MODEL_DIR / "model_mlp_neural_network.pkl", | |
| "XGBoost": _MODEL_DIR / "model_xgboost.pkl", | |
| "LightGBM": _MODEL_DIR / "model_lightgbm.pkl", | |
| } | |
| _DL_MODEL_FILES = { | |
| "Deep MLP (512-256-128-64)": _MODEL_DIR / "model_dl_deep_mlp_512_256_128_64.pkl", | |
| "1D-CNN": _MODEL_DIR / "model_dl_1d_cnn.pkl", | |
| "Residual MLP (3 blocks)": _MODEL_DIR / "model_dl_residual_mlp_3_blocks.pkl", | |
| "Attention MLP": _MODEL_DIR / "model_dl_attention_mlp.pkl", | |
| } | |
| # ── Human-readable feature catalog ──────────────────────────────────────── | |
| # Maps raw feature names to user-facing description + category + direction | |
| # of influence ("high means AI-like" or "low means AI-like"). | |
| FEATURE_CATALOG: Dict[str, Dict[str, str]] = { | |
| "duration_sec": { | |
| "label": "Süre", | |
| "labelEn": "Duration", | |
| "category": "meta", | |
| "description": "Toplam ses uzunluğu. AI üretimler genelde sabit 30-60s uzunluklarda toplanır.", | |
| }, | |
| "sample_rate": { | |
| "label": "Örnekleme Hızı", | |
| "labelEn": "Sample Rate", | |
| "category": "meta", | |
| "description": "Sesin dijital çözünürlüğü.", | |
| }, | |
| "rms_energy": { | |
| "label": "Ortalama Enerji (RMS)", | |
| "labelEn": "RMS Energy", | |
| "category": "temporal", | |
| "description": "Ses yüksekliğinin ortalaması. AI üretimler sıklıkla abartılı kompresyonla yüksek ama düz enerji gösterir.", | |
| }, | |
| "rms_std": { | |
| "label": "Enerji Dalgalanması", | |
| "labelEn": "Energy Variability", | |
| "category": "temporal", | |
| "description": "Ses seviyesinin zamanla nasıl değiştiği. İnsan performanslarında doğal dalgalanma olur.", | |
| }, | |
| "rms_dynamic_range": { | |
| "label": "Dinamik Aralık", | |
| "labelEn": "Dynamic Range", | |
| "category": "temporal", | |
| "description": "En sessiz ile en yüksek bölüm arasındaki fark. Düşük değer AI/aşırı-mastering işareti.", | |
| }, | |
| "spectral_centroid_mean": { | |
| "label": "Spektral Merkez", | |
| "labelEn": "Spectral Centroid", | |
| "category": "spectral", | |
| "description": "Sesin parlaklık merkezi. Tutarsız değerler doğal enstrüman karakterini gösterir.", | |
| }, | |
| "spectral_centroid_std": { | |
| "label": "Parlaklık Oynaklığı", | |
| "labelEn": "Brightness Variability", | |
| "category": "spectral", | |
| "description": "Parlaklığın zamanla değişimi. AI modeller monoton kalır.", | |
| }, | |
| "spectral_flatness_mean": { | |
| "label": "Spektral Düzlük", | |
| "labelEn": "Spectral Flatness", | |
| "category": "spectral", | |
| "description": "Gürültü benzerliği. 0 = müzikal ton, 1 = beyaz gürültü. AI üretimler aşırı temiz veya aşırı gürültülü olabilir.", | |
| }, | |
| "spectral_flatness_std": { | |
| "label": "Düzlük Oynaklığı", | |
| "labelEn": "Flatness Variability", | |
| "category": "spectral", | |
| "description": "Spektral tekstür değişkenliği. Düşük = tekdüze (AI işareti).", | |
| }, | |
| "spectral_bandwidth_mean": { | |
| "label": "Spektral Bant Genişliği", | |
| "labelEn": "Spectral Bandwidth", | |
| "category": "spectral", | |
| "description": "Frekansların yayılımı. Dar bantlar AI synth'lerin özelliğidir.", | |
| }, | |
| "spectral_bandwidth_std": { | |
| "label": "Bant Oynaklığı", | |
| "labelEn": "Bandwidth Variability", | |
| "category": "spectral", | |
| "description": "Bant genişliğinin zamanla değişimi.", | |
| }, | |
| "spectral_rolloff_mean": { | |
| "label": "Spektral Rolloff", | |
| "labelEn": "Spectral Rolloff", | |
| "category": "spectral", | |
| "description": "Enerjinin %85'inin kapsadığı frekans. Yüksek frekans zenginliğinin göstergesi.", | |
| }, | |
| "spectral_rolloff_std": { | |
| "label": "Rolloff Oynaklığı", | |
| "labelEn": "Rolloff Variability", | |
| "category": "spectral", | |
| "description": "Rolloff'un zamanla değişimi.", | |
| }, | |
| "spectral_contrast_mean": { | |
| "label": "Spektral Kontrast", | |
| "labelEn": "Spectral Contrast", | |
| "category": "spectral", | |
| "description": "Tepe-vadi farkı. Zengin harmonikler insan performansını, düşük kontrast AI üretimi gösterir.", | |
| }, | |
| "spectral_contrast_std": { | |
| "label": "Kontrast Oynaklığı", | |
| "labelEn": "Contrast Variability", | |
| "category": "spectral", | |
| "description": "Kontrastın zamanla değişkenliği.", | |
| }, | |
| "mfcc_variance": { | |
| "label": "MFCC Varyansı", | |
| "labelEn": "MFCC Variance", | |
| "category": "timbre", | |
| "description": "Timbre (tınsal renk) çeşitliliği. Düşük = monoton tınlama, AI işareti.", | |
| }, | |
| "mfcc_delta_var": { | |
| "label": "MFCC Delta Varyansı", | |
| "labelEn": "MFCC Delta Variance", | |
| "category": "timbre", | |
| "description": "Timbre'deki değişim hızı.", | |
| }, | |
| "mfcc_delta2_var": { | |
| "label": "MFCC İvme Varyansı", | |
| "labelEn": "MFCC Acceleration Variance", | |
| "category": "timbre", | |
| "description": "Timbre ivmelenmesi — ani tekstür değişimleri.", | |
| }, | |
| "mel_flatness": { | |
| "label": "Mel Düzlüğü", | |
| "labelEn": "Mel Flatness", | |
| "category": "spectral", | |
| "description": "Mel-skalasında düzlük. İnsan kulağı hassasiyetiyle ağırlıklandırılmış.", | |
| }, | |
| "tempo_bpm": { | |
| "label": "Tempo (BPM)", | |
| "labelEn": "Tempo", | |
| "category": "rhythm", | |
| "description": "Dakikadaki vuruş. AI modelleri sıklıkla 120 BPM gibi yuvarlak değerlere takılır.", | |
| }, | |
| "tempo_stability": { | |
| "label": "Tempo Sabitliği", | |
| "labelEn": "Tempo Stability", | |
| "category": "rhythm", | |
| "description": "Vuruş aralığının standart sapması. Aşırı sabit tempo = AI işareti (insanlarda mikro-kayma olur).", | |
| }, | |
| "tempo_cv": { | |
| "label": "Tempo Varyasyon Katsayısı", | |
| "labelEn": "Tempo CV", | |
| "category": "rhythm", | |
| "description": "Normalize tempo değişkenliği.", | |
| }, | |
| "zero_crossing_rate": { | |
| "label": "Sıfır Geçiş Oranı", | |
| "labelEn": "Zero Crossing Rate", | |
| "category": "temporal", | |
| "description": "Sinyalin sıfırı geçme sıklığı. Gürültü seviyesi ve ses karakteri göstergesi.", | |
| }, | |
| "zero_crossing_std": { | |
| "label": "Sıfır Geçiş Oynaklığı", | |
| "labelEn": "ZCR Variability", | |
| "category": "temporal", | |
| "description": "Sıfır geçiş oranının zamanla değişimi.", | |
| }, | |
| "onset_strength_mean": { | |
| "label": "Onset Gücü", | |
| "labelEn": "Onset Strength", | |
| "category": "rhythm", | |
| "description": "Nota başlangıçlarının belirginliği. Düşük = sürekli drone (AI işareti).", | |
| }, | |
| "onset_strength_std": { | |
| "label": "Onset Oynaklığı", | |
| "labelEn": "Onset Variability", | |
| "category": "rhythm", | |
| "description": "Nota vurgu varyasyonu — dinamik performans işareti.", | |
| }, | |
| "beat_count": { | |
| "label": "Vuruş Sayısı", | |
| "labelEn": "Beat Count", | |
| "category": "rhythm", | |
| "description": "Tespit edilen toplam vuruş sayısı.", | |
| }, | |
| "chroma_entropy": { | |
| "label": "Kroma Entropisi", | |
| "labelEn": "Chroma Entropy", | |
| "category": "harmonic", | |
| "description": "12 nota sınıfı dağılımının rastgelelığı. Düşük = tek tonik takıntı (AI).", | |
| }, | |
| "chroma_std": { | |
| "label": "Kroma Varyansı", | |
| "labelEn": "Chroma Variance", | |
| "category": "harmonic", | |
| "description": "Pitch class dağılımının zaman varyansı.", | |
| }, | |
| "chroma_transition_rate": { | |
| "label": "Akor Geçiş Hızı", | |
| "labelEn": "Chord Transition Rate", | |
| "category": "harmonic", | |
| "description": "Pitch class değişim sıklığı. Düşük = basit/tekrarlı armoni (AI işareti).", | |
| }, | |
| "harmonic_ratio": { | |
| "label": "Harmonik Oran", | |
| "labelEn": "Harmonic Ratio", | |
| "category": "harmonic", | |
| "description": "Harmonik/(Harmonik+Perküsif) oranı. Aşırı harmonik = yapay, aşırı perküsif = gürültü.", | |
| }, | |
| "tonnetz_std": { | |
| "label": "Tonnetz Varyansı", | |
| "labelEn": "Tonnetz Variance", | |
| "category": "harmonic", | |
| "description": "Tonal merkez hareketi — akor ilerleyişi zenginliği.", | |
| }, | |
| "spectral_regularity": { | |
| "label": "Spektral Düzenlilik", | |
| "labelEn": "Spectral Regularity", | |
| "category": "composite", | |
| "description": "Birleşik spektral AI-skoru.", | |
| }, | |
| "temporal_patterns": { | |
| "label": "Zamansal Desenler", | |
| "labelEn": "Temporal Patterns", | |
| "category": "composite", | |
| "description": "Zamansal tekrar ve mikro-kayma birleşik skoru.", | |
| }, | |
| "harmonic_structure": { | |
| "label": "Harmonik Yapı", | |
| "labelEn": "Harmonic Structure", | |
| "category": "composite", | |
| "description": "Armonik karmaşıklık birleşik skoru.", | |
| }, | |
| "has_vocals": { | |
| "label": "Vokal Mevcut", | |
| "labelEn": "Has Vocals", | |
| "category": "vocal", | |
| "description": "Vokal tespit edildi mi?", | |
| }, | |
| "vocal_confidence": { | |
| "label": "Vokal Güveni", | |
| "labelEn": "Vocal Confidence", | |
| "category": "vocal", | |
| "description": "Vokal varlığı güven skoru.", | |
| }, | |
| "vocal_ai_score": { | |
| "label": "Vokal AI Skoru", | |
| "labelEn": "Vocal AI Score", | |
| "category": "vocal", | |
| "description": "Vokalin AI-olma olasılığı.", | |
| }, | |
| "pitch_stability_score": { | |
| "label": "Pitch Sabitliği", | |
| "labelEn": "Pitch Stability", | |
| "category": "vocal", | |
| "description": "Ton perdesinin sabitliği. AŞIRI sabit = AI (insanlarda doğal titreme olur).", | |
| }, | |
| "vibrato_regularity_score": { | |
| "label": "Vibrato Düzenliliği", | |
| "labelEn": "Vibrato Regularity", | |
| "category": "vocal", | |
| "description": "Vibrato'nun zamansal düzenliliği. Matematiksel düzen = AI, organik dalgalanma = insan.", | |
| }, | |
| "formant_consistency_score": { | |
| "label": "Formant Tutarlılığı", | |
| "labelEn": "Formant Consistency", | |
| "category": "vocal", | |
| "description": "Ses yolu rezonanslarının tutarlılığı. Fiziksel sesyolu olmayanlar aşırı tutarlı olur.", | |
| }, | |
| "breath_pattern_score": { | |
| "label": "Nefes Deseni", | |
| "labelEn": "Breath Pattern", | |
| "category": "vocal", | |
| "description": "Nefes alma/verme örüntüleri. AI üretimler nefes sesleri olmadan veya sahte nefeslerle üretir.", | |
| }, | |
| "vocal_texture_score": { | |
| "label": "Vokal Tekstür", | |
| "labelEn": "Vocal Texture", | |
| "category": "vocal", | |
| "description": "Ses teli mikro-varyasyonları (jitter, shimmer).", | |
| }, | |
| "pitch_mean_hz": { | |
| "label": "Ortalama Pitch (Hz)", | |
| "labelEn": "Mean Pitch", | |
| "category": "vocal", | |
| "description": "Vokal fundamental frekansı ortalaması.", | |
| }, | |
| "pitch_std_cents": { | |
| "label": "Pitch Sapması (cent)", | |
| "labelEn": "Pitch Deviation", | |
| "category": "vocal", | |
| "description": "Pitch'in standart sapması cent cinsinden.", | |
| }, | |
| "vibrato_rate_hz": { | |
| "label": "Vibrato Hızı (Hz)", | |
| "labelEn": "Vibrato Rate", | |
| "category": "vocal", | |
| "description": "Saniyedeki vibrato salınımı (insanlar: 4-7Hz).", | |
| }, | |
| "vibrato_extent_cents": { | |
| "label": "Vibrato Genişliği (cent)", | |
| "labelEn": "Vibrato Extent", | |
| "category": "vocal", | |
| "description": "Vibrato'nun pitch sapma miktarı.", | |
| }, | |
| "vocal_harmonic_ratio": { | |
| "label": "Vokal Harmonik Oranı", | |
| "labelEn": "Vocal Harmonic Ratio", | |
| "category": "vocal", | |
| "description": "Vokal içindeki harmonik saflık.", | |
| }, | |
| "vocal_energy_ratio": { | |
| "label": "Vokal Enerji Oranı", | |
| "labelEn": "Vocal Energy Ratio", | |
| "category": "vocal", | |
| "description": "Toplam enerjide vokal payı.", | |
| }, | |
| } | |
| class FeatureContribution: | |
| """SHAP-based contribution of a single feature to the prediction.""" | |
| name: str | |
| label: str # Turkish label | |
| label_en: str # English label | |
| category: str # spectral / temporal / harmonic / vocal / rhythm / timbre / meta / composite | |
| value: float # raw measured value | |
| z_score: float # population-normalized | |
| shap_value: float # +: pushes toward AI, -: pushes toward human | |
| direction: str # "towards_ai" | "towards_human" | "neutral" | |
| description: str # human-readable explanation | |
| class ConfidenceBand: | |
| """Human-readable confidence tier (qualitative, no interval).""" | |
| tier: str # "uncertain" | "likely" | "strong" | "very_strong" | |
| label_tr: str | |
| label_en: str | |
| margin: float # 0 at the threshold, 1 at the far end of the verdict's side | |
| class ModelVote: | |
| """Individual model's vote in the ensemble.""" | |
| name: str # XGBoost / LightGBM / ... | |
| probability: float | |
| vote: str # "ai" | "human" | |
| available: bool = True # False: model failed to load, probability is a placeholder | |
| class XAIResult: | |
| """Rich explainable analysis result.""" | |
| # Core prediction | |
| is_ai_generated: bool | |
| probability: float # 0.0 - 1.0 | |
| threshold: float # optimal threshold from training | |
| confidence_band: ConfidenceBand | |
| # Ensemble breakdown (if available) | |
| model_votes: List[ModelVote] = field(default_factory=list) | |
| best_model_name: str = "XGBoost" | |
| # Feature contributions | |
| top_contributions: List[FeatureContribution] = field(default_factory=list) | |
| all_features: Dict[str, FeatureContribution] = field(default_factory=dict) | |
| # Meta | |
| base_probability: float = 0.5 # SHAP expected value | |
| model_version: str = "auris-xai-v1" | |
| feature_count: int = 49 | |
| class WindowReading: | |
| """One timeline window read by the primary classifier.""" | |
| probability: float | |
| is_ai: bool | |
| margin: float # 0 at the threshold, 1 at the far end of its side | |
| reasons: List[FeatureContribution] = field(default_factory=list) | |
| # The classifier was trained on features of 60 s clips; a window's beat count is | |
| # scaled to that length so it lands on the same axis as the training data. | |
| _TRAINING_CLIP_SEC = 60.0 | |
| class XAIInferenceService: | |
| """Loads trained artifacts and performs explainable inference.""" | |
| def __init__(self) -> None: | |
| self.model = None | |
| self.scaler = None | |
| self.feature_cols: List[str] = [] | |
| self.training_results: Dict[str, Any] = {} | |
| self.feature_stats: Dict[str, Dict[str, float]] = {} | |
| self.shap_explainer = None | |
| self.threshold: float = 0.5 | |
| self.available: bool = False | |
| # All 11 models for ensemble voting {name: model_object} | |
| self.ensemble_models: Dict[str, Any] = {} | |
| self._load() | |
| def _load(self) -> None: | |
| try: | |
| if not _MODEL_PATH.exists(): | |
| logger.warning( | |
| f"XAI model not found at {_MODEL_PATH} — " | |
| "service disabled. Run training first." | |
| ) | |
| return | |
| with open(_MODEL_PATH, "rb") as f: | |
| self.model = pickle.load(f) | |
| with open(_SCALER_PATH, "rb") as f: | |
| self.scaler = pickle.load(f) | |
| with open(_COLUMNS_PATH, "r") as f: | |
| self.feature_cols = json.load(f) | |
| if _RESULTS_PATH.exists(): | |
| with open(_RESULTS_PATH, "r") as f: | |
| self.training_results = json.load(f) | |
| if _STATS_PATH.exists(): | |
| with open(_STATS_PATH, "r") as f: | |
| self.feature_stats = json.load(f) | |
| # Load Youden-optimal threshold for the best model | |
| best = self.training_results.get("_best_model", "LightGBM") | |
| best_data = self.training_results.get(best, {}) | |
| saved_threshold = best_data.get("optimal_threshold") | |
| if saved_threshold and isinstance(saved_threshold, float): | |
| self.threshold = saved_threshold | |
| logger.info(f"Loaded Youden threshold for {best}: {self.threshold:.4f}") | |
| # Load all 11 ensemble models for real-time voting | |
| self._load_ensemble_models() | |
| # Try to build SHAP explainer (optional — fail silently) | |
| try: | |
| import shap | |
| self.shap_explainer = shap.TreeExplainer(self.model) | |
| logger.info("SHAP TreeExplainer initialized") | |
| except Exception as e: | |
| logger.warning(f"SHAP explainer disabled: {e}") | |
| self.available = True | |
| logger.info( | |
| f"XAI service loaded: {len(self.feature_cols)} features, " | |
| f"threshold={self.threshold:.4f}" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to load XAI service: {e}", exc_info=True) | |
| self.available = False | |
| def status(self) -> Dict[str, Any]: | |
| """What actually loaded, for /api/health.""" | |
| return { | |
| "available": self.available, | |
| "bestModel": self.training_results.get("_best_model") if isinstance(self.training_results, dict) else None, | |
| "threshold": round(float(self.threshold), 6) if self.available else None, | |
| "features": len(self.feature_cols) if self.available else 0, | |
| "ensembleLoaded": len(self.ensemble_models), | |
| "ensembleTotal": len(_ML_MODEL_FILES) + len(_DL_MODEL_FILES), | |
| "shap": self.shap_explainer is not None, | |
| } | |
| def predict( | |
| self, | |
| features: AudioFeatures, | |
| vocals: Optional[VocalFeatures] = None, | |
| ) -> Optional[XAIResult]: | |
| """Run explainable inference on extracted features. | |
| Returns None if model is not available (caller should fall back). | |
| """ | |
| if not self.available: | |
| return None | |
| # Build feature vector matching training column order | |
| feature_map = self._build_feature_map(features, vocals) | |
| x = np.array( | |
| [feature_map.get(col, 0.0) for col in self.feature_cols], | |
| dtype=np.float64, | |
| ) | |
| x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0) | |
| x_scaled = self.scaler.transform(x.reshape(1, -1)) | |
| # Prediction | |
| prob = float(self.model.predict_proba(x_scaled)[0, 1]) | |
| is_ai = prob >= self.threshold | |
| # Confidence band | |
| band = self._confidence_band(prob) | |
| # SHAP contributions | |
| contributions_all: Dict[str, FeatureContribution] = {} | |
| top: List[FeatureContribution] = [] | |
| base_prob = 0.5 | |
| if self.shap_explainer is not None: | |
| try: | |
| shap_values = self.shap_explainer.shap_values(x_scaled) | |
| # For binary XGBoost: shap_values shape = (1, n_features) | |
| if isinstance(shap_values, list): | |
| sv = shap_values[1][0] if len(shap_values) > 1 else shap_values[0][0] | |
| else: | |
| sv = shap_values[0] | |
| base_val = self.shap_explainer.expected_value | |
| if isinstance(base_val, (list, np.ndarray)): | |
| base_val = float(np.array(base_val).flat[-1]) | |
| # Convert log-odds to probability baseline | |
| base_prob = float(1.0 / (1.0 + np.exp(-base_val))) | |
| for i, col in enumerate(self.feature_cols): | |
| raw = feature_map.get(col, 0.0) | |
| stats = self.feature_stats.get(col, {}) | |
| mean = stats.get("mean", 0.0) | |
| std = stats.get("std", 1.0) or 1.0 | |
| z = (raw - mean) / std | |
| shap_v = float(sv[i]) | |
| if abs(shap_v) < 0.001: | |
| direction = "neutral" | |
| elif shap_v > 0: | |
| direction = "towards_ai" | |
| else: | |
| direction = "towards_human" | |
| meta = FEATURE_CATALOG.get(col, {}) | |
| contrib = FeatureContribution( | |
| name=col, | |
| label=meta.get("label", col), | |
| label_en=meta.get("labelEn", col), | |
| category=meta.get("category", "other"), | |
| value=float(raw), | |
| z_score=float(z), | |
| shap_value=shap_v, | |
| direction=direction, | |
| description=meta.get("description", ""), | |
| ) | |
| contributions_all[col] = contrib | |
| top = sorted( | |
| contributions_all.values(), | |
| key=lambda c: abs(c.shap_value), | |
| reverse=True, | |
| )[:10] | |
| except Exception as e: | |
| logger.warning(f"SHAP computation failed: {e}") | |
| # Ensemble votes — real inference from all 11 models | |
| votes = self._build_votes(x_scaled) | |
| return XAIResult( | |
| is_ai_generated=is_ai, | |
| probability=prob, | |
| threshold=self.threshold, | |
| confidence_band=band, | |
| model_votes=votes, | |
| best_model_name=self.training_results.get("_best_model", "XGBoost"), | |
| top_contributions=top, | |
| all_features=contributions_all, | |
| base_probability=base_prob, | |
| model_version="auris-xai-v1", | |
| feature_count=len(self.feature_cols), | |
| ) | |
| def predict_window( | |
| self, | |
| features: AudioFeatures, | |
| vocals: Optional[VocalFeatures], | |
| *, | |
| window_sec: float, | |
| top: int = 3, | |
| ) -> Optional[WindowReading]: | |
| """Probability for one window of a longer track, with its main reasons. | |
| Same LightGBM model, scaler and threshold as predict(); no ensemble vote | |
| and no full SHAP table, since a timeline holds many windows. | |
| """ | |
| if not self.available: | |
| return None | |
| feature_map = self._build_feature_map(features, vocals) | |
| feature_map["beat_count"] = feature_map["beat_count"] * (_TRAINING_CLIP_SEC / max(window_sec, 1.0)) | |
| x = np.array([feature_map.get(col, 0.0) for col in self.feature_cols], dtype=np.float64) | |
| x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0) | |
| x_scaled = self.scaler.transform(x.reshape(1, -1)) | |
| prob = float(self.model.predict_proba(x_scaled)[0, 1]) | |
| band = self._confidence_band(prob) | |
| reasons: List[FeatureContribution] = [] | |
| if self.shap_explainer is not None: | |
| try: | |
| shap_values = self.shap_explainer.shap_values(x_scaled) | |
| if isinstance(shap_values, list): | |
| sv = shap_values[1][0] if len(shap_values) > 1 else shap_values[0][0] | |
| else: | |
| sv = shap_values[0] | |
| ranked = sorted(range(len(self.feature_cols)), key=lambda i: abs(float(sv[i])), reverse=True) | |
| for i in ranked[:top]: | |
| col = self.feature_cols[i] | |
| shap_v = float(sv[i]) | |
| if abs(shap_v) < 0.001: | |
| continue | |
| raw = feature_map.get(col, 0.0) | |
| stats = self.feature_stats.get(col, {}) | |
| std = stats.get("std", 1.0) or 1.0 | |
| meta = FEATURE_CATALOG.get(col, {}) | |
| reasons.append(FeatureContribution( | |
| name=col, | |
| label=meta.get("label", col), | |
| label_en=meta.get("labelEn", col), | |
| category=meta.get("category", "other"), | |
| value=float(raw), | |
| z_score=float((raw - stats.get("mean", 0.0)) / std), | |
| shap_value=shap_v, | |
| direction="towards_ai" if shap_v > 0 else "towards_human", | |
| description=meta.get("description", ""), | |
| )) | |
| except Exception as e: # noqa: BLE001 - a missing explanation must not drop the window | |
| logger.warning(f"Window SHAP failed: {e}") | |
| return WindowReading( | |
| probability=prob, | |
| is_ai=prob >= self.threshold, | |
| margin=band.margin, | |
| reasons=reasons, | |
| ) | |
| def _build_feature_map( | |
| self, | |
| features: AudioFeatures, | |
| vocals: Optional[VocalFeatures], | |
| ) -> Dict[str, float]: | |
| """Match AudioFeatures + VocalFeatures to training column names.""" | |
| m: Dict[str, float] = { | |
| "duration_sec": features.duration_sec, | |
| "sample_rate": float(features.sample_rate), | |
| "rms_energy": features.rms_energy, | |
| "rms_std": features.rms_std, | |
| "rms_dynamic_range": features.rms_dynamic_range, | |
| "spectral_centroid_mean": features.spectral_centroid_mean, | |
| "spectral_centroid_std": features.spectral_centroid_std, | |
| "spectral_flatness_mean": features.spectral_flatness_mean, | |
| "spectral_flatness_std": features.spectral_flatness_std, | |
| "spectral_bandwidth_mean": features.spectral_bandwidth_mean, | |
| "spectral_bandwidth_std": features.spectral_bandwidth_std, | |
| "spectral_rolloff_mean": features.spectral_rolloff_mean, | |
| "spectral_rolloff_std": features.spectral_rolloff_std, | |
| "spectral_contrast_mean": features.spectral_contrast_mean, | |
| "spectral_contrast_std": features.spectral_contrast_std, | |
| "mfcc_variance": features.mfcc_variance, | |
| "mfcc_delta_var": features.mfcc_delta_var, | |
| "mfcc_delta2_var": features.mfcc_delta2_var, | |
| "mel_flatness": features.mel_flatness, | |
| "tempo_bpm": features.tempo_bpm, | |
| "tempo_stability": features.tempo_stability, | |
| "tempo_cv": features.tempo_cv, | |
| "zero_crossing_rate": features.zero_crossing_rate, | |
| "zero_crossing_std": features.zero_crossing_std, | |
| "onset_strength_mean": features.onset_strength_mean, | |
| "onset_strength_std": features.onset_strength_std, | |
| "beat_count": float(features.beat_count), | |
| "chroma_entropy": features.chroma_entropy, | |
| "chroma_std": features.chroma_std, | |
| "chroma_transition_rate": features.chroma_transition_rate, | |
| "harmonic_ratio": features.harmonic_ratio, | |
| "tonnetz_std": features.tonnetz_std, | |
| "spectral_regularity": features.spectral_regularity, | |
| "temporal_patterns": features.temporal_patterns, | |
| "harmonic_structure": features.harmonic_structure, | |
| } | |
| if vocals is not None: | |
| m.update({ | |
| "has_vocals": 1.0 if vocals.has_vocals else 0.0, | |
| "vocal_confidence": vocals.vocal_confidence, | |
| "vocal_ai_score": vocals.vocal_ai_score, | |
| "pitch_stability_score": vocals.pitch_stability_score, | |
| "vibrato_regularity_score": vocals.vibrato_regularity_score, | |
| "formant_consistency_score": vocals.formant_consistency_score, | |
| "breath_pattern_score": vocals.breath_pattern_score, | |
| "vocal_texture_score": vocals.vocal_texture_score, | |
| "pitch_mean_hz": vocals.pitch_mean_hz, | |
| "pitch_std_cents": vocals.pitch_std_cents, | |
| "vibrato_rate_hz": vocals.vibrato_rate_hz, | |
| "vibrato_extent_cents": vocals.vibrato_extent_cents, | |
| "vocal_harmonic_ratio": getattr(vocals, "vocal_harmonic_ratio", 0.0), | |
| "vocal_energy_ratio": getattr(vocals, "vocal_energy_ratio", 0.0), | |
| }) | |
| return m | |
| def _confidence_band(self, prob: float) -> ConfidenceBand: | |
| """Map probability to a qualitative tier. | |
| Measured from the decision threshold (not 0.5): the margin is how far | |
| the probability sits into its side of the threshold, 0 at the | |
| threshold and 1 at 0.0 / 1.0. With a 0.5 threshold the cut points | |
| match the old distance-from-0.5 tiers (0.1 / 0.25 / 0.4). | |
| """ | |
| t = min(max(self.threshold, 1e-6), 1 - 1e-6) | |
| margin = (prob - t) / (1.0 - t) if prob >= t else (t - prob) / t | |
| margin = max(0.0, min(1.0, margin)) | |
| if margin < 0.2: | |
| tier = "uncertain" | |
| label_tr, label_en = "Belirsiz", "Uncertain" | |
| elif margin < 0.5: | |
| tier = "likely" | |
| label_tr, label_en = "Muhtemelen", "Likely" | |
| elif margin < 0.8: | |
| tier = "strong" | |
| label_tr, label_en = "Güçlü İşaret", "Strong" | |
| else: | |
| tier = "very_strong" | |
| label_tr, label_en = "Yüksek Güven", "Very Strong" | |
| return ConfidenceBand( | |
| tier=tier, | |
| label_tr=label_tr, | |
| label_en=label_en, | |
| margin=round(margin, 3), | |
| ) | |
| def _load_ensemble_models(self) -> None: | |
| """Load all 11 ML/DL models for real ensemble voting.""" | |
| # DL pkls were saved with __main__.TorchSklearnWrapper — remap to real module | |
| class _DLUnpickler(pickle.Unpickler): | |
| def find_class(self, module: str, name: str): | |
| if name == "TorchSklearnWrapper": | |
| from app.training.train_deep_classifiers import TorchSklearnWrapper | |
| return TorchSklearnWrapper | |
| return super().find_class(module, name) | |
| all_files = {**_ML_MODEL_FILES, **_DL_MODEL_FILES} | |
| loaded = 0 | |
| for name, path in all_files.items(): | |
| if not path.exists(): | |
| logger.warning(f"Ensemble model not found: {path.name}") | |
| continue | |
| try: | |
| with open(path, "rb") as f: | |
| if name in _DL_MODEL_FILES: | |
| obj = _DLUnpickler(f).load() | |
| else: | |
| obj = pickle.load(f) | |
| self.ensemble_models[name] = obj | |
| loaded += 1 | |
| except Exception as e: | |
| logger.warning(f"Could not load ensemble model {name}: {e}") | |
| logger.info(f"Ensemble: {loaded}/{len(all_files)} models loaded") | |
| def _build_votes(self, x_scaled: "np.ndarray") -> List[ModelVote]: | |
| """Run real inference on all loaded ensemble models. | |
| A model that failed to load or raises at inference time still gets | |
| a row, marked unavailable with a neutral 0.5 placeholder — never an | |
| invented probability. | |
| """ | |
| votes: List[ModelVote] = [] | |
| best_name = self.training_results.get("_best_model", "LightGBM") | |
| all_names = list({**_ML_MODEL_FILES, **_DL_MODEL_FILES}.keys()) | |
| for name in all_names: | |
| model = self.ensemble_models.get(name) | |
| prob: Optional[float] = None | |
| if model is not None: | |
| try: | |
| prob = float(model.predict_proba(x_scaled)[0, 1]) | |
| except Exception as e: | |
| logger.warning(f"Inference failed for {name}: {e}") | |
| if prob is None: | |
| votes.append(ModelVote(name=name, probability=0.5, vote="human", available=False)) | |
| continue | |
| threshold = self.threshold if name == best_name else 0.5 | |
| votes.append(ModelVote( | |
| name=name, | |
| probability=round(prob, 4), | |
| vote="ai" if prob >= threshold else "human", | |
| )) | |
| # Loaded models first, highest probability first; placeholders last. | |
| return sorted(votes, key=lambda v: (v.available, v.probability), reverse=True) | |
| def to_dict(self, result: XAIResult) -> Dict[str, Any]: | |
| """Serialize XAIResult for JSON response.""" | |
| return { | |
| "isAIGenerated": result.is_ai_generated, | |
| "probability": round(result.probability, 4), | |
| "threshold": round(result.threshold, 4), | |
| "confidenceBand": { | |
| "tier": result.confidence_band.tier, | |
| "labelTr": result.confidence_band.label_tr, | |
| "labelEn": result.confidence_band.label_en, | |
| "margin": result.confidence_band.margin, | |
| }, | |
| "baseProbability": round(result.base_probability, 4), | |
| "modelVotes": [ | |
| { | |
| "name": v.name, | |
| "probability": round(v.probability, 4), | |
| "vote": v.vote, | |
| "available": v.available, | |
| } | |
| for v in result.model_votes | |
| ], | |
| "bestModel": result.best_model_name, | |
| "topContributions": [ | |
| self._contrib_to_dict(c) for c in result.top_contributions | |
| ], | |
| "allFeatures": { | |
| name: self._contrib_to_dict(c) | |
| for name, c in result.all_features.items() | |
| }, | |
| "modelVersion": result.model_version, | |
| "featureCount": result.feature_count, | |
| } | |
| def _contrib_to_dict(c: FeatureContribution) -> Dict[str, Any]: | |
| return { | |
| "name": c.name, | |
| "label": c.label, | |
| "labelEn": c.label_en, | |
| "category": c.category, | |
| "value": round(c.value, 4), | |
| "zScore": round(c.z_score, 3), | |
| "shapValue": round(c.shap_value, 4), | |
| "direction": c.direction, | |
| "description": c.description, | |
| } | |
| # Singleton | |
| _service: Optional[XAIInferenceService] = None | |
| def get_xai_service() -> XAIInferenceService: | |
| global _service | |
| if _service is None: | |
| _service = XAIInferenceService() | |
| return _service | |