JointRecAlign 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ """
2
+ Phone-alignment metrics.
3
+
4
+ Input: an alignment store {file_id: {ref_intervals, hyp_intervals, ref_seq, hyp_seq, style}},
5
+ times in SECONDS; all reported errors are in milliseconds.
6
+
7
+ - Boundary F1 at several tolerances: the boundary set is every phone start
8
+ and every phone end, deduplicated (the end of a phone followed by a pause
9
+ is a boundary too); each REFERENCE boundary is
10
+ matched to the NEAREST unused hypothesis boundary within the tolerance
11
+ (one-to-one); TP/FP/FN are pooled over all utterances before P, R and F1.
12
+ - Boundary errors on phones matched by edit distance (same label only):
13
+ mean / median / P90, rates of errors > 50 ms and > 100 ms, signed mean
14
+ shift of starts and ends, duration error.
15
+ - PER of the hypothesis phone sequence.
16
+
17
+ Sequences are aligned on a shared character encoding, so multi-character
18
+ phone tokens (IPA strings) are compared correctly. Group statistics are pooled
19
+ over every phone of the group (micro-average).
20
+ """
21
+
22
+ import numpy as np
23
+ import Levenshtein
24
+ from jiwer import process_words
25
+
26
+
27
+ # ----------------------------------------------------------------------
28
+ # Sequence alignment (Levenshtein), robust to multi-char phoneme tokens
29
+ # ----------------------------------------------------------------------
30
+ def _editops(ref, hyp):
31
+ """editops on a shared char-encoding so IPA tokens of any length work."""
32
+ vocab = {}
33
+
34
+ def enc(seq):
35
+ out = []
36
+ for t in seq:
37
+ if t not in vocab:
38
+ vocab[t] = chr(0xE000 + len(vocab)) # private-use code points
39
+ out.append(vocab[t])
40
+ return "".join(out)
41
+
42
+ return Levenshtein.editops(enc(ref), enc(hyp))
43
+
44
+
45
+ def align_sequences(ref, hyp):
46
+ """Return list of (ref_idx, hyp_idx); None on either side = del/ins."""
47
+ alignment = []
48
+ ops = _editops(ref, hyp)
49
+ ref_idx = hyp_idx = op_idx = 0
50
+
51
+ while ref_idx < len(ref) or hyp_idx < len(hyp):
52
+ if op_idx < len(ops):
53
+ op_type, src_pos, dest_pos = ops[op_idx]
54
+ if op_type == "delete" and src_pos == ref_idx:
55
+ alignment.append((ref_idx, None)); ref_idx += 1; op_idx += 1; continue
56
+ elif op_type == "insert" and dest_pos == hyp_idx:
57
+ alignment.append((None, hyp_idx)); hyp_idx += 1; op_idx += 1; continue
58
+ elif op_type == "replace" and src_pos == ref_idx and dest_pos == hyp_idx:
59
+ alignment.append((ref_idx, hyp_idx)); ref_idx += 1; hyp_idx += 1; op_idx += 1; continue
60
+ if ref_idx < len(ref) and hyp_idx < len(hyp):
61
+ alignment.append((ref_idx, hyp_idx)); ref_idx += 1; hyp_idx += 1
62
+ elif ref_idx < len(ref):
63
+ alignment.append((ref_idx, None)); ref_idx += 1
64
+ elif hyp_idx < len(hyp):
65
+ alignment.append((None, hyp_idx)); hyp_idx += 1
66
+ return alignment
67
+
68
+
69
+ # ----------------------------------------------------------------------
70
+ # Per-phoneme errors (only phoneme-matched pairs contribute boundary error)
71
+ # ----------------------------------------------------------------------
72
+ def per_phoneme_errors(ref_alignments, hyp_alignments, ref_seq, hyp_seq):
73
+ """One record per correctly-matched phoneme. Times in seconds in -> ms out."""
74
+ alignment = align_sequences(ref_seq, hyp_seq)
75
+ records = []
76
+ for ref_idx, hyp_idx in alignment:
77
+ if ref_idx is None or hyp_idx is None:
78
+ continue
79
+ if ref_seq[ref_idx] != hyp_seq[hyp_idx]:
80
+ continue
81
+ rs, re = ref_alignments[ref_idx]["start"], ref_alignments[ref_idx]["end"]
82
+ hs, he = hyp_alignments[hyp_idx]["start"], hyp_alignments[hyp_idx]["end"]
83
+ records.append({
84
+ "phoneme": ref_seq[ref_idx],
85
+ # signed (keep direction): + = hypothesis is LATE
86
+ "signed_start": (hs - rs) * 1000.0,
87
+ "signed_end": (he - re) * 1000.0,
88
+ # absolute magnitudes
89
+ "start_err": abs(hs - rs) * 1000.0,
90
+ "end_err": abs(he - re) * 1000.0,
91
+ "dur_err": abs((he - hs) - (re - rs)) * 1000.0,
92
+ "mid_err": abs((hs + he) / 2 - (rs + re) / 2) * 1000.0,
93
+ })
94
+ return records
95
+
96
+
97
+ # ----------------------------------------------------------------------
98
+ # Boundary-detection F1 (time-only, label-agnostic).
99
+ #
100
+ # Boundary set: every phone start and every phone end, deduplicated.
101
+ # Taking all starts AND all ends then deduplicating is equivalent to
102
+ # "starts + last end" under a contiguous segmentation (phone n's end IS
103
+ # phone n+1's start), but stays correct when a pause leaves a gap between
104
+ # consecutive intervals, where "starts + last end" would silently drop the
105
+ # offset preceding the gap.
106
+ #
107
+ # Matching: each REFERENCE boundary takes the NEAREST unused hypothesis
108
+ # boundary within the tolerance, one-to-one.
109
+ # FP = hypothesis boundaries left unmatched, FN = reference boundaries with
110
+ # no hypothesis boundary within tolerance. A boundary that exists but is
111
+ # outside tolerance therefore costs one FP and one FN.
112
+ #
113
+ # tolerance is in SECONDS.
114
+ # ----------------------------------------------------------------------
115
+ def boundary_times(intervals):
116
+ b = [iv["start"] for iv in intervals] + [iv["end"] for iv in intervals]
117
+ return sorted(set(b))
118
+
119
+
120
+ def compute_f1_counts(ref_bounds, pred_bounds, tolerance):
121
+ ref_bounds = np.sort(np.asarray(ref_bounds, dtype=float))
122
+ pred_bounds = np.sort(np.asarray(pred_bounds, dtype=float))
123
+ used = np.zeros(len(pred_bounds), dtype=bool)
124
+ TP = 0
125
+ for rb in ref_bounds:
126
+ if not len(pred_bounds):
127
+ break
128
+ d = np.abs(pred_bounds - rb)
129
+ d[used] = np.inf
130
+ j = int(d.argmin())
131
+ if d[j] <= tolerance:
132
+ used[j] = True
133
+ TP += 1
134
+ FP = len(pred_bounds) - TP
135
+ FN = len(ref_bounds) - TP
136
+ return TP, FP, FN
137
+
138
+
139
+ def _f1(tp, fp, fn):
140
+ p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
141
+ r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
142
+ return 2 * p * r / (p + r) * 100 if (p + r) > 0 else 0.0
143
+
144
+
145
+ # ----------------------------------------------------------------------
146
+ # Pooled boundary statistics over a set of per-phoneme records (ms).
147
+ # ----------------------------------------------------------------------
148
+ def boundary_stats(df):
149
+ import pandas as pd # evaluation only (pip install ".[eval]")
150
+ if len(df) == 0:
151
+ return {}
152
+ s, e, d = df["start_err"], df["end_err"], df["dur_err"]
153
+ both = pd.concat([s, e]) # AAS / tail pool start AND end boundaries
154
+ return {
155
+ "K": int(len(df)),
156
+ # --- central ---
157
+ "AAS (ms)": both.mean(), # mean |error| over all boundaries
158
+ "MedianBE (ms)": both.median(), # median |error| over all boundaries
159
+ "Median_start (ms)": s.median(),
160
+ "Mean_start (ms)": s.mean(),
161
+ "Median_end (ms)": e.median(),
162
+ "Mean_end (ms)": e.mean(),
163
+ # --- tail: pooled over start+end ---
164
+ "P90 (ms)": both.quantile(0.90),
165
+ "%>50ms": (both > 50).mean() * 100, # gross-error rate
166
+ "%>100ms": (both > 100).mean() * 100,
167
+ "%within20ms": (both <= 20).mean() * 100,
168
+ # --- direction of bias ---
169
+ "SignedMean_start (ms)": df["signed_start"].mean(), # + = hyp late
170
+ "SignedMean_end (ms)": df["signed_end"].mean(),
171
+ # --- duration ---
172
+ "Mean_dur (ms)": d.mean(),
173
+ "Median_dur (ms)": d.median(),
174
+ "%dur>50ms": (d > 50).mean() * 100,
175
+ }
176
+
177
+
178
+ # ----------------------------------------------------------------------
179
+ # Main entry point
180
+ # ----------------------------------------------------------------------
181
+ def compute_metrics(alignment_store, csv_path, per_phoneme_csv=None,
182
+ f1_tolerances=(0.02, 0.05)):
183
+ """
184
+ alignment_store: {file_id: {ref_intervals, hyp_intervals, ref_seq, hyp_seq, style}}
185
+ *_intervals: list of {"start": sec, "end": sec}
186
+ *_seq: list of phoneme tokens
187
+ Writes a file/style/global metrics CSV, and optionally a per-phoneme CSV.
188
+ All boundary stats are pooled (micro-averaged) within each group.
189
+ """
190
+ import pandas as pd # evaluation only (pip install ".[eval]")
191
+ all_records = [] # long-form: one row per matched phoneme, tagged with file+style
192
+ f1_rows = [] # per-file F1 counts + PER, pooled later
193
+
194
+ for f, data in alignment_store.items():
195
+ ref_intervals = data["ref_intervals"]
196
+ hyp_intervals = data["hyp_intervals"]
197
+ ref_seq, hyp_seq = data["ref_seq"], data["hyp_seq"]
198
+ style = data.get("style", "ALL")
199
+
200
+ recs = per_phoneme_errors(ref_intervals, hyp_intervals, ref_seq, hyp_seq)
201
+ for r in recs:
202
+ r["file"] = f; r["style"] = style
203
+ all_records.extend(recs)
204
+
205
+ # F1 over all boundary times (label-agnostic boundary detection)
206
+ ref_bounds = boundary_times(ref_intervals)
207
+ pred_bounds = boundary_times(hyp_intervals)
208
+ row = {"file": f, "style": style, "N_ref": len(ref_seq), "N_hyp": len(hyp_seq),
209
+ "B_ref": len(ref_bounds), "B_hyp": len(pred_bounds)}
210
+ for tol in f1_tolerances:
211
+ tp, fp, fn = compute_f1_counts(ref_bounds, pred_bounds, tol)
212
+ tag = f"{int(round(tol * 1000))}"
213
+ row[f"TP_{tag}"], row[f"FP_{tag}"], row[f"FN_{tag}"] = tp, fp, fn
214
+
215
+ out = process_words(" ".join(ref_seq), " ".join(hyp_seq))
216
+ row["S"], row["D"], row["I"] = out.substitutions, out.deletions, out.insertions
217
+ f1_rows.append(row)
218
+
219
+ records_df = pd.DataFrame(all_records)
220
+ f1_df = pd.DataFrame(f1_rows)
221
+ tags = [f"{int(round(t * 1000))}" for t in f1_tolerances]
222
+
223
+ def assemble(rec_subset, f1_subset, label, style_val):
224
+ out = {"group": label, "style": style_val}
225
+ out.update(boundary_stats(rec_subset))
226
+ out["N_ref"] = int(f1_subset["N_ref"].sum())
227
+ out["N_hyp"] = int(f1_subset["N_hyp"].sum())
228
+ out["B_ref"] = int(f1_subset["B_ref"].sum())
229
+ out["B_hyp"] = int(f1_subset["B_hyp"].sum())
230
+ S, D, I = f1_subset["S"].sum(), f1_subset["D"].sum(), f1_subset["I"].sum()
231
+ out["PER (%)"] = (S + D + I) / out["N_ref"] * 100 if out["N_ref"] else np.nan
232
+ out["Deletions"], out["Insertions"] = int(D), int(I)
233
+ for tag in tags:
234
+ tp = f1_subset[f"TP_{tag}"].sum()
235
+ fp = f1_subset[f"FP_{tag}"].sum()
236
+ fn = f1_subset[f"FN_{tag}"].sum()
237
+ out[f"P@{tag}ms (%)"] = tp / (tp + fp) * 100 if (tp + fp) > 0 else np.nan
238
+ out[f"R@{tag}ms (%)"] = tp / (tp + fn) * 100 if (tp + fn) > 0 else np.nan
239
+ out[f"F1@{tag}ms (%)"] = _f1(tp, fp, fn)
240
+ return out
241
+
242
+ rows = []
243
+ # per file
244
+ for f in f1_df["file"]:
245
+ rsub = records_df[records_df["file"] == f] if len(records_df) else records_df
246
+ fsub = f1_df[f1_df["file"] == f]
247
+ rows.append(assemble(rsub, fsub, f, fsub["style"].iloc[0]))
248
+ # per style
249
+ for s, fsub in f1_df.groupby("style"):
250
+ rsub = records_df[records_df["style"] == s] if len(records_df) else records_df
251
+ rows.append(assemble(rsub, fsub, f"STYLE_{s}", s))
252
+ # global
253
+ rows.append(assemble(records_df, f1_df, "GLOBAL", "ALL"))
254
+
255
+ df = pd.DataFrame(rows)
256
+ df.to_csv(csv_path, index=False)
257
+
258
+ # optional per-phoneme table (pooled over all files)
259
+ if per_phoneme_csv is not None and len(records_df):
260
+ ph_rows = []
261
+ for ph, sub in records_df.groupby("phoneme"):
262
+ r = {"phoneme": ph}; r.update(boundary_stats(sub)); ph_rows.append(r)
263
+ pd.DataFrame(ph_rows).sort_values("AAS (ms)", ascending=False)\
264
+ .to_csv(per_phoneme_csv, index=False)
265
+
266
+ return df
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Scoring of latent-SIL checkpoints on an evaluation manifest (nothing is fitted).
5
+
6
+ One model pass per chunk, as attention_eval.run_manifest (CTC greedy target,
7
+ prior 0, eval mode), then two decodes of the same energies:
8
+
9
+ sil Viterbi over the extended topology [SIL] p1 [SIL] p2 ... [SIL]
10
+ (positions = config.sil_eval_positions, default "all": the greedy
11
+ target has no word boundaries), learned key / entry cost, min frames
12
+ as trained. A phone ends where the following SIL starts; SIL frames
13
+ belong to no phone.
14
+ dtw the plain decode (attention_eval.dtw_path on the phone softmax, onsets,
15
+ contiguous ends), for comparison on the same pass.
16
+
17
+ Boundary time of frame f in a chunk starting at cs: cs + f * HOP + offset, with
18
+ HOP and offset read from the model (attention_eval.frame_stride_s,
19
+ frame_time_offset_s): 20 ms / +2.5 ms, 10 ms / +7.5 ms.
20
+
21
+ Metrics: F1@10/20/50, MedianBE, P90, PER (metrics.compute_metrics), duration
22
+ slope/corr (all phones, and in-speech phones only), and, when the manifest has
23
+ pause_intervals, SIL against the reference pauses over each whole file (a pause
24
+ cut by a chunk edge counts once): frame precision/recall/F1 (frame centres,
25
+ excluded_intervals ignored) and pause-interval P/R/F1 at IoU >= 0.5.
26
+ """
27
+ import itertools
28
+ import os
29
+ import tempfile
30
+ import unicodedata
31
+
32
+ import numpy as np
33
+ import torch
34
+
35
+ from .. import sil_fs
36
+ from . import attention_eval as E
37
+ from .metrics import compute_metrics, align_sequences
38
+
39
+ # Set from the model by _set_frame (defaults: 20 ms model). CENTRE = centre of
40
+ # frame 0's receptive field, used for frame-level SIL scoring.
41
+ HOP = 0.02
42
+ CENTRE = 0.0125
43
+
44
+
45
+ def _set_frame(model):
46
+ global HOP, CENTRE
47
+ m = getattr(model, "module", model)
48
+ HOP = E.frame_stride_s(m)
49
+ CENTRE = E.frame_time_offset_s(m) + HOP / 2 # (RF - hop) / 2 + hop / 2 = RF / 2
50
+ return E.frame_time_offset_s(m)
51
+
52
+
53
+ PAUSE_MIN = 0.02 # a reference pause shorter than 20 ms is ignored
54
+ GATE = 0.5 # same gate as attention_eval.duration_fidelity
55
+
56
+
57
+ def _gap_has_pause(pauses, a, b):
58
+ """True if the gap (a, b) between two consecutive reference phones holds
59
+ >= PAUSE_MIN of reference pause (pauses: [(start, end)]; None = every gap
60
+ >= PAUSE_MIN counts as a pause)."""
61
+ if b - a < PAUSE_MIN:
62
+ return False
63
+ if pauses is None:
64
+ return True
65
+ out = []
66
+ for s, e in pauses:
67
+ s2, e2 = max(s, a), min(e, b)
68
+ if e2 - s2 <= 1e-6:
69
+ continue
70
+ if out and s2 - out[-1][1] < 1e-6:
71
+ out[-1] = (out[-1][0], e2)
72
+ else:
73
+ out.append((s2, e2))
74
+ return any(e - s >= PAUSE_MIN for s, e in out)
75
+
76
+
77
+ def duration_fidelity_inspeech(store, pauses_by_file=None, gate=GATE):
78
+ """attention_eval.duration_fidelity restricted to phones with no pause on
79
+ either side."""
80
+ R, Hd = [], []
81
+ for nm, d in store.items():
82
+ refs = d["ref_intervals"]
83
+ pauses = None if pauses_by_file is None else pauses_by_file.get(nm, [])
84
+ N = len(refs)
85
+ for i, j in align_sequences(d["ref_seq"], d["hyp_seq"]):
86
+ if i is None or j is None or d["ref_seq"][i] != d["hyp_seq"][j]:
87
+ continue
88
+ if i == 0 or i == N - 1:
89
+ continue
90
+ r, h = refs[i], d["hyp_intervals"][j]
91
+ if abs(h["start"] - r["start"]) > gate or abs(h["end"] - r["end"]) > gate:
92
+ continue
93
+ if (_gap_has_pause(pauses, refs[i - 1]["end"], r["start"])
94
+ or _gap_has_pause(pauses, r["end"], refs[i + 1]["start"])):
95
+ continue
96
+ R.append((r["end"] - r["start"]) * 1000)
97
+ Hd.append((h["end"] - h["start"]) * 1000)
98
+ if len(R) < 100:
99
+ return {}
100
+ R, Hd = np.array(R), np.array(Hd)
101
+ return {"dur_slope_sp": float(np.polyfit(R, Hd, 1)[0]), "dur_corr_sp": float(np.corrcoef(R, Hd)[0, 1])}
102
+
103
+
104
+ def _segments(states, tokens, cs, offset, sil_frames):
105
+ """Per-frame states -> hypothesis phone intervals. sil_frames collects
106
+ (start, end) of every SIL run (seconds)."""
107
+ ca = []
108
+ for s, grp in itertools.groupby(enumerate(states), key=lambda x: x[1]):
109
+ grp = list(grp)
110
+ f0, f1 = grp[0][0], grp[-1][0] + 1
111
+ t0, t1 = cs + f0 * HOP + offset, cs + f1 * HOP + offset
112
+ if s is None:
113
+ sil_frames.append((t0, t1))
114
+ continue
115
+ ph = tokens[s]
116
+ if ph in E.SKIP_TOK:
117
+ continue
118
+ if len(ph) == 1 and unicodedata.combining(ph):
119
+ if ca:
120
+ ca[-1]["phoneme"] += ph
121
+ ca[-1]["end"] = t1
122
+ continue
123
+ ca.append({"phoneme": E.HYP_FIX.get(ph, ph), "start": t0, "end": t1})
124
+ return ca
125
+
126
+
127
+ @torch.inference_mode()
128
+ def run_manifest_sil(model, feature_extractor, tokenizer, manifest, device="cuda",
129
+ max_files=None, positions=None, offset=None, with_dtw=True):
130
+ """-> (store_sil, store_dtw or None, sil_runs {file: [(s, e)]}, chunks {file: [(s, e)]})
131
+ offset None = the model's frame_time_offset_s."""
132
+ model_offset = _set_frame(model)
133
+ offset = model_offset if offset is None else offset
134
+ m = getattr(model, "module", model)
135
+ positions = positions or getattr(m, "sil_eval_positions", "all")
136
+ k = int(getattr(m, "sil_min_frames", 1))
137
+ sil_logp = float(torch.as_tensor(m.sil_logp).detach()) # a learned parameter in most checkpoints
138
+ was_training = model.training
139
+ model.eval()
140
+ store, store_dtw, sil_runs, chunks = {}, {}, {}, {}
141
+ try:
142
+ for nm, meta in list(manifest["files"].items())[:max_files]:
143
+ wav = torch.from_numpy(E.load_audio(meta["wav"])).float()
144
+ hyp, hyp_dtw, runs = [], [], []
145
+ chunks[nm] = [(float(c["start"]), float(c["end"])) for c in meta["chunks"]]
146
+ for ch in meta["chunks"]:
147
+ s0, s1 = int(ch["start"] * E.SR), int(ch["end"] * E.SR)
148
+ seg = wav[s0:s1]
149
+ if seg.numel() < 400:
150
+ continue
151
+ x = feature_extractor(E.to_model_rate(seg.numpy(), m),
152
+ sampling_rate=feature_extractor.sampling_rate,
153
+ return_tensors="pt", return_attention_mask=True)
154
+ o = model(input_values=x.input_values.to(device),
155
+ attention_mask=x.attention_mask.to(device), labels=None,
156
+ prior_weight=0.0, ctc_weight=0.0, align_weight=0.0,
157
+ contrastive_weight=0.0)
158
+ T, P = int(o.input_lengths[0]), int(o.text_lengths[0])
159
+ if P == 0:
160
+ continue
161
+ cs = s0 / E.SR
162
+ tokens = tokenizer.convert_ids_to_tokens(o.predicted_phone_ids[0, :P].cpu().tolist())
163
+ en = o.energy[0, :T, :P].float()
164
+ sil = o.sil_energy_raw[0, :T].float()
165
+ logp_ext = torch.log_softmax(torch.cat([en, sil[:, None]], -1), -1).cpu().numpy()
166
+ top, path = sil_fs.viterbi(logp_ext, P, sil_fs.slot_mask(P, positions), k, sil_logp)
167
+ if path is not None:
168
+ st = [None if top["col"][s] < 0 else int(top["col"][s]) for s in path]
169
+ hyp.extend(_segments(st, tokens, cs, offset, runs))
170
+ if with_dtw:
171
+ att = np.exp(en.log_softmax(-1).cpu().numpy())
172
+ p2 = E.dtw_path(att)
173
+ if p2 is not None:
174
+ ca = _segments([int(v) for v in p2], tokens, cs, offset, [])
175
+ for i in range(len(ca) - 1): # contiguous ends, as attention_eval
176
+ ca[i]["end"] = ca[i + 1]["start"]
177
+ if ca:
178
+ ca[-1]["end"] = cs + T * HOP + offset
179
+ hyp_dtw.extend(ca)
180
+ for st_, h in ((store, hyp), (store_dtw, hyp_dtw)):
181
+ h = sorted(h, key=lambda z: z["start"])
182
+ st_[nm] = {"ref_intervals": meta["ref_intervals"], "hyp_intervals": h,
183
+ "ref_seq": [z["phoneme"] for z in meta["ref_intervals"]],
184
+ "hyp_seq": [z["phoneme"] for z in h], "style": meta.get("style", "ALL")}
185
+ sil_runs[nm] = runs
186
+ finally:
187
+ model.train(was_training)
188
+ return store, (store_dtw if with_dtw else None), sil_runs, chunks
189
+
190
+
191
+ def _score(store, tag=""):
192
+ tmp = tempfile.mkdtemp()
193
+ df = compute_metrics(store, os.path.join(tmp, "m.csv"), None, f1_tolerances=(0.01, 0.02, 0.05))
194
+ g = df[df.group == "GLOBAL"].iloc[0]
195
+
196
+ def col(*keys):
197
+ for c in df.columns:
198
+ if all(k.lower() in str(c).lower() for k in keys):
199
+ return float(g[c])
200
+ return float("nan")
201
+ return {f"F1@10{tag}": col("F1@10"), f"F1@20{tag}": col("F1@20"), f"F1@50{tag}": col("F1@50"),
202
+ f"MedianBE{tag}": col("median", "BE"), f"P90{tag}": col("P90"), f"PER{tag}": col("PER")}
203
+
204
+
205
+ def _sil_timeline(phones, end):
206
+ """Whole-file SIL runs: the time in [0, end] not covered by a hypothesis phone.
207
+ SIL-Viterbi gives every decoded frame to a phone or to SIL, so this is the
208
+ decoded SIL with a pause cut by a chunk edge joined back into one run, and
209
+ audio left out between chunks counted as SIL."""
210
+ runs, cur = [], 0.0
211
+ for p in sorted(phones, key=lambda z: z["start"]):
212
+ if p["start"] - cur >= HOP / 2:
213
+ runs.append((cur, min(p["start"], end)))
214
+ cur = max(cur, p["end"])
215
+ if end - cur >= HOP / 2:
216
+ runs.append((cur, end))
217
+ return [r for r in runs if r[1] - r[0] >= HOP / 2]
218
+
219
+
220
+ def _frame_counts(a, b, P, X, R):
221
+ """(tp, fp, fn) of SIL frames vs reference pause frames, frame centres in [a, b)."""
222
+ c = np.arange(a, b - HOP / 2, HOP) + CENTRE
223
+ c = c[c < b]
224
+ keep = ~np.any((X[:, :1] <= c) & (c < X[:, 1:]), axis=0) if len(X) else np.ones(len(c), bool)
225
+ ref = np.any((P[:, :1] <= c) & (c < P[:, 1:]), axis=0) if len(P) else np.zeros(len(c), bool)
226
+ hyp = np.any((R[:, :1] <= c) & (c < R[:, 1:]), axis=0) if len(R) else np.zeros(len(c), bool)
227
+ return int((hyp & ref & keep).sum()), int((hyp & ~ref & keep).sum()), int((~hyp & ref & keep).sum())
228
+
229
+
230
+ def _pause_matches(refs, hyps, iou):
231
+ """Reference pauses matched one-to-one (greedy) by a SIL run at IoU >= iou."""
232
+ used, m = set(), 0
233
+ for r in refs:
234
+ best, bj = 0.0, None
235
+ for j, h in enumerate(hyps):
236
+ if j in used:
237
+ continue
238
+ inter = max(0.0, min(r[1], h[1]) - max(r[0], h[0]))
239
+ if inter <= 0:
240
+ continue
241
+ u = max(r[1], h[1]) - min(r[0], h[0])
242
+ if inter / u > best:
243
+ best, bj = inter / u, j
244
+ if bj is not None and best >= iou:
245
+ used.add(bj)
246
+ m += 1
247
+ return m
248
+
249
+
250
+ def sil_scores(manifest, sil_runs, chunks, store=None, iou=0.5, mode="file"):
251
+ """SIL vs reference pauses (manifest pause_intervals).
252
+
253
+ mode "file" (default, needs store = the SIL-Viterbi alignment store): one SIL
254
+ timeline per file (_sil_timeline over [0, last chunk end]), frames over the
255
+ whole file, every reference pause.
256
+ mode "chunk" (the scores before Oct 2026, kept to compare with older runs):
257
+ SIL runs per chunk, frames inside chunks only, reference pauses only if
258
+ inside one chunk. Biased low by cuts: a pause crossing a chunk edge leaves
259
+ the reference while the SIL on each side of the edge counts as a false
260
+ positive, so the more chunks, the lower the pause precision."""
261
+ if mode not in ("file", "chunk"):
262
+ raise ValueError(f"mode {mode!r}: 'file' or 'chunk'")
263
+ if mode == "file" and store is None:
264
+ raise ValueError("mode 'file' needs the alignment store (hypothesis phones)")
265
+ tp = fp = fn = 0
266
+ m_tp = n_hyp = n_ref = 0
267
+ for nm, runs in sil_runs.items():
268
+ if not chunks[nm]:
269
+ continue
270
+ meta = manifest["files"][nm]
271
+ P = np.asarray([p[:2] for p in meta.get("pause_intervals", [])], float).reshape(-1, 2)
272
+ X = np.asarray([p[:2] for p in meta.get("excluded_intervals", [])], float).reshape(-1, 2)
273
+ if mode == "file":
274
+ end = chunks[nm][-1][1]
275
+ hyps = _sil_timeline(store[nm]["hyp_intervals"], end)
276
+ spans, refs = [(0.0, end)], list(P)
277
+ else:
278
+ hyps, spans = list(runs), chunks[nm]
279
+ refs = [p for p in P if any(a <= p[0] and p[1] <= b for a, b in spans)]
280
+ R = np.asarray(hyps, float).reshape(-1, 2)
281
+ for a, b in spans:
282
+ t, f_, n = _frame_counts(a, b, P, X, R)
283
+ tp += t; fp += f_; fn += n
284
+ n_ref += len(refs); n_hyp += len(hyps)
285
+ m_tp += _pause_matches(refs, hyps, iou)
286
+ f = lambda p, r: 2 * p * r / (p + r) if p + r > 0 else 0.0
287
+ pf, rf = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
288
+ pi, ri = m_tp / max(n_hyp, 1), m_tp / max(n_ref, 1)
289
+ return {"sil_frame_P": 100 * pf, "sil_frame_R": 100 * rf, "sil_frame_F1": 100 * f(pf, rf),
290
+ "sil_pause_P": 100 * pi, "sil_pause_R": 100 * ri, "sil_pause_F1": 100 * f(pi, ri),
291
+ "sil_n_hyp": n_hyp, "sil_n_ref": n_ref}
292
+
293
+
294
+ def evaluate(model, feature_extractor, tokenizer, manifest, device="cuda", max_files=None,
295
+ positions=None, offset=None, with_dtw=True, sil_mode="file"):
296
+ """All metrics as a flat dict (keys suffixed _dtw for the plain DTW decode).
297
+ sil_mode: pause scoring, see sil_scores."""
298
+ store, store_dtw, runs, chunks = run_manifest_sil(
299
+ model, feature_extractor, tokenizer, manifest, device, max_files, positions, offset, with_dtw)
300
+ has_pauses = any("pause_intervals" in m for m in manifest["files"].values())
301
+ pauses = ({nm: [tuple(p[:2]) for p in m.get("pause_intervals", [])]
302
+ for nm, m in manifest["files"].items()} if has_pauses else None)
303
+ res = _score(store)
304
+ res.update(E.duration_fidelity(store, align_sequences))
305
+ res.update(duration_fidelity_inspeech(store, pauses))
306
+ if has_pauses:
307
+ res.update(sil_scores(manifest, runs, chunks, store, mode=sil_mode))
308
+ if store_dtw is not None:
309
+ res.update(_score(store_dtw, "_dtw"))
310
+ res.update({f"{k}_dtw": v for k, v in E.duration_fidelity(store_dtw, align_sequences).items()})
311
+ return res, store, store_dtw