Spaces:
Configuration error
Configuration error
Upload ml/model/fusion.py with huggingface_hub
Browse files- ml/model/fusion.py +75 -66
ml/model/fusion.py
CHANGED
|
@@ -1,31 +1,29 @@
|
|
| 1 |
"""
|
| 2 |
-
ml/model/fusion.py - Multi-modal fusion + per-user self-calibration +
|
| 3 |
-
==================================================================================
|
| 4 |
Fuses neural disfluency classification, CTC phonetic alignment, and Praat acoustic
|
| 5 |
-
correlates of phonation into a
|
| 6 |
|
| 7 |
Design & Scientific Transparency Notes:
|
| 8 |
-
- Heuristic Weighting: The
|
| 9 |
-
weights (40% disfluency, 45% pronunciation, 15% phonation correlates)
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
-
|
| 14 |
-
|
| 15 |
-
- Clinical Grounding: This prototype measures acoustic correlates of phonation
|
| 16 |
-
and phonetic alignment; it is not an FDA-cleared diagnostic device.
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
from typing import Optional, Dict, Any, Tuple
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
#
|
| 26 |
THRESHOLD_MILD = 0.60
|
| 27 |
THRESHOLD_MODERATE = 0.78
|
| 28 |
-
|
| 29 |
BORDERLINE_BAND = (0.42, 0.58)
|
| 30 |
|
| 31 |
|
|
@@ -34,59 +32,65 @@ def bucket_of(sev: int) -> str:
|
|
| 34 |
return "silent"
|
| 35 |
if sev == 5:
|
| 36 |
return "uncertain"
|
| 37 |
-
return
|
| 38 |
|
| 39 |
|
| 40 |
-
def severity_from_softmax(probs) -> Tuple[Optional[int], float, str]:
|
| 41 |
"""
|
| 42 |
-
0..3
|
| 43 |
-
Returns: (
|
| 44 |
"""
|
| 45 |
if probs is None:
|
| 46 |
-
return None, 0.0, "unknown"
|
| 47 |
p = np.asarray(probs, dtype=float)
|
| 48 |
if p.sum() > 0:
|
| 49 |
p = p / p.sum()
|
| 50 |
if len(p) == 0:
|
| 51 |
-
return None, 0.0, "unknown"
|
| 52 |
|
| 53 |
p_stutter = float(p[1]) if len(p) == 2 else float(np.sum(p[1:]))
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
margin = abs(p_stutter - 0.50) * 2.0 # 0.0 at 50/50 tossup, 1.0 at pure certainty
|
| 57 |
if BORDERLINE_BAND[0] <= p_stutter <= BORDERLINE_BAND[1]:
|
| 58 |
-
|
| 59 |
-
elif
|
| 60 |
-
|
| 61 |
else:
|
| 62 |
-
|
| 63 |
|
| 64 |
if p_stutter < THRESHOLD_MILD:
|
| 65 |
-
return 0, p_stutter,
|
| 66 |
elif p_stutter < THRESHOLD_MODERATE:
|
| 67 |
-
return 1, p_stutter,
|
| 68 |
-
elif p_stutter <
|
| 69 |
-
return 2, p_stutter,
|
| 70 |
else:
|
| 71 |
-
return 3, p_stutter,
|
| 72 |
|
| 73 |
|
| 74 |
def articulation_severity(articulatory: dict) -> Tuple[int, str]:
|
| 75 |
"""
|
| 76 |
-
0..3 roughness from acoustic correlates of phonation (Praat PointProcess).
|
| 77 |
-
Returns: (
|
| 78 |
"""
|
| 79 |
if articulatory.get("is_silent"):
|
| 80 |
return 4, "silent"
|
|
|
|
|
|
|
|
|
|
| 81 |
s = 0
|
| 82 |
flags = []
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
s += 1
|
| 85 |
-
flags.append("Low HNR (
|
| 86 |
-
if
|
| 87 |
s += 1
|
| 88 |
-
flags.append("High Jitter (
|
| 89 |
-
if
|
| 90 |
s += 1
|
| 91 |
flags.append("Low Voicing (<25%)")
|
| 92 |
|
|
@@ -98,7 +102,7 @@ def articulation_severity(articulatory: dict) -> Tuple[int, str]:
|
|
| 98 |
# Per-user self-calibration (Single Shift Guarded)
|
| 99 |
# ---------------------------------------------------------------------------
|
| 100 |
class CalibrationProfile:
|
| 101 |
-
"""Per-user
|
| 102 |
|
| 103 |
def __init__(self, normal_fluent: Optional[float] = None):
|
| 104 |
self.normal_fluent = normal_fluent
|
|
@@ -108,10 +112,10 @@ class CalibrationProfile:
|
|
| 108 |
return self.normal_fluent is not None
|
| 109 |
|
| 110 |
def shift(self, raw: int) -> int:
|
| 111 |
-
"""Single bucket offset so the user's natural baseline ==
|
| 112 |
if not self.active or raw in (4, 5):
|
| 113 |
return raw
|
| 114 |
-
offset = 1 if self.normal_fluent < 0.60 else 0
|
| 115 |
return max(0, raw - offset)
|
| 116 |
|
| 117 |
|
|
@@ -136,8 +140,8 @@ def diag_statistics(
|
|
| 136 |
weights: Tuple[float, float, float] = (0.40, 0.45, 0.15),
|
| 137 |
) -> dict:
|
| 138 |
"""
|
| 139 |
-
Fuse all modalities into an auditable
|
| 140 |
-
Single-shift calibration applied
|
| 141 |
"""
|
| 142 |
cal = calibration or CalibrationProfile()
|
| 143 |
w_stut, w_pron, w_art = weights
|
|
@@ -158,7 +162,8 @@ def diag_statistics(
|
|
| 158 |
"overall": "silent",
|
| 159 |
},
|
| 160 |
"fluency_100": None,
|
| 161 |
-
"confidence": "
|
|
|
|
| 162 |
"is_silent": True,
|
| 163 |
"self_calibrated": False,
|
| 164 |
"evidence": {
|
|
@@ -166,30 +171,29 @@ def diag_statistics(
|
|
| 166 |
},
|
| 167 |
}
|
| 168 |
|
| 169 |
-
# 2. Raw Subsystem
|
| 170 |
-
sev, stut_prob_val,
|
| 171 |
if sev is None:
|
| 172 |
stut_bucket, stut_prob_val, stut_probs_disp = None, 0.0, None
|
| 173 |
stut_loss = 0.0
|
| 174 |
else:
|
| 175 |
-
stut_bucket = sev
|
| 176 |
p = np.asarray(probs, dtype=float)
|
| 177 |
stut_probs_disp = p.tolist()
|
| 178 |
-
# Continuous stutter penalty
|
| 179 |
stut_loss = max(0.0, (stut_prob_val - 0.45) / 0.55)
|
| 180 |
|
| 181 |
-
# Pronunciation penalty from
|
| 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)
|
| 185 |
if pron_loss < 0.15:
|
| 186 |
-
pron_bucket = 0 #
|
| 187 |
elif pron_loss < 0.40:
|
| 188 |
pron_bucket = 1 # mild
|
| 189 |
elif pron_loss < 0.70:
|
| 190 |
pron_bucket = 2 # moderate
|
| 191 |
else:
|
| 192 |
-
pron_bucket = 3 #
|
| 193 |
else:
|
| 194 |
pron_loss = 0.0
|
| 195 |
pron_bucket = None
|
|
@@ -198,19 +202,23 @@ def diag_statistics(
|
|
| 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
|
| 202 |
if sev is not None and pr is not None:
|
| 203 |
-
|
| 204 |
elif sev is not None:
|
| 205 |
-
|
| 206 |
elif pr is not None:
|
| 207 |
-
|
| 208 |
else:
|
| 209 |
-
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
# Single shift applied
|
| 214 |
present = [b for b in (stut_bucket, pron_bucket, art_bucket) if b is not None and b != 4]
|
| 215 |
overall_raw = max(present) if present else 0
|
| 216 |
overall = cal.shift(overall_raw) if cal.active else overall_raw
|
|
@@ -222,16 +230,17 @@ def diag_statistics(
|
|
| 222 |
"articulation": bucket_of(art_bucket),
|
| 223 |
"overall": bucket_of(overall),
|
| 224 |
},
|
| 225 |
-
"fluency_100":
|
| 226 |
-
"confidence":
|
|
|
|
| 227 |
"is_silent": False,
|
| 228 |
"self_calibrated": cal.active,
|
| 229 |
"evidence": {
|
| 230 |
"stutter_probs": stut_probs_disp,
|
| 231 |
-
"
|
| 232 |
-
"
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
"articulatory_correlates": articulatory,
|
| 236 |
"articulatory_summary": art_desc,
|
| 237 |
"heuristic_weights_applied": {
|
|
|
|
| 1 |
"""
|
| 2 |
+
ml/model/fusion.py - Multi-modal fusion + per-user self-calibration + margin estimation
|
| 3 |
+
========================================================================================
|
| 4 |
Fuses neural disfluency classification, CTC phonetic alignment, and Praat acoustic
|
| 5 |
+
correlates of phonation into a transparent screening report.
|
| 6 |
|
| 7 |
Design & Scientific Transparency Notes:
|
| 8 |
+
- Heuristic Weighting: The Composite Screening Index (0..100) utilizes transparent
|
| 9 |
+
heuristic weights (40% disfluency, 45% pronunciation, 15% phonation correlates).
|
| 10 |
+
- Single-Shift Calibration: The user's baseline offset is applied consistently at the
|
| 11 |
+
composite level to prevent compounding discount errors.
|
| 12 |
+
- Prediction Margin: Quantifies distance from the decision boundary (|P - 0.5| * 200%).
|
| 13 |
+
- Non-Diagnostic Screening: Categorizes into 'typical', 'mild', 'moderate', or 'significant'
|
| 14 |
+
concern bands rather than definitive clinical severity diagnoses.
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
from typing import Optional, Dict, Any, Tuple
|
| 18 |
|
| 19 |
import numpy as np
|
| 20 |
|
| 21 |
+
CONCERN_BANDS = ["typical", "mild", "moderate", "significant", "silent", "uncertain"]
|
| 22 |
|
| 23 |
+
# Empirical screening thresholds
|
| 24 |
THRESHOLD_MILD = 0.60
|
| 25 |
THRESHOLD_MODERATE = 0.78
|
| 26 |
+
THRESHOLD_SIGNIFICANT = 0.90
|
| 27 |
BORDERLINE_BAND = (0.42, 0.58)
|
| 28 |
|
| 29 |
|
|
|
|
| 32 |
return "silent"
|
| 33 |
if sev == 5:
|
| 34 |
return "uncertain"
|
| 35 |
+
return CONCERN_BANDS[min(max(sev, 0), 3)]
|
| 36 |
|
| 37 |
|
| 38 |
+
def severity_from_softmax(probs) -> Tuple[Optional[int], float, str, float]:
|
| 39 |
"""
|
| 40 |
+
0..3 concern band from a stutter softmax with prediction margin estimation.
|
| 41 |
+
Returns: (concern_band_index, p_stutter, margin_label, margin_pct)
|
| 42 |
"""
|
| 43 |
if probs is None:
|
| 44 |
+
return None, 0.0, "unknown", 0.0
|
| 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", 0.0
|
| 50 |
|
| 51 |
p_stutter = float(p[1]) if len(p) == 2 else float(np.sum(p[1:]))
|
| 52 |
+
margin_pct = round(abs(p_stutter - 0.50) * 200.0, 1) # 0% at 0.50 tossup, 100% at 0.0 or 1.0
|
| 53 |
+
|
|
|
|
| 54 |
if BORDERLINE_BAND[0] <= p_stutter <= BORDERLINE_BAND[1]:
|
| 55 |
+
margin_label = f"Borderline Margin ({margin_pct:.0f}%)"
|
| 56 |
+
elif margin_pct < 40.0:
|
| 57 |
+
margin_label = f"Moderate Margin ({margin_pct:.0f}%)"
|
| 58 |
else:
|
| 59 |
+
margin_label = f"High Margin ({margin_pct:.0f}%)"
|
| 60 |
|
| 61 |
if p_stutter < THRESHOLD_MILD:
|
| 62 |
+
return 0, p_stutter, margin_label, margin_pct
|
| 63 |
elif p_stutter < THRESHOLD_MODERATE:
|
| 64 |
+
return 1, p_stutter, margin_label, margin_pct
|
| 65 |
+
elif p_stutter < THRESHOLD_SIGNIFICANT:
|
| 66 |
+
return 2, p_stutter, margin_label, margin_pct
|
| 67 |
else:
|
| 68 |
+
return 3, p_stutter, margin_label, margin_pct
|
| 69 |
|
| 70 |
|
| 71 |
def articulation_severity(articulatory: dict) -> Tuple[int, str]:
|
| 72 |
"""
|
| 73 |
+
0..3 roughness index from acoustic correlates of phonation (Praat PointProcess).
|
| 74 |
+
Returns: (concern_index, summary_string)
|
| 75 |
"""
|
| 76 |
if articulatory.get("is_silent"):
|
| 77 |
return 4, "silent"
|
| 78 |
+
if not articulatory.get("is_valid", True):
|
| 79 |
+
return 0, "Acoustic features unmeasured"
|
| 80 |
+
|
| 81 |
s = 0
|
| 82 |
flags = []
|
| 83 |
+
hnr = articulatory.get("hnr_db")
|
| 84 |
+
jitter = articulatory.get("jitter")
|
| 85 |
+
voiced_ratio = articulatory.get("voiced_ratio")
|
| 86 |
+
|
| 87 |
+
if hnr is not None and hnr < 9.0:
|
| 88 |
s += 1
|
| 89 |
+
flags.append(f"Low HNR ({hnr:.1f} dB)")
|
| 90 |
+
if jitter is not None and jitter > 0.05:
|
| 91 |
s += 1
|
| 92 |
+
flags.append(f"High Jitter ({jitter*100:.1f}%)")
|
| 93 |
+
if voiced_ratio is not None and voiced_ratio < 0.25:
|
| 94 |
s += 1
|
| 95 |
flags.append("Low Voicing (<25%)")
|
| 96 |
|
|
|
|
| 102 |
# Per-user self-calibration (Single Shift Guarded)
|
| 103 |
# ---------------------------------------------------------------------------
|
| 104 |
class CalibrationProfile:
|
| 105 |
+
"""Per-user baseline offset derived from their own healthy baseline recording."""
|
| 106 |
|
| 107 |
def __init__(self, normal_fluent: Optional[float] = None):
|
| 108 |
self.normal_fluent = normal_fluent
|
|
|
|
| 112 |
return self.normal_fluent is not None
|
| 113 |
|
| 114 |
def shift(self, raw: int) -> int:
|
| 115 |
+
"""Single bucket offset so the user's natural baseline == typical."""
|
| 116 |
if not self.active or raw in (4, 5):
|
| 117 |
return raw
|
| 118 |
+
offset = 1 if (self.normal_fluent is not None and self.normal_fluent < 0.60) else 0
|
| 119 |
return max(0, raw - offset)
|
| 120 |
|
| 121 |
|
|
|
|
| 140 |
weights: Tuple[float, float, float] = (0.40, 0.45, 0.15),
|
| 141 |
) -> dict:
|
| 142 |
"""
|
| 143 |
+
Fuse all modalities into an auditable screening assessment dictionary.
|
| 144 |
+
Single-shift calibration applied consistently at the composite level.
|
| 145 |
"""
|
| 146 |
cal = calibration or CalibrationProfile()
|
| 147 |
w_stut, w_pron, w_art = weights
|
|
|
|
| 162 |
"overall": "silent",
|
| 163 |
},
|
| 164 |
"fluency_100": None,
|
| 165 |
+
"confidence": "High (Silence Confirmed)",
|
| 166 |
+
"prediction_margin": 100.0,
|
| 167 |
"is_silent": True,
|
| 168 |
"self_calibrated": False,
|
| 169 |
"evidence": {
|
|
|
|
| 171 |
},
|
| 172 |
}
|
| 173 |
|
| 174 |
+
# 2. Raw Subsystem Concern Bands
|
| 175 |
+
sev, stut_prob_val, margin_label, margin_pct = severity_from_softmax(probs)
|
| 176 |
if sev is None:
|
| 177 |
stut_bucket, stut_prob_val, stut_probs_disp = None, 0.0, None
|
| 178 |
stut_loss = 0.0
|
| 179 |
else:
|
| 180 |
+
stut_bucket = sev
|
| 181 |
p = np.asarray(probs, dtype=float)
|
| 182 |
stut_probs_disp = p.tolist()
|
|
|
|
| 183 |
stut_loss = max(0.0, (stut_prob_val - 0.45) / 0.55)
|
| 184 |
|
| 185 |
+
# Pronunciation penalty from phonetic alignment
|
| 186 |
pr = pronunciation.get("pron_score") if isinstance(pronunciation, dict) else None
|
| 187 |
if pr is not None:
|
| 188 |
pron_loss = 1.0 - float(pr)
|
| 189 |
if pron_loss < 0.15:
|
| 190 |
+
pron_bucket = 0 # typical
|
| 191 |
elif pron_loss < 0.40:
|
| 192 |
pron_bucket = 1 # mild
|
| 193 |
elif pron_loss < 0.70:
|
| 194 |
pron_bucket = 2 # moderate
|
| 195 |
else:
|
| 196 |
+
pron_bucket = 3 # significant
|
| 197 |
else:
|
| 198 |
pron_loss = 0.0
|
| 199 |
pron_bucket = None
|
|
|
|
| 202 |
art_bucket, art_desc = articulation_severity(articulatory)
|
| 203 |
art_loss = art_bucket / 3.0 if art_bucket != 4 else 0.0
|
| 204 |
|
| 205 |
+
# Composite Screening Index (0..100)
|
| 206 |
if sev is not None and pr is not None:
|
| 207 |
+
composite_score = int(100.0 * max(0.0, 1.0 - (w_stut * stut_loss + w_pron * pron_loss + w_art * art_loss)))
|
| 208 |
elif sev is not None:
|
| 209 |
+
composite_score = int(100.0 * max(0.0, 1.0 - (0.75 * stut_loss + 0.25 * art_loss)))
|
| 210 |
elif pr is not None:
|
| 211 |
+
composite_score = int(100.0 * max(0.0, 1.0 - (0.75 * pron_loss + 0.25 * art_loss)))
|
| 212 |
else:
|
| 213 |
+
composite_score = int(100.0 * max(0.0, 1.0 - art_loss))
|
| 214 |
|
| 215 |
+
# Adjust score if calibrated
|
| 216 |
+
if cal.active and cal.normal_fluent is not None and cal.normal_fluent < 0.60:
|
| 217 |
+
composite_score = min(100, composite_score + 10)
|
| 218 |
+
|
| 219 |
+
composite_score = max(0, min(100, composite_score))
|
| 220 |
|
| 221 |
+
# Single shift applied consistently at the composite overall rating
|
| 222 |
present = [b for b in (stut_bucket, pron_bucket, art_bucket) if b is not None and b != 4]
|
| 223 |
overall_raw = max(present) if present else 0
|
| 224 |
overall = cal.shift(overall_raw) if cal.active else overall_raw
|
|
|
|
| 230 |
"articulation": bucket_of(art_bucket),
|
| 231 |
"overall": bucket_of(overall),
|
| 232 |
},
|
| 233 |
+
"fluency_100": composite_score,
|
| 234 |
+
"confidence": margin_label,
|
| 235 |
+
"prediction_margin": margin_pct,
|
| 236 |
"is_silent": False,
|
| 237 |
"self_calibrated": cal.active,
|
| 238 |
"evidence": {
|
| 239 |
"stutter_probs": stut_probs_disp,
|
| 240 |
+
"stutter_concern_raw": sev,
|
| 241 |
+
"overall_concern_raw": overall_raw,
|
| 242 |
+
"overall_concern_calibrated": overall,
|
| 243 |
+
"pron_score": pr,
|
| 244 |
"articulatory_correlates": articulatory,
|
| 245 |
"articulatory_summary": art_desc,
|
| 246 |
"heuristic_weights_applied": {
|