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.
- jointrecalign/__init__.py +1 -0
- jointrecalign/__main__.py +4 -0
- jointrecalign/download.py +82 -0
- jointrecalign/evaluation/__init__.py +1 -0
- jointrecalign/evaluation/attention_eval.py +214 -0
- jointrecalign/evaluation/ctcfa_eval.py +131 -0
- jointrecalign/evaluation/metrics.py +266 -0
- jointrecalign/evaluation/sil_eval.py +311 -0
- jointrecalign/evaluation/vad_chunking.py +166 -0
- jointrecalign/model.py +609 -0
- jointrecalign/modules.py +340 -0
- jointrecalign/pool_alignment.py +958 -0
- jointrecalign/praat.py +43 -0
- jointrecalign/praat_plugin/setup.praat +2 -0
- jointrecalign/praat_plugin/transcribe.praat +44 -0
- jointrecalign/reference.py +204 -0
- jointrecalign/report.py +193 -0
- jointrecalign/report_assets/index.html +102 -0
- jointrecalign/report_assets/page.html +507 -0
- jointrecalign/sil_fs.py +278 -0
- jointrecalign/transcribe.py +326 -0
- jointrecalign/transcribe_cli.py +434 -0
- jointrecalign-0.1.0.dist-info/METADATA +535 -0
- jointrecalign-0.1.0.dist-info/RECORD +28 -0
- jointrecalign-0.1.0.dist-info/WHEEL +5 -0
- jointrecalign-0.1.0.dist-info/entry_points.txt +3 -0
- jointrecalign-0.1.0.dist-info/licenses/LICENSE +21 -0
- jointrecalign-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|