notUbaid commited on
Commit
f890b16
·
verified ·
1 Parent(s): 477898b

Upload ml/model/fusion.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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 + 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
- - Single-Shift Calibration: The user's baseline offset is applied exactly ONCE
12
- at the overall decision level to prevent compounding discount errors.
13
- - Uncertainty Estimation: Flags borderline classification regions and noisy
14
- recordings with explicit confidence scoring rather than overconfident assertions.
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
- BUCKETS = ["fluent", "mild", "moderate", "severe", "silent", "uncertain"]
24
 
25
- # Configurable empirical thresholds
26
  THRESHOLD_MILD = 0.60
27
  THRESHOLD_MODERATE = 0.78
28
- THRESHOLD_SEVERE = 0.90
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 BUCKETS[min(max(sev, 0), 3)]
38
 
39
 
40
- def severity_from_softmax(probs) -> Tuple[Optional[int], float, str]:
41
  """
42
- 0..3 clinical severity from a stutter softmax with uncertainty tracking.
43
- Returns: (severity_bucket, p_stutter, confidence_rating)
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
- # Uncertainty / Confidence estimation
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
- confidence = "low (borderline transition)"
59
- elif margin < 0.35:
60
- confidence = "moderate"
61
  else:
62
- confidence = "high"
63
 
64
  if p_stutter < THRESHOLD_MILD:
65
- return 0, p_stutter, confidence
66
  elif p_stutter < THRESHOLD_MODERATE:
67
- return 1, p_stutter, confidence
68
- elif p_stutter < THRESHOLD_SEVERE:
69
- return 2, p_stutter, confidence
70
  else:
71
- return 3, p_stutter, confidence
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: (severity_index, summary_string)
78
  """
79
  if articulatory.get("is_silent"):
80
  return 4, "silent"
 
 
 
81
  s = 0
82
  flags = []
83
- if articulatory.get("hnr_db", 20) < 9.0:
 
 
 
 
84
  s += 1
85
- flags.append("Low HNR (<9 dB)")
86
- if articulatory.get("jitter", 0.0) > 0.05:
87
  s += 1
88
- flags.append("High Jitter (>5%)")
89
- if articulatory.get("voiced_ratio", 1.0) < 0.25:
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 bucket-offset derived from their own healthy baseline recording."""
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 == fluent."""
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 diagnostic screening dictionary.
140
- Single-shift calibration applied strictly at the composite level.
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": "high (silence confirmed)",
 
162
  "is_silent": True,
163
  "self_calibrated": False,
164
  "evidence": {
@@ -166,30 +171,29 @@ def diag_statistics(
166
  },
167
  }
168
 
169
- # 2. Raw Subsystem Severities (Unshifted at modality level)
170
- sev, stut_prob_val, confidence_rating = severity_from_softmax(probs)
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 # Raw unshifted severity
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 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)
185
  if pron_loss < 0.15:
186
- pron_bucket = 0 # fluent
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 # severe
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 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:
207
- fluency = int(100.0 * max(0.0, 1.0 - (0.75 * pron_loss + 0.25 * art_loss)))
208
  else:
209
- fluency = int(100.0 * max(0.0, 1.0 - art_loss))
210
 
211
- fluency = max(0, min(100, fluency))
 
 
 
 
212
 
213
- # Single shift applied exactly once at the overall rating
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": fluency,
226
- "confidence": confidence_rating,
 
227
  "is_silent": False,
228
  "self_calibrated": cal.active,
229
  "evidence": {
230
  "stutter_probs": stut_probs_disp,
231
- "stutter_severity_raw": sev,
232
- "overall_severity_raw": overall_raw,
233
- "overall_severity_calibrated": overall,
234
- "pron_goodness": pr,
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": {