Describe the bug
OfflineDiarWithASR.evaluate() reports a corpus-level average_cpWER computed with the
concatenation algorithm that PR #15573 replaced, so it disagrees with — and systematically
under-reports relative to — every per-session cpWER printed beside it.
PR #15573 ("[Fix] Make cpWER calculation identical to meeteval") replaced cpWER with MeetEval
semantics: each (ref_speaker, hyp_speaker) pair is scored independently, so an edit cannot cross a
speaker boundary. That PR changed nemo/collections/asr/metrics/der.py (where cpWER lived at the
time; it is now nemo/collections/asr/metrics/cpwer.py) and its tests, but it did not touch
nemo/collections/asr/parts/utils/diarization_utils.py.
nemo/collections/asr/parts/utils/diarization_utils.py:1367-1371 still does:
cpWER_values, hyps_spk, refs_spk = concat_perm_word_error_rate(spk_hypotheses, spk_references)
# Take an average of cpWER and regular WER value on all sessions
wer_results['total'] = {}
wer_results['total']['average_cpWER'] = word_error_rate(hypotheses=hyps_spk, references=refs_spk)
hyps_spk / refs_spk are the space-joined per-speaker transcripts
(cpwer.py:153-154: min_perm_hyp_trans = " ".join(hyp_texts) / ref_trans = " ".join(spk_reference)),
so running plain word_error_rate on them is exactly the old concatenation approach — edits are
free to cross speaker boundaries again.
The value is user-facing: print_errors (diarization_utils.py:1519-1521) prints it under the
label cpWER, and write_session_level_result_in_csv writes it into the results CSV. It is
reachable from two shipped scripts and a tutorial:
examples/speaker_tasks/diarization/clustering_diarizer/offline_diar_with_asr_infer.py:75
scripts/speaker_tasks/eval_diar_with_asr.py:169 / :176
tutorials/speaker_tasks/ASR_with_SpeakerDiarization.ipynb
Steps/Code to reproduce bug
This drives the public OfflineDiarWithASR.evaluate() entry point with three sessions, each being
the scenario already pinned by
tests/collections/speaker_tasks/utils/test_cpwer.py::TestCpWERExpectedValues::test_cross_boundary
(and quoted in PR #15573's own description as the bug it fixed):
import os, tempfile
from nemo.collections.asr.parts.utils.diarization_utils import OfflineDiarWithASR
# CTM columns: <uniq_id> <speaker> <start> <duration> <word>
def write_ctm(path, uniq_id, spk_texts):
with open(path, "w") as f:
t = 0.0
for spk_idx, text in enumerate(spk_texts):
for w in text.split():
f.write(f"{uniq_id} speaker_{spk_idx} {t:.2f} 0.10 {w}\n")
t += 0.10
tmp = tempfile.mkdtemp()
hypdir = os.path.join(tmp, "hyp"); os.makedirs(hypdir)
audio_file_list, ref_ctms, hyp_ctms = [], [], []
for i in range(3):
uid = f"sess{i}"
audio = os.path.join(tmp, f"{uid}.wav"); open(audio, "w").close()
ref = os.path.join(tmp, f"{uid}.ctm"); hyp = os.path.join(hypdir, f"{uid}.ctm")
write_ctm(ref, uid, ["the cat", "sat on"]) # reference: 2 speakers
write_ctm(hyp, uid, ["the cat sat", "on"]) # hypothesis: boundary shifted by one word
audio_file_list.append(audio); ref_ctms.append(ref); hyp_ctms.append(hyp)
wer_results = OfflineDiarWithASR.evaluate(
audio_file_list=audio_file_list,
hyp_trans_info_dict=None,
hyp_ctm_file_list=hyp_ctms,
ref_ctm_file_list=ref_ctms,
)
for k in sorted(wer_results):
print(k, "->", wer_results[k])
Actual output:
sess0 -> {'cpWER': 0.5, 'WER': 0.0}
sess1 -> {'cpWER': 0.5, 'WER': 0.0}
sess2 -> {'cpWER': 0.5, 'WER': 0.0}
total -> {'average_cpWER': 0.0, 'average_WER': 0.0}
Every session reports cpWER = 0.5, while the corpus value reports 0.0. print_errors() then
shows the user:
DER : 0.0000
FA : 0.0000
MISS : 0.0000
CER : 0.0000
Spk. counting acc. : 1.0000
cpWER : 0.0000
WER : 0.0000
0.0 is the exact value PR #15573's description cites as the bug it fixed:
For example, hyp=["the cat sat", "on"] vs ref=["the cat", "sat on"] would incorrectly return
cpWER=0.0 instead of the correct cpWER=0.5.
The same thing can be seen without any files, at the level of the two functions involved:
from nemo.collections.asr.metrics.cpwer import concat_perm_word_error_rate
from nemo.collections.asr.metrics.wer import word_error_rate
spk_hypotheses = [["the cat sat", "on"]]
spk_references = [["the cat", "sat on"]]
cpWER_values, hyps_spk, refs_spk = concat_perm_word_error_rate(spk_hypotheses, spk_references)
print("per-session cpWER:", cpWER_values)
# verbatim diarization_utils.py:1371
print("average_cpWER :", word_error_rate(hypotheses=hyps_spk, references=refs_spk))
print("strings actually scored -> hyp:", hyps_spk, "ref:", refs_spk)
per-session cpWER: [0.5]
average_cpWER : 0.0
strings actually scored -> hyp: ['the cat sat on'] ref: ['the cat sat on']
The concatenation collapses the speaker boundary, so the two strings become identical.
Expected behavior
The corpus-level cpWER should be the MeetEval-consistent aggregate of the session values — total
errors over total reference words — so that a corpus of N sessions each at cpWER 0.5 reports 0.5,
not 0.0.
This also matches the metric printed next to it: average_WER is computed by word_error_rate over
the list of sessions, which accumulates scores and words across the whole list and divides once
(nemo/collections/asr/metrics/wer.py:35-71) — i.e. it is already length-weighted across sessions.
average_cpWER should be aggregated the same way.
Environment overview (please complete the following information)
- Environment location: Bare-metal
- Method of NeMo install: from source (
git clone + pip install -e .), Python 3.10, PyTorch 2.12.0
Environment details
- OS version: macOS 26.5.1 (arm64)
- PyTorch version: 2.12.0
- Python version: 3.10.18
Additional context
tests/collections/speaker_tasks/utils/test_cpwer.py covers calculate_session_cpWER and
concat_perm_word_error_rate thoroughly, but nothing covers the corpus aggregation in
diarization_utils.py, which is why the gap survived.
The streaming evaluator in the same file (diarization_utils.py:652) is not affected — it returns
per-chunk cpWER lists and never aggregates them, so evaluate() is the only corpus-level
aggregation site.
Describe the bug
OfflineDiarWithASR.evaluate()reports a corpus-levelaverage_cpWERcomputed with theconcatenation algorithm that PR #15573 replaced, so it disagrees with — and systematically
under-reports relative to — every per-session
cpWERprinted beside it.PR #15573 ("[Fix] Make cpWER calculation identical to meeteval") replaced cpWER with MeetEval
semantics: each
(ref_speaker, hyp_speaker)pair is scored independently, so an edit cannot cross aspeaker boundary. That PR changed
nemo/collections/asr/metrics/der.py(where cpWER lived at thetime; it is now
nemo/collections/asr/metrics/cpwer.py) and its tests, but it did not touchnemo/collections/asr/parts/utils/diarization_utils.py.nemo/collections/asr/parts/utils/diarization_utils.py:1367-1371still does:hyps_spk/refs_spkare the space-joined per-speaker transcripts(
cpwer.py:153-154:min_perm_hyp_trans = " ".join(hyp_texts)/ref_trans = " ".join(spk_reference)),so running plain
word_error_rateon them is exactly the old concatenation approach — edits arefree to cross speaker boundaries again.
The value is user-facing:
print_errors(diarization_utils.py:1519-1521) prints it under thelabel
cpWER, andwrite_session_level_result_in_csvwrites it into the results CSV. It isreachable from two shipped scripts and a tutorial:
examples/speaker_tasks/diarization/clustering_diarizer/offline_diar_with_asr_infer.py:75scripts/speaker_tasks/eval_diar_with_asr.py:169/:176tutorials/speaker_tasks/ASR_with_SpeakerDiarization.ipynbSteps/Code to reproduce bug
This drives the public
OfflineDiarWithASR.evaluate()entry point with three sessions, each beingthe scenario already pinned by
tests/collections/speaker_tasks/utils/test_cpwer.py::TestCpWERExpectedValues::test_cross_boundary(and quoted in PR #15573's own description as the bug it fixed):
Actual output:
Every session reports
cpWER = 0.5, while the corpus value reports0.0.print_errors()thenshows the user:
0.0is the exact value PR #15573's description cites as the bug it fixed:The same thing can be seen without any files, at the level of the two functions involved:
The concatenation collapses the speaker boundary, so the two strings become identical.
Expected behavior
The corpus-level
cpWERshould be the MeetEval-consistent aggregate of the session values — totalerrors over total reference words — so that a corpus of N sessions each at cpWER 0.5 reports 0.5,
not 0.0.
This also matches the metric printed next to it:
average_WERis computed byword_error_rateoverthe list of sessions, which accumulates
scoresandwordsacross the whole list and divides once(
nemo/collections/asr/metrics/wer.py:35-71) — i.e. it is already length-weighted across sessions.average_cpWERshould be aggregated the same way.Environment overview (please complete the following information)
git clone+pip install -e .), Python 3.10, PyTorch 2.12.0Environment details
Additional context
tests/collections/speaker_tasks/utils/test_cpwer.pycoverscalculate_session_cpWERandconcat_perm_word_error_ratethoroughly, but nothing covers the corpus aggregation indiarization_utils.py, which is why the gap survived.The streaming evaluator in the same file (
diarization_utils.py:652) is not affected — it returnsper-chunk cpWER lists and never aggregates them, so
evaluate()is the only corpus-levelaggregation site.