Rthur2003 commited on
Commit
d3432f6
·
1 Parent(s): 08942e6

feat: implement meta-classifier service for AURIS score fusion

Browse files
Files changed (1) hide show
  1. app/services/meta_classifier.py +466 -0
app/services/meta_classifier.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Meta-classifier service for AURIS score fusion.
3
+
4
+ Replaces the hand-tuned weighted-average fusion with
5
+ a trained stacking ensemble that combines signals from
6
+ all analysis towers.
7
+
8
+ Towers:
9
+ 1. wav2vec2 fine-tuned (logit + hidden stats)
10
+ 2. Librosa features (spectral, temporal, harmonic)
11
+ 3. Vocal analysis (pitch, vibrato, formant, breath)
12
+ 4. CLAP embeddings (when available)
13
+ 5. FST external (when available)
14
+
15
+ The meta-classifier was trained on the same dataset
16
+ with cross-validated tower outputs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import pickle
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import List, Optional
26
+
27
+ import numpy as np
28
+
29
+ from .feature_extractor import AudioFeatures
30
+ from .vocal_analyzer import VocalFeatures
31
+ from .wav2vec2_detector import Wav2Vec2Result
32
+ from .clap_detector import CLAPResult
33
+ from .fst_client import FSTResult
34
+ from .logging_config import get_logger
35
+
36
+ logger = get_logger(__name__)
37
+
38
+ _MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
39
+
40
+
41
+ @dataclass
42
+ class MetaResult:
43
+ """Final detection result from meta-classifier."""
44
+
45
+ is_ai_generated: bool
46
+ confidence: float
47
+ model_version: str = "auris-v2-meta"
48
+ decision_source: str = "auris_meta"
49
+ analysis_mode: str = "production"
50
+
51
+ # Per-tower scores for transparency
52
+ tower_scores: dict = field(default_factory=dict)
53
+
54
+ # Explainable indicators (SHAP-based when available)
55
+ indicators: List[str] = field(default_factory=list)
56
+
57
+ # Feature importances for this prediction
58
+ top_features: List[dict] = field(default_factory=list)
59
+
60
+
61
+ class MetaClassifierService:
62
+ """
63
+ Trained stacking meta-classifier.
64
+
65
+ Combines all tower outputs into a single feature vector,
66
+ runs the trained classifier, and generates explainable
67
+ indicators.
68
+
69
+ Falls back to simple averaging when trained model is
70
+ not available (during development before first training).
71
+ """
72
+
73
+ def __init__(self):
74
+ self._model = None
75
+ self._scaler = None
76
+ self._feature_cols: list[str] = []
77
+ self._initialized = False
78
+ self._trained = False
79
+
80
+ def _ensure_loaded(self) -> bool:
81
+ """Load trained meta-classifier if available."""
82
+ if self._initialized:
83
+ return self._trained
84
+
85
+ self._initialized = True
86
+
87
+ model_path = _MODELS_DIR / "auris_classifier_v1.pkl"
88
+ scaler_path = _MODELS_DIR / "feature_scaler_v1.pkl"
89
+ columns_path = _MODELS_DIR / "feature_columns_v1.json"
90
+
91
+ if not model_path.exists():
92
+ logger.info(
93
+ "Meta-classifier not found. "
94
+ "Using fallback fusion."
95
+ )
96
+ return False
97
+
98
+ try:
99
+ with open(model_path, "rb") as f:
100
+ self._model = pickle.load(f)
101
+ with open(scaler_path, "rb") as f:
102
+ self._scaler = pickle.load(f)
103
+ with open(columns_path, "r") as f:
104
+ self._feature_cols = json.load(f)
105
+
106
+ self._trained = True
107
+ logger.info(
108
+ f"Meta-classifier loaded: "
109
+ f"{type(self._model).__name__}, "
110
+ f"{len(self._feature_cols)} features"
111
+ )
112
+ return True
113
+
114
+ except Exception as e:
115
+ logger.error(f"Failed to load meta-classifier: {e}")
116
+ return False
117
+
118
+ def predict(
119
+ self,
120
+ features: AudioFeatures,
121
+ vocals: Optional[VocalFeatures] = None,
122
+ wav2vec2: Optional[Wav2Vec2Result] = None,
123
+ clap: Optional[CLAPResult] = None,
124
+ fst: Optional[FSTResult] = None,
125
+ ) -> MetaResult:
126
+ """
127
+ Run meta-classifier on all tower outputs.
128
+
129
+ Args:
130
+ features: Librosa-extracted audio features.
131
+ vocals: Vocal analysis results.
132
+ wav2vec2: wav2vec2 tower result.
133
+ clap: CLAP embedding result.
134
+ fst: FST external API result.
135
+
136
+ Returns:
137
+ MetaResult with final prediction and explanations.
138
+ """
139
+ is_trained = self._ensure_loaded()
140
+
141
+ # Collect per-tower scores for transparency
142
+ tower_scores = {}
143
+ if wav2vec2 and wav2vec2.available:
144
+ tower_scores["wav2vec2"] = wav2vec2.p_ai
145
+ if clap and clap.available:
146
+ tower_scores["clap"] = clap.confidence
147
+ if fst and fst.available:
148
+ tower_scores["fst"] = fst.confidence
149
+
150
+ # Local feature score (heuristic, used as fallback signal)
151
+ local_score = (
152
+ features.spectral_regularity * 0.35
153
+ + features.temporal_patterns * 0.35
154
+ + features.harmonic_structure * 0.30
155
+ )
156
+ tower_scores["local_features"] = round(local_score, 4)
157
+
158
+ if vocals and vocals.has_vocals:
159
+ tower_scores["vocals"] = vocals.vocal_ai_score
160
+
161
+ if is_trained:
162
+ return self._predict_trained(
163
+ features, vocals, wav2vec2, clap, fst,
164
+ tower_scores,
165
+ )
166
+ else:
167
+ return self._predict_fallback(
168
+ features, vocals, wav2vec2, clap, fst,
169
+ tower_scores,
170
+ )
171
+
172
+ def _predict_trained(
173
+ self,
174
+ features: AudioFeatures,
175
+ vocals: Optional[VocalFeatures],
176
+ wav2vec2: Optional[Wav2Vec2Result],
177
+ clap: Optional[CLAPResult],
178
+ fst: Optional[FSTResult],
179
+ tower_scores: dict,
180
+ ) -> MetaResult:
181
+ """Prediction using trained meta-classifier."""
182
+ # Build feature vector matching training columns
183
+ feat_dict = self._build_feature_dict(
184
+ features, vocals,
185
+ )
186
+
187
+ # Assemble in correct column order
188
+ x = np.array([
189
+ feat_dict.get(col, 0.0)
190
+ for col in self._feature_cols
191
+ ], dtype=np.float32).reshape(1, -1)
192
+
193
+ x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0)
194
+ x_scaled = self._scaler.transform(x)
195
+
196
+ # Predict
197
+ proba = self._model.predict_proba(x_scaled)[0]
198
+ p_ai = float(proba[1])
199
+
200
+ # FST calibration (not in trained model)
201
+ if fst and fst.available:
202
+ tower_scores["fst"] = fst.confidence
203
+ if (p_ai > 0.5) != fst.is_ai:
204
+ # Disagreement — moderate confidence
205
+ p_ai = p_ai * 0.85 + 0.5 * 0.15
206
+
207
+ is_ai = p_ai > 0.5
208
+ confidence = round(p_ai if is_ai else 1.0 - p_ai, 4)
209
+
210
+ # Generate indicators
211
+ indicators = self._build_indicators(
212
+ is_ai, confidence, features, vocals,
213
+ tower_scores,
214
+ )
215
+
216
+ # Feature importances for this prediction
217
+ top_features = self._get_top_features(x_scaled[0])
218
+
219
+ return MetaResult(
220
+ is_ai_generated=is_ai,
221
+ confidence=confidence,
222
+ model_version="auris-v2-trained",
223
+ decision_source="auris_meta",
224
+ analysis_mode="production",
225
+ tower_scores=tower_scores,
226
+ indicators=indicators,
227
+ top_features=top_features,
228
+ )
229
+
230
+ def _predict_fallback(
231
+ self,
232
+ features: AudioFeatures,
233
+ vocals: Optional[VocalFeatures],
234
+ wav2vec2: Optional[Wav2Vec2Result],
235
+ clap: Optional[CLAPResult],
236
+ fst: Optional[FSTResult],
237
+ tower_scores: dict,
238
+ ) -> MetaResult:
239
+ """
240
+ Fallback when trained model is not available.
241
+
242
+ Uses weighted averaging of available tower scores.
243
+ Better than heuristic-only but not data-driven.
244
+ """
245
+ scores = []
246
+ weights = []
247
+
248
+ # wav2vec2 gets highest weight if available
249
+ if wav2vec2 and wav2vec2.available:
250
+ scores.append(wav2vec2.p_ai)
251
+ weights.append(0.40)
252
+
253
+ # Local features
254
+ local = tower_scores.get("local_features", 0.5)
255
+ scores.append(local)
256
+ weights.append(0.25 if wav2vec2 and wav2vec2.available else 0.45)
257
+
258
+ # Vocals
259
+ if vocals and vocals.has_vocals:
260
+ scores.append(vocals.vocal_ai_score)
261
+ weights.append(0.15)
262
+
263
+ # CLAP
264
+ if clap and clap.available:
265
+ scores.append(clap.confidence)
266
+ weights.append(0.10)
267
+
268
+ # FST
269
+ if fst and fst.available:
270
+ scores.append(fst.confidence)
271
+ weights.append(0.20)
272
+
273
+ # Weighted average
274
+ total_w = sum(weights)
275
+ p_ai = sum(
276
+ s * (w / total_w) for s, w in zip(scores, weights)
277
+ )
278
+
279
+ is_ai = p_ai > 0.5
280
+ confidence = round(max(0.51, min(0.97, p_ai)), 4)
281
+
282
+ indicators = self._build_indicators(
283
+ is_ai, confidence, features, vocals,
284
+ tower_scores,
285
+ )
286
+
287
+ return MetaResult(
288
+ is_ai_generated=is_ai,
289
+ confidence=confidence,
290
+ model_version="auris-v1-heuristic",
291
+ decision_source="auris_fallback",
292
+ analysis_mode="production",
293
+ tower_scores=tower_scores,
294
+ indicators=indicators,
295
+ )
296
+
297
+ def _build_feature_dict(
298
+ self,
299
+ features: AudioFeatures,
300
+ vocals: Optional[VocalFeatures],
301
+ ) -> dict:
302
+ """Build flat feature dict for meta-classifier."""
303
+ d = {
304
+ "duration_sec": features.duration_sec,
305
+ "sample_rate": features.sample_rate,
306
+ "rms_energy": features.rms_energy,
307
+ "tempo_bpm": features.tempo_bpm,
308
+ "tempo_stability": features.tempo_stability,
309
+ "spectral_centroid_mean": features.spectral_centroid_mean,
310
+ "spectral_centroid_std": features.spectral_centroid_std,
311
+ "spectral_flatness_mean": features.spectral_flatness_mean,
312
+ "mfcc_variance": features.mfcc_variance,
313
+ "chroma_entropy": features.chroma_entropy,
314
+ "harmonic_ratio": features.harmonic_ratio,
315
+ "zero_crossing_rate": features.zero_crossing_rate,
316
+ "spectral_regularity": features.spectral_regularity,
317
+ "temporal_patterns": features.temporal_patterns,
318
+ "harmonic_structure": features.harmonic_structure,
319
+ }
320
+
321
+ if vocals:
322
+ d.update({
323
+ "has_vocals": 1.0 if vocals.has_vocals else 0.0,
324
+ "vocal_confidence": vocals.vocal_confidence,
325
+ "vocal_ai_score": vocals.vocal_ai_score,
326
+ "pitch_stability_score": vocals.pitch_stability_score,
327
+ "vibrato_regularity_score": vocals.vibrato_regularity_score,
328
+ "formant_consistency_score": vocals.formant_consistency_score,
329
+ "breath_pattern_score": vocals.breath_pattern_score,
330
+ "vocal_texture_score": vocals.vocal_texture_score,
331
+ "pitch_mean_hz": vocals.pitch_mean_hz,
332
+ "pitch_std_cents": vocals.pitch_std_cents,
333
+ "vibrato_rate_hz": vocals.vibrato_rate_hz,
334
+ "vibrato_extent_cents": vocals.vibrato_extent_cents,
335
+ "vocal_harmonic_ratio": vocals.vocal_harmonic_ratio,
336
+ "vocal_energy_ratio": vocals.vocal_energy_ratio,
337
+ })
338
+ else:
339
+ for key in [
340
+ "has_vocals", "vocal_confidence", "vocal_ai_score",
341
+ "pitch_stability_score", "vibrato_regularity_score",
342
+ "formant_consistency_score", "breath_pattern_score",
343
+ "vocal_texture_score", "pitch_mean_hz",
344
+ "pitch_std_cents", "vibrato_rate_hz",
345
+ "vibrato_extent_cents", "vocal_harmonic_ratio",
346
+ "vocal_energy_ratio",
347
+ ]:
348
+ d[key] = 0.0
349
+
350
+ return d
351
+
352
+ def _get_top_features(
353
+ self, x: np.ndarray, top_n: int = 5
354
+ ) -> list[dict]:
355
+ """
356
+ Get top contributing features for this prediction.
357
+
358
+ Uses feature_importances_ from tree models.
359
+ In future: SHAP values for per-sample explanation.
360
+ """
361
+ if not hasattr(self._model, "feature_importances_"):
362
+ return []
363
+
364
+ importances = self._model.feature_importances_
365
+ indices = np.argsort(importances)[::-1][:top_n]
366
+
367
+ result = []
368
+ for idx in indices:
369
+ col_name = (
370
+ self._feature_cols[idx]
371
+ if idx < len(self._feature_cols)
372
+ else f"feature_{idx}"
373
+ )
374
+ result.append({
375
+ "feature": col_name,
376
+ "importance": round(float(importances[idx]), 4),
377
+ "value": round(float(x[idx]), 4),
378
+ })
379
+
380
+ return result
381
+
382
+ @staticmethod
383
+ def _build_indicators(
384
+ is_ai: bool,
385
+ confidence: float,
386
+ features: AudioFeatures,
387
+ vocals: Optional[VocalFeatures],
388
+ tower_scores: dict,
389
+ ) -> list[str]:
390
+ """Generate human-readable indicators."""
391
+ indicators = []
392
+
393
+ # Overall
394
+ label = "AI-generated" if is_ai else "human-composed"
395
+ if confidence > 0.85:
396
+ indicators.append(
397
+ f"High confidence: classified as {label}."
398
+ )
399
+ elif confidence > 0.70:
400
+ indicators.append(
401
+ f"Moderate confidence: likely {label}."
402
+ )
403
+ else:
404
+ indicators.append(
405
+ f"Low confidence: borderline {label}."
406
+ )
407
+
408
+ # Tower agreement
409
+ ai_towers = sum(
410
+ 1 for v in tower_scores.values() if v > 0.5
411
+ )
412
+ human_towers = sum(
413
+ 1 for v in tower_scores.values() if v <= 0.5
414
+ )
415
+ total = ai_towers + human_towers
416
+
417
+ if total > 1:
418
+ if ai_towers == total:
419
+ indicators.append(
420
+ f"All {total} analysis signals agree: AI-generated."
421
+ )
422
+ elif human_towers == total:
423
+ indicators.append(
424
+ f"All {total} analysis signals agree: human-composed."
425
+ )
426
+ else:
427
+ indicators.append(
428
+ f"Mixed signals: {ai_towers}/{total} indicate AI, "
429
+ f"{human_towers}/{total} indicate human."
430
+ )
431
+
432
+ # Spectral
433
+ if features.spectral_regularity > 0.7:
434
+ indicators.append(
435
+ "High spectral regularity — typical of AI synthesis."
436
+ )
437
+ elif features.spectral_regularity < 0.3:
438
+ indicators.append(
439
+ "Natural spectral variation — consistent with human recording."
440
+ )
441
+
442
+ # Temporal
443
+ if features.temporal_patterns > 0.7:
444
+ indicators.append(
445
+ f"Metronomic timing precision "
446
+ f"(tempo jitter: {features.tempo_stability:.3f}s)."
447
+ )
448
+
449
+ # Vocals
450
+ if vocals and vocals.has_vocals:
451
+ if vocals.vocal_ai_score > 0.7:
452
+ indicators.append(
453
+ "Vocal analysis indicates synthetic voice characteristics."
454
+ )
455
+ elif vocals.vocal_ai_score < 0.3:
456
+ indicators.append(
457
+ "Vocal patterns consistent with natural human singing."
458
+ )
459
+
460
+ if vocals.pitch_std_cents < 10:
461
+ indicators.append(
462
+ f"Pitch jitter ({vocals.pitch_std_cents:.1f} cents) "
463
+ "is unusually low — suggests synthetic vocal."
464
+ )
465
+
466
+ return indicators