notUbaid commited on
Commit
9e475da
·
verified ·
1 Parent(s): aaddb6f

Upload ml/model/fusion.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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
- The heart of the "not over-strict" requirement. It fuses every modality into
5
- ONE diagnosis, graded into coarse buckets, and calibrates those bucket
6
- thresholds against the user's own "my normal" recording so a natural-speech
7
- baseline is not flagged as disordered.
8
-
9
- Honesty & Clinical Realism:
10
- - Silence Guard: Pure silence or missing speech returns 'silent' status
11
- rather than penalizing the user with a severe disease rating.
12
- - Stutter probability P(stutter) is mapped into clinical buckets:
13
- P < 0.60 -> fluent (healthy speech cadence)
14
- 0.60 <= P < 0.78 -> mild (occasional repetition or micro-hesitation)
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 | None:
34
- """0..3 clinical severity from a stutter softmax."""
 
 
 
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
- if p_stutter < 0.60:
45
- return 0
46
- elif p_stutter < 0.78:
47
- return 1
48
- elif p_stutter < 0.90:
49
- return 2
 
 
 
 
 
 
 
 
 
 
50
  else:
51
- return 3
52
 
53
 
54
- def articulation_severity(articulatory: dict) -> int:
55
- """0..3 roughness from real Praat values."""
 
 
 
56
  if articulatory.get("is_silent"):
57
- return 4
58
  s = 0
59
- if articulatory.get("hnr_db", 20) < 9.0: s += 1
60
- if articulatory.get("jitter", 0.0) > 0.05: s += 1
61
- if articulatory.get("voiced_ratio", 1.0) < 0.25: s += 1
62
- return min(s, 3)
 
 
 
 
 
 
 
 
 
63
 
64
 
65
  # ---------------------------------------------------------------------------
66
  # Per-user self-calibration
67
  # ---------------------------------------------------------------------------
68
  class CalibrationProfile:
69
- """Per-user bucket-offset derived from their own 'normal' recording."""
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 == 4:
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
- """Fuse all modalities into a single human-facing diagnosis dict."""
 
 
 
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 detected in audio. Please speak clearly into your microphone.",
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
- # Articulation penalty
163
- art_bucket = articulation_severity(articulatory)
164
  art_loss = art_bucket / 3.0 if art_bucket != 4 else 0.0
165
 
166
- # Clinically realistic 0..100 Fluency Index
167
  if sev is not None and pr is not None:
168
- fluency = int(100.0 * max(0.0, 1.0 - (0.40 * stut_loss + 0.45 * pron_loss + 0.15 * art_loss)))
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
- "stutter_severity": sev,
195
  "pron_goodness": pr,
196
- "articulatory": articulatory,
197
- "normal_fluent": cal.normal_fluent,
198
- "shift_applied": cal.active and cal.normal_fluent is not None and cal.normal_fluent < 0.60,
 
 
 
 
 
 
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
  }