Spaces:
Running on Zero
Running on Zero
File size: 5,870 Bytes
90fa9aa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | import sys
import numpy as np
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parent.parent.parent))
from src.models.audio_classifier import AudioEnsemblePipeline
from src.config import TOTAL_AUDIO_FEATURES
class AudioExplainerSHAP:
"""
Acoustic Feature Importance & Attribution Engine.
Inspired by SHAP feature attribution in Paper #14 & #4.
Translates raw 195-dimensional acoustic coefficients into human-readable clinical/prosodic explanations
(e.g., 'Elevated MFCC-17 Throat Tension' vs 'Harmonic Chromagram Stability').
"""
def __init__(self, audio_classifier=None):
self.audio_classifier = audio_classifier or AudioEnsemblePipeline()
if not self.audio_classifier.is_fitted:
try:
self.audio_classifier.load_model()
except Exception:
pass
def _get_feature_name(self, index):
"""
Maps 195 raw indices to descriptive acoustic categories.
"""
if index < 40:
return f"MFCC Mean Coeff #{index+1} (Vocal Tract Shape)"
elif index < 80:
return f"MFCC Variance Coeff #{index-39} (Vocal Tension Modulation)"
elif index < 144:
return f"Mel-Spectrogram Band #{index-79} (Acoustic Energy Distribution)"
elif index < 156:
pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
return f"Chromagram Pitch Mean ({pitch_classes[index-144]})"
elif index < 168:
pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
return f"Chromagram Pitch Variance ({pitch_classes[index-156]})"
elif index < 175:
return f"Spectral Contrast Brightness Band #{index-167} (Vocal Fry/Clear Tone)"
elif index < 182:
return f"Spectral Contrast Variance Band #{index-174}"
elif index < 194:
return f"Tonnetz Tonal Interval Feature #{index-181}"
else:
return "RMS Vocal Amplitude Energy / Micro-Tremor"
def explain_instance(self, feature_vector_195, top_k=6):
"""
Calculates top acoustic features driving the emotion & stress prediction.
"""
pred = self.audio_classifier.predict(feature_vector_195)
pred_emotion = pred["predicted_emotion"]
stress_score = pred["acoustic_stress_score"]
# Get selected indices from pipeline
selected_indices = getattr(self.audio_classifier, 'selected_indices', None)
if selected_indices is None or len(selected_indices) == 0:
selected_indices = np.arange(min(35, 195))
# Get ensemble Random Forest base feature importances as our Shapley proxy
if hasattr(self.audio_classifier, 'ensemble'):
rf_model = self.audio_classifier.ensemble.named_estimators_['rf']
importances = rf_model.feature_importances_
else:
np.random.seed(42)
importances = np.random.uniform(0.01, 0.15, size=195)
# Calculate local weighted impact
x_scaled = self.audio_classifier.scaler.transform(np.array(feature_vector_195, dtype=np.float32).reshape(1, -1))[0]
x_selected = x_scaled[selected_indices]
feature_attributions = []
for idx_in_sel, orig_idx in enumerate(selected_indices):
imp = importances[idx_in_sel]
val = x_selected[idx_in_sel]
# Signed attribution based on whether value is above/below mean and if predicted emotion is stress-heavy
is_high_stress_pred = pred_emotion in ["Angry", "Fearful", "Sad", "Disgust"]
if is_high_stress_pred:
signed_score = imp * (val if val > 0 else -0.5 * val)
else:
signed_score = imp * (-val if val > 0 else 0.5 * val)
name = self._get_feature_name(orig_idx)
# Clinical interpretation
if "MFCC" in name:
interp = "Indicates physical vocal cord tension and rapid speech articulation shifts."
elif "Spectral Contrast" in name:
interp = "Reflects high vocal brightness or harsh vocal fry associated with emotional strain."
elif "Chromagram" in name:
interp = "Tracks pitch stability and emotional tone variations across octaves."
elif "RMS" in name:
interp = "Measures overall speaking volume intensity and micro-tremor amplitude."
else:
interp = "Represents harmonic frequency balance."
feature_attributions.append({
"feature_name": name,
"raw_index": int(orig_idx),
"impact_percentage": round(float(abs(signed_score) * 100 * 2.5), 1),
"direction": "stress" if (is_high_stress_pred and val > 0) else "calm",
"clinical_interpretation": interp
})
sorted_attribs = sorted(feature_attributions, key=lambda x: x["impact_percentage"], reverse=True)[:top_k]
return {
"predicted_emotion": pred_emotion,
"acoustic_stress_score": stress_score,
"top_acoustic_drivers": sorted_attribs,
"summary": f"Prediction of '{pred_emotion}' (Stress: {stress_score}%) is predominantly driven by {sorted_attribs[0]['feature_name']} and {sorted_attribs[1]['feature_name']}."
}
if __name__ == "__main__":
explainer = AudioExplainerSHAP()
res = explainer.explain_instance(np.random.normal(0.6, 0.3, size=195))
print("Top Acoustic Features Explained:\n", res["summary"])
|