-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathviterbi.py
More file actions
278 lines (221 loc) · 11.5 KB
/
Copy pathviterbi.py
File metadata and controls
278 lines (221 loc) · 11.5 KB
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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
"""
viterbi.py — Pass 4: holistic Viterbi sequence decoder.
4-state left-to-right Bakis HMM:
State 0: S1 (first heart sound transient)
State 1: systole (S1→S2 interval)
State 2: S2 (second heart sound transient)
State 3: diastole (S2→S1 interval; longest phase)
Transition topology (no reverse transitions):
S1 → S1 (self-loop while in S1 event)
S1 → systole
systole → systole
systole → S2
S2 → S2
S2 → diastole
diastole → diastole
diastole → S1 (next cardiac cycle)
Self-loop probabilities are derived from expected state durations so that
the decoder implicitly models duration without an explicit duration model.
Emissions come from analysis_data["pass3_emissions"] (shape T×3):
column 0 → P(S1|t)
column 1 → P(S2|t)
column 2 → P(Noise|t)
Columns 1 and 3 (systole and diastole) both map to P(Noise|t) since those
are transition/silence phases; see _build_4state_log_obs().
Entry point: run_pass4_viterbi()
"""
import logging
from typing import Dict, List, Optional, Tuple
import numpy as np
from confidence_engine import calculate_bpm_intervals
# ─────────────────────────────────────────────────────────────────────────────
# State indices
# ─────────────────────────────────────────────────────────────────────────────
STATE_S1 = 0
STATE_SYSTOLE = 1
STATE_S2 = 2
STATE_DIASTOLE = 3
N_STATES = 4
# ─────────────────────────────────────────────────────────────────────────────
# Transition matrix
# ─────────────────────────────────────────────────────────────────────────────
def build_transition_matrix(bpm: float, sample_rate: int, params: Dict) -> np.ndarray:
"""
Build a 4×4 left-to-right transition matrix for the cardiac cycle HMM.
Self-loop probability for each state is derived from the expected state
duration (in samples): p_self = 1 - 1/duration_samples. The complement
probability is placed on the single allowed forward transition.
Returns log-probability matrix (4×4) in natural log. -inf for
disallowed transitions.
"""
ivs = calculate_bpm_intervals(bpm, params)
sl_weight = float(params.get("pass4_transition_self_loop_weight", 0.85))
s1_dur_samp = max(2, int(round(float(ivs.get("s1_nominal", 0.040)) * sample_rate)))
sys_dur_samp = max(2, int(round(float(ivs.get("s1_s2_nominal", 0.300)) * sample_rate)))
s2_dur_samp = max(2, int(round(float(ivs.get("s2_nominal", 0.030)) * sample_rate)))
dia_dur_samp = max(2, int(round(float(ivs.get("s2_s1_nominal", 0.400)) * sample_rate)))
def _self_loop(dur: int) -> float:
"""p_self ∝ expected duration, clamped by sl_weight."""
raw = 1.0 - 1.0 / dur
return float(np.clip(sl_weight * raw / max(raw, 1e-10) * raw, 0.50, 0.999))
p_s1_self = _self_loop(s1_dur_samp)
p_sys_self = _self_loop(sys_dur_samp)
p_s2_self = _self_loop(s2_dur_samp)
p_dia_self = _self_loop(dia_dur_samp)
_neg_inf = float("-inf")
T = np.full((N_STATES, N_STATES), _neg_inf, dtype=np.float64)
# S1 row
T[STATE_S1, STATE_S1] = np.log(p_s1_self)
T[STATE_S1, STATE_SYSTOLE] = np.log(1.0 - p_s1_self)
# systole row
T[STATE_SYSTOLE, STATE_SYSTOLE] = np.log(p_sys_self)
T[STATE_SYSTOLE, STATE_S2] = np.log(1.0 - p_sys_self)
# S2 row
T[STATE_S2, STATE_S2] = np.log(p_s2_self)
T[STATE_S2, STATE_DIASTOLE] = np.log(1.0 - p_s2_self)
# diastole row
T[STATE_DIASTOLE, STATE_DIASTOLE] = np.log(p_dia_self)
T[STATE_DIASTOLE, STATE_S1] = np.log(1.0 - p_dia_self)
return T
# ─────────────────────────────────────────────────────────────────────────────
# Observation mapping
# ─────────────────────────────────────────────────────────────────────────────
def _build_4state_log_obs(emissions: np.ndarray, params: Dict) -> np.ndarray:
"""
Map 3-column emissions [P_S1, P_S2, P_Noise] to 4-state log-obs matrix.
State–emission mapping:
S1 → emissions[:, 0] (P_S1)
systole → emissions[:, 2] (P_Noise)
S2 → emissions[:, 1] (P_S2)
diastole → emissions[:, 2] (P_Noise)
The emission_weight param blends spectral evidence with a uniform prior.
"""
T = len(emissions)
w = float(np.clip(params.get("pass4_emission_weight", 0.7), 0.0, 1.0))
uniform = 0.25 # uniform over 4 states → 0.25 each
obs = np.zeros((T, N_STATES), dtype=np.float64)
obs[:, STATE_S1] = w * emissions[:, 0] + (1.0 - w) * uniform
obs[:, STATE_SYSTOLE] = w * emissions[:, 2] + (1.0 - w) * uniform
obs[:, STATE_S2] = w * emissions[:, 1] + (1.0 - w) * uniform
obs[:, STATE_DIASTOLE] = w * emissions[:, 2] + (1.0 - w) * uniform
# Normalize rows so they sum to 1, then take log.
row_sums = obs.sum(axis=1, keepdims=True) + 1e-30
log_obs = np.log(obs / row_sums)
return log_obs
# ─────────────────────────────────────────────────────────────────────────────
# Core Viterbi decoder
# ─────────────────────────────────────────────────────────────────────────────
def run_viterbi(
log_obs: np.ndarray, # shape (T, N_STATES) log emission probs
log_trans: np.ndarray, # shape (N_STATES, N_STATES)
log_prior: np.ndarray, # shape (N_STATES,)
) -> np.ndarray: # shape (T,) best state sequence
"""
Standard log-domain Viterbi algorithm.
All inputs are expected to be in natural-log probability space.
-inf entries in log_trans correctly prevent disallowed transitions.
"""
T, K = log_obs.shape
viterbi = np.full((T, K), float("-inf"), dtype=np.float64)
backptr = np.zeros((T, K), dtype=np.int32)
viterbi[0] = log_prior + log_obs[0]
for t in range(1, T):
for s in range(K):
trans_scores = viterbi[t - 1] + log_trans[:, s]
best_prev = int(np.argmax(trans_scores))
viterbi[t, s] = trans_scores[best_prev] + log_obs[t, s]
backptr[t, s] = best_prev
# Backtrack
path = np.zeros(T, dtype=np.int32)
path[T - 1] = int(np.argmax(viterbi[T - 1]))
for t in range(T - 2, -1, -1):
path[t] = backptr[t + 1, path[t + 1]]
return path
# ─────────────────────────────────────────────────────────────────────────────
# S1 peak extraction from Viterbi path
# ─────────────────────────────────────────────────────────────────────────────
def _extract_s1_peaks_from_path(
path: np.ndarray,
audio_envelope: np.ndarray,
sample_rate: int,
params: Dict,
) -> np.ndarray:
"""
Find each contiguous run of S1 state in path and pick the sample with the
highest audio_envelope value within that run as the S1 peak position.
"""
n = len(path)
peaks: List[int] = []
i = 0
while i < n:
if path[i] != STATE_S1:
i += 1
continue
j = i
while j < n and path[j] == STATE_S1:
j += 1
# Run is [i, j); pick argmax amplitude within that run.
seg = audio_envelope[i:j]
if len(seg) > 0:
peaks.append(i + int(np.argmax(seg)))
i = j
return np.asarray(peaks, dtype=np.int64) if peaks else np.empty(0, dtype=np.int64)
# ─────────────────────────────────────────────────────────────────────────────
# Public entry point
# ─────────────────────────────────────────────────────────────────────────────
def run_pass4_viterbi(
s1_peaks_in: np.ndarray,
analysis_data: Dict,
audio_envelope: np.ndarray,
sample_rate: int,
params: Dict,
) -> Tuple[np.ndarray, Dict]:
"""
Run the Pass 4 Viterbi holistic decoder.
Requires analysis_data["pass3_emissions"] (see archived emissions generator in pass3 archived logic.md).
Falls back to returning s1_peaks_in unchanged if emissions are unavailable.
Stores analysis_data["pass4_state_sequence"] (int32 array, length = n_samples)
and analysis_data["pass4_s1_peaks"] (int64 array).
Returns (refined_s1_peaks, updated_analysis_data).
"""
emissions = analysis_data.get("pass3_emissions")
if emissions is None or not isinstance(emissions, np.ndarray) or emissions.ndim != 2:
logging.warning(
"Pass 4 Viterbi: pass3_emissions not available. Returning Pass 3 peaks unchanged."
)
return s1_peaks_in, analysis_data
T = len(emissions)
if T == 0:
return s1_peaks_in, analysis_data
# Build BPM estimate from Pass 3 peaks for the transition matrix.
bpm = 80.0
try:
rr_arr = np.diff(s1_peaks_in.astype(np.float64)) / float(sample_rate)
rr_arr = rr_arr[np.isfinite(rr_arr) & (rr_arr > 0)]
if len(rr_arr) > 0:
fb = float(60.0 / np.median(rr_arr))
if np.isfinite(fb) and fb > 0:
bpm = fb
except Exception:
pass
log_trans = build_transition_matrix(bpm, sample_rate, params)
# Prior: start in diastole (most of the time between beats) or uniform.
log_prior = np.log(np.array([0.10, 0.05, 0.05, 0.80], dtype=np.float64))
log_obs = _build_4state_log_obs(emissions, params)
logging.info(
"Pass 4 Viterbi: decoding %d samples at BPM=%.1f...", T, bpm,
)
path = run_viterbi(log_obs, log_trans, log_prior)
s1_peaks_out = _extract_s1_peaks_from_path(path, audio_envelope, sample_rate, params)
if len(s1_peaks_out) < 2:
logging.warning(
"Pass 4 Viterbi: only %d S1 peaks extracted — keeping Pass 3 result.",
len(s1_peaks_out),
)
s1_peaks_out = s1_peaks_in
analysis_data["pass4_state_sequence"] = path.astype(np.int32)
analysis_data["pass4_s1_peaks"] = s1_peaks_out
logging.info(
"Pass 4 Viterbi: extracted %d S1 peaks from path.", len(s1_peaks_out),
)
return s1_peaks_out, analysis_data