Spaces:
Configuration error
Configuration error
Upload ml/model/fusion.py with huggingface_hub
Browse files- ml/model/fusion.py +94 -52
ml/model/fusion.py
CHANGED
|
@@ -1,72 +1,102 @@
|
|
| 1 |
"""
|
| 2 |
-
ml/model/fusion.py - Multi-modal fusion + per-user self-calibration
|
| 3 |
-
====================================================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
0.78 <= P < 0.90 -> moderate (frequent disfluent events)
|
| 16 |
-
P >= 0.90 -> severe (prolonged blocks or continuous repetitions)
|
| 17 |
-
- Pronunciation GOP directly evaluates phonetic accuracy and alignment.
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
-
from typing import Optional
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
|
| 24 |
-
BUCKETS = ["fluent", "mild", "moderate", "severe", "silent"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def bucket_of(sev: int) -> str:
|
| 28 |
if sev == 4:
|
| 29 |
return "silent"
|
|
|
|
|
|
|
| 30 |
return BUCKETS[min(max(sev, 0), 3)]
|
| 31 |
|
| 32 |
|
| 33 |
-
def severity_from_softmax(probs) -> int
|
| 34 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 35 |
if probs is None:
|
| 36 |
-
return None
|
| 37 |
p = np.asarray(probs, dtype=float)
|
| 38 |
if p.sum() > 0:
|
| 39 |
p = p / p.sum()
|
| 40 |
if len(p) == 0:
|
| 41 |
-
return None
|
| 42 |
|
| 43 |
p_stutter = float(p[1]) if len(p) == 2 else float(np.sum(p[1:]))
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
else:
|
| 51 |
-
return 3
|
| 52 |
|
| 53 |
|
| 54 |
-
def articulation_severity(articulatory: dict) -> int:
|
| 55 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 56 |
if articulatory.get("is_silent"):
|
| 57 |
-
return 4
|
| 58 |
s = 0
|
| 59 |
-
|
| 60 |
-
if articulatory.get("
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
# Per-user self-calibration
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
class CalibrationProfile:
|
| 69 |
-
"""Per-user bucket-offset derived from their own
|
| 70 |
|
| 71 |
def __init__(self, normal_fluent: Optional[float] = None):
|
| 72 |
self.normal_fluent = normal_fluent
|
|
@@ -77,7 +107,7 @@ class CalibrationProfile:
|
|
| 77 |
|
| 78 |
def shift(self, raw: int) -> int:
|
| 79 |
"""Bucket offset so the user's natural baseline == fluent."""
|
| 80 |
-
if not self.active or raw
|
| 81 |
return raw
|
| 82 |
offset = 1 if self.normal_fluent < 0.60 else 0
|
| 83 |
return max(0, raw - offset)
|
|
@@ -94,16 +124,21 @@ def calibrate_from_normal(normal_clip_probs) -> CalibrationProfile:
|
|
| 94 |
|
| 95 |
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
-
# Top-level fusion
|
| 98 |
# ---------------------------------------------------------------------------
|
| 99 |
def diag_statistics(
|
| 100 |
probs,
|
| 101 |
pronunciation: dict,
|
| 102 |
articulatory: dict,
|
| 103 |
calibration: Optional[CalibrationProfile] = None,
|
|
|
|
| 104 |
) -> dict:
|
| 105 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 106 |
cal = calibration or CalibrationProfile()
|
|
|
|
| 107 |
|
| 108 |
# 1. Silence Guard
|
| 109 |
is_silent = False
|
|
@@ -121,21 +156,21 @@ def diag_statistics(
|
|
| 121 |
"overall": "silent",
|
| 122 |
},
|
| 123 |
"fluency_100": None,
|
|
|
|
| 124 |
"is_silent": True,
|
| 125 |
"self_calibrated": False,
|
| 126 |
"evidence": {
|
| 127 |
-
"note": "No speech
|
| 128 |
},
|
| 129 |
}
|
| 130 |
|
| 131 |
-
sev = severity_from_softmax(probs)
|
| 132 |
if sev is None:
|
| 133 |
stut_bucket, stut_prob_val, stut_probs_disp = None, 0.0, None
|
| 134 |
stut_loss = 0.0
|
| 135 |
else:
|
| 136 |
stut_bucket = cal.shift(sev) if cal.active else sev
|
| 137 |
p = np.asarray(probs, dtype=float)
|
| 138 |
-
stut_prob_val = float(p[1]) if len(p) > 1 else float(np.sum(p[1:]))
|
| 139 |
stut_probs_disp = p.tolist()
|
| 140 |
|
| 141 |
# Continuous stutter penalty
|
|
@@ -143,7 +178,7 @@ def diag_statistics(
|
|
| 143 |
if cal.active and cal.normal_fluent is not None and cal.normal_fluent < 0.60:
|
| 144 |
stut_loss = max(0.0, stut_loss - 0.25)
|
| 145 |
|
| 146 |
-
# Pronunciation penalty
|
| 147 |
pr = pronunciation.get("pron_score") if isinstance(pronunciation, dict) else None
|
| 148 |
if pr is not None:
|
| 149 |
pron_loss = 1.0 - float(pr)
|
|
@@ -159,13 +194,13 @@ def diag_statistics(
|
|
| 159 |
pron_loss = 0.0
|
| 160 |
pron_bucket = None
|
| 161 |
|
| 162 |
-
#
|
| 163 |
-
art_bucket = articulation_severity(articulatory)
|
| 164 |
art_loss = art_bucket / 3.0 if art_bucket != 4 else 0.0
|
| 165 |
|
| 166 |
-
#
|
| 167 |
if sev is not None and pr is not None:
|
| 168 |
-
fluency = int(100.0 * max(0.0, 1.0 - (
|
| 169 |
elif sev is not None:
|
| 170 |
fluency = int(100.0 * max(0.0, 1.0 - (0.75 * stut_loss + 0.25 * art_loss)))
|
| 171 |
elif pr is not None:
|
|
@@ -187,14 +222,21 @@ def diag_statistics(
|
|
| 187 |
"overall": bucket_of(overall),
|
| 188 |
},
|
| 189 |
"fluency_100": fluency,
|
|
|
|
| 190 |
"is_silent": False,
|
| 191 |
"self_calibrated": cal.active,
|
| 192 |
"evidence": {
|
| 193 |
"stutter_probs": stut_probs_disp,
|
| 194 |
-
"
|
| 195 |
"pron_goodness": pr,
|
| 196 |
-
"
|
| 197 |
-
"
|
| 198 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
},
|
| 200 |
}
|
|
|
|
| 1 |
"""
|
| 2 |
+
ml/model/fusion.py - Multi-modal fusion + per-user self-calibration + uncertainty
|
| 3 |
+
==================================================================================
|
| 4 |
+
Fuses neural disfluency classification, CTC phonetic alignment, and Praat acoustic
|
| 5 |
+
correlates of phonation into a unified, transparent screening report.
|
| 6 |
+
|
| 7 |
+
Design & Scientific Transparency Notes:
|
| 8 |
+
- Heuristic Weighting: The Fluency Index (0..100) utilizes empirically tuned
|
| 9 |
+
weights (40% disfluency, 45% pronunciation, 15% phonation correlates) designed
|
| 10 |
+
for educational screening & practice feedback.
|
| 11 |
+
- Uncertainty Estimation: Flags borderline classification regions and noisy
|
| 12 |
+
recordings with explicit confidence scoring rather than overconfident assertions.
|
| 13 |
+
- Clinical Grounding: This prototype measures acoustic correlates of phonation
|
| 14 |
+
and phonetic alignment; it is not an FDA-cleared diagnostic device.
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
+
from typing import Optional, Dict, Any, Tuple
|
| 18 |
|
| 19 |
import numpy as np
|
| 20 |
|
| 21 |
+
BUCKETS = ["fluent", "mild", "moderate", "severe", "silent", "uncertain"]
|
| 22 |
+
|
| 23 |
+
# Configurable empirical thresholds
|
| 24 |
+
THRESHOLD_MILD = 0.60
|
| 25 |
+
THRESHOLD_MODERATE = 0.78
|
| 26 |
+
THRESHOLD_SEVERE = 0.90
|
| 27 |
+
BORDERLINE_BAND = (0.42, 0.58)
|
| 28 |
|
| 29 |
|
| 30 |
def bucket_of(sev: int) -> str:
|
| 31 |
if sev == 4:
|
| 32 |
return "silent"
|
| 33 |
+
if sev == 5:
|
| 34 |
+
return "uncertain"
|
| 35 |
return BUCKETS[min(max(sev, 0), 3)]
|
| 36 |
|
| 37 |
|
| 38 |
+
def severity_from_softmax(probs) -> Tuple[Optional[int], float, str]:
|
| 39 |
+
"""
|
| 40 |
+
0..3 clinical severity from a stutter softmax with uncertainty tracking.
|
| 41 |
+
Returns: (severity_bucket, p_stutter, confidence_rating)
|
| 42 |
+
"""
|
| 43 |
if probs is None:
|
| 44 |
+
return None, 0.0, "unknown"
|
| 45 |
p = np.asarray(probs, dtype=float)
|
| 46 |
if p.sum() > 0:
|
| 47 |
p = p / p.sum()
|
| 48 |
if len(p) == 0:
|
| 49 |
+
return None, 0.0, "unknown"
|
| 50 |
|
| 51 |
p_stutter = float(p[1]) if len(p) == 2 else float(np.sum(p[1:]))
|
| 52 |
+
|
| 53 |
+
# Uncertainty / Confidence estimation
|
| 54 |
+
margin = abs(p_stutter - 0.50) * 2.0 # 0.0 at 50/50 tossup, 1.0 at pure certainty
|
| 55 |
+
if BORDERLINE_BAND[0] <= p_stutter <= BORDERLINE_BAND[1]:
|
| 56 |
+
confidence = "low (borderline transition)"
|
| 57 |
+
elif margin < 0.35:
|
| 58 |
+
confidence = "moderate"
|
| 59 |
+
else:
|
| 60 |
+
confidence = "high"
|
| 61 |
+
|
| 62 |
+
if p_stutter < THRESHOLD_MILD:
|
| 63 |
+
return 0, p_stutter, confidence
|
| 64 |
+
elif p_stutter < THRESHOLD_MODERATE:
|
| 65 |
+
return 1, p_stutter, confidence
|
| 66 |
+
elif p_stutter < THRESHOLD_SEVERE:
|
| 67 |
+
return 2, p_stutter, confidence
|
| 68 |
else:
|
| 69 |
+
return 3, p_stutter, confidence
|
| 70 |
|
| 71 |
|
| 72 |
+
def articulation_severity(articulatory: dict) -> Tuple[int, str]:
|
| 73 |
+
"""
|
| 74 |
+
0..3 roughness from acoustic correlates of phonation (Praat PointProcess).
|
| 75 |
+
Returns: (severity_index, summary_string)
|
| 76 |
+
"""
|
| 77 |
if articulatory.get("is_silent"):
|
| 78 |
+
return 4, "silent"
|
| 79 |
s = 0
|
| 80 |
+
flags = []
|
| 81 |
+
if articulatory.get("hnr_db", 20) < 9.0:
|
| 82 |
+
s += 1
|
| 83 |
+
flags.append("Low HNR (<9 dB)")
|
| 84 |
+
if articulatory.get("jitter", 0.0) > 0.05:
|
| 85 |
+
s += 1
|
| 86 |
+
flags.append("High Jitter (>5%)")
|
| 87 |
+
if articulatory.get("voiced_ratio", 1.0) < 0.25:
|
| 88 |
+
s += 1
|
| 89 |
+
flags.append("Low Voicing (<25%)")
|
| 90 |
+
|
| 91 |
+
desc = ", ".join(flags) if flags else "Normal acoustic range"
|
| 92 |
+
return min(s, 3), desc
|
| 93 |
|
| 94 |
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
# Per-user self-calibration
|
| 97 |
# ---------------------------------------------------------------------------
|
| 98 |
class CalibrationProfile:
|
| 99 |
+
"""Per-user bucket-offset derived from their own healthy baseline recording."""
|
| 100 |
|
| 101 |
def __init__(self, normal_fluent: Optional[float] = None):
|
| 102 |
self.normal_fluent = normal_fluent
|
|
|
|
| 107 |
|
| 108 |
def shift(self, raw: int) -> int:
|
| 109 |
"""Bucket offset so the user's natural baseline == fluent."""
|
| 110 |
+
if not self.active or raw in (4, 5):
|
| 111 |
return raw
|
| 112 |
offset = 1 if self.normal_fluent < 0.60 else 0
|
| 113 |
return max(0, raw - offset)
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
# ---------------------------------------------------------------------------
|
| 127 |
+
# Top-level multi-modal fusion
|
| 128 |
# ---------------------------------------------------------------------------
|
| 129 |
def diag_statistics(
|
| 130 |
probs,
|
| 131 |
pronunciation: dict,
|
| 132 |
articulatory: dict,
|
| 133 |
calibration: Optional[CalibrationProfile] = None,
|
| 134 |
+
weights: Tuple[float, float, float] = (0.40, 0.45, 0.15),
|
| 135 |
) -> dict:
|
| 136 |
+
"""
|
| 137 |
+
Fuse all modalities into an auditable diagnostic screening dictionary.
|
| 138 |
+
Weights: (w_stutter, w_pronunciation, w_acoustics)
|
| 139 |
+
"""
|
| 140 |
cal = calibration or CalibrationProfile()
|
| 141 |
+
w_stut, w_pron, w_art = weights
|
| 142 |
|
| 143 |
# 1. Silence Guard
|
| 144 |
is_silent = False
|
|
|
|
| 156 |
"overall": "silent",
|
| 157 |
},
|
| 158 |
"fluency_100": None,
|
| 159 |
+
"confidence": "high (silence confirmed)",
|
| 160 |
"is_silent": True,
|
| 161 |
"self_calibrated": False,
|
| 162 |
"evidence": {
|
| 163 |
+
"note": "No active speech signal detected. Ambient energy below threshold.",
|
| 164 |
},
|
| 165 |
}
|
| 166 |
|
| 167 |
+
sev, stut_prob_val, confidence_rating = severity_from_softmax(probs)
|
| 168 |
if sev is None:
|
| 169 |
stut_bucket, stut_prob_val, stut_probs_disp = None, 0.0, None
|
| 170 |
stut_loss = 0.0
|
| 171 |
else:
|
| 172 |
stut_bucket = cal.shift(sev) if cal.active else sev
|
| 173 |
p = np.asarray(probs, dtype=float)
|
|
|
|
| 174 |
stut_probs_disp = p.tolist()
|
| 175 |
|
| 176 |
# Continuous stutter penalty
|
|
|
|
| 178 |
if cal.active and cal.normal_fluent is not None and cal.normal_fluent < 0.60:
|
| 179 |
stut_loss = max(0.0, stut_loss - 0.25)
|
| 180 |
|
| 181 |
+
# Pronunciation penalty from GOP
|
| 182 |
pr = pronunciation.get("pron_score") if isinstance(pronunciation, dict) else None
|
| 183 |
if pr is not None:
|
| 184 |
pron_loss = 1.0 - float(pr)
|
|
|
|
| 194 |
pron_loss = 0.0
|
| 195 |
pron_bucket = None
|
| 196 |
|
| 197 |
+
# Phonation acoustic penalty
|
| 198 |
+
art_bucket, art_desc = articulation_severity(articulatory)
|
| 199 |
art_loss = art_bucket / 3.0 if art_bucket != 4 else 0.0
|
| 200 |
|
| 201 |
+
# Composite Fluency Index (0..100)
|
| 202 |
if sev is not None and pr is not None:
|
| 203 |
+
fluency = int(100.0 * max(0.0, 1.0 - (w_stut * stut_loss + w_pron * pron_loss + w_art * art_loss)))
|
| 204 |
elif sev is not None:
|
| 205 |
fluency = int(100.0 * max(0.0, 1.0 - (0.75 * stut_loss + 0.25 * art_loss)))
|
| 206 |
elif pr is not None:
|
|
|
|
| 222 |
"overall": bucket_of(overall),
|
| 223 |
},
|
| 224 |
"fluency_100": fluency,
|
| 225 |
+
"confidence": confidence_rating,
|
| 226 |
"is_silent": False,
|
| 227 |
"self_calibrated": cal.active,
|
| 228 |
"evidence": {
|
| 229 |
"stutter_probs": stut_probs_disp,
|
| 230 |
+
"stutter_severity_raw": sev,
|
| 231 |
"pron_goodness": pr,
|
| 232 |
+
"articulatory_correlates": articulatory,
|
| 233 |
+
"articulatory_summary": art_desc,
|
| 234 |
+
"heuristic_weights_applied": {
|
| 235 |
+
"stutter_weight": w_stut,
|
| 236 |
+
"pronunciation_weight": w_pron,
|
| 237 |
+
"acoustics_weight": w_art,
|
| 238 |
+
},
|
| 239 |
+
"normal_fluent_baseline": cal.normal_fluent,
|
| 240 |
+
"calibration_shift_applied": cal.active and cal.normal_fluent is not None and cal.normal_fluent < 0.60,
|
| 241 |
},
|
| 242 |
}
|