Files
wehub-resource-sync ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

1610 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Tests for DER calculation in nemo.collections.asr.metrics.der and
nemo.collections.asr.metrics.md_eval. cpWER coverage lives in
test_cpwer.py for nemo.collections.asr.metrics.cpwer.
All expected values are pre-verified against an external annotation
library (3.x, the historical NeMo dependency that exposed
``Annotation`` / ``Segment`` / ``Timeline`` and a reference
``DiarizationErrorRate``). The values are hardcoded here so that
**this file does not import any external annotation library**.
md-eval (NIST md-eval-22.pl) and the external reference engine share the
same DER semantics (optimal speaker mapping via the Hungarian algorithm,
same collar / overlap conventions) and produce identical results when
the UEM is equivalent. The only behavioural difference captured by these
tests is that md-eval derives the evaluation region (UEM) from the
*reference* extent whereas the external engine uses the *union* of
reference and hypothesis extents. Tests for that case use an explicit
UEM to keep the two engines aligned.
The :class:`TestLhotseAnnotation` group additionally covers the
lhotse-based replacement for the external annotation library types
(``Annotation`` / ``Segment`` / ``Timeline``) introduced in
:mod:`nemo.collections.asr.metrics.der`. Annotations are built as lists
of :class:`lhotse.SupervisionSegment` and must produce bit-identical DER
to the legacy label-string path.
"""
import io
import pytest
from lhotse import SupervisionSegment, SupervisionSet
from nemo.collections.asr.metrics.der import (
make_diar_annotation,
make_diar_segment,
make_uem_timeline,
score_labels,
score_labels_from_rttm_labels,
unique_speakers,
write_supervisions_to_rttm,
)
from nemo.collections.asr.metrics.md_eval import (
EPSILON,
DiarizationErrorResult,
_iter_annotation_segments,
_labels_to_rttm_data,
_merge_rttm_dicts,
_merge_uem_dicts,
_uem_list_to_uem_data,
evaluate,
)
# ─── Helpers ──────────────────────────────────────────────────────────────
def _seg(start: float, end: float, spk: str) -> str:
"""Create a ``"start end speaker"`` label string."""
return f"{start} {end} {spk}"
def _labels(*segments):
"""Convert ``(start, end, speaker)`` tuples to label strings."""
return [_seg(s, e, k) for s, e, k in segments]
def _score(
ref_segs,
hyp_segs,
collar=0.0,
ignore_overlap=False,
uem_segs=None,
file_id="file1",
):
"""Score a single file through the public ``score_labels_from_rttm_labels`` API."""
ref_labels = _labels(*ref_segs)
hyp_labels = _labels(*hyp_segs)
ref_list = [(file_id, ref_labels)]
hyp_list = [(file_id, hyp_labels)]
uem_list = [(file_id, uem_segs)] if uem_segs else None
result = score_labels_from_rttm_labels(
ref_list,
hyp_list,
uem_segments_list=uem_list,
collar=collar,
ignore_overlap=ignore_overlap,
verbose=False,
)
assert result is not None, "score_labels_from_rttm_labels returned None"
return result
def _score_raw(
ref_segs,
hyp_segs,
collar=0.0,
ignore_overlap=False,
uem_segs=None,
file_id="file1",
):
"""Score a single file through the low-level ``evaluate`` API in md_eval."""
ref_labels = _labels(*ref_segs)
hyp_labels = _labels(*hyp_segs)
ref_data = _merge_rttm_dicts([_labels_to_rttm_data(file_id, ref_labels)])
sys_data = _merge_rttm_dicts([_labels_to_rttm_data(file_id, hyp_labels)])
uem_data = None
if uem_segs:
uem_data = _merge_uem_dicts([_uem_list_to_uem_data(file_id, uem_segs)])
_, cum = evaluate(
ref_data,
sys_data,
uem_data=uem_data,
collar=collar,
opt_1=ignore_overlap,
verbose=False,
)
scored = cum.get("SCORED_SPEAKER", 0.0) or EPSILON
missed = cum.get("MISSED_SPEAKER", 0.0)
falarm = cum.get("FALARM_SPEAKER", 0.0)
error = cum.get("SPEAKER_ERROR", 0.0)
return {
"DER": (missed + falarm + error) / scored,
"CER": error / scored,
"FA": falarm / scored,
"MISS": missed / scored,
"scored": scored,
}
def assert_der(actual, expected, tol=1e-6):
diff = abs(actual - expected)
assert diff <= tol, f"DER mismatch: actual={actual:.8f}, expected={expected:.8f}"
def _score_lhotse(
ref_segs,
hyp_segs,
collar=0.0,
ignore_overlap=False,
uem_segs=None,
file_id="file1",
):
"""Score a single file through ``score_labels`` using lhotse-based annotations.
Mirrors :func:`_score` but builds the reference and hypothesis as lists of
``lhotse.SupervisionSegment`` (via :func:`make_diar_annotation`) instead of
label strings, exercising the new lhotse-based pipeline end-to-end.
"""
ref_labels = _labels(*ref_segs)
hyp_labels = _labels(*hyp_segs)
ref_ann = make_diar_annotation(ref_labels, uniq_name=file_id)
hyp_ann = make_diar_annotation(hyp_labels, uniq_name=file_id)
all_uem = [make_uem_timeline(uem_segs, uniq_id=file_id)] if uem_segs else None
audio_rttm_map = {file_id: {}}
result = score_labels(
audio_rttm_map,
[(file_id, ref_ann)],
[(file_id, hyp_ann)],
all_uem=all_uem,
collar=collar,
ignore_overlap=ignore_overlap,
verbose=False,
)
assert result is not None, "score_labels returned None"
return result
# ─── Tests: md_eval low-level engine ──────────────────────────────────────
class TestMdEvalBasic:
"""Verify the md_eval engine produces correct DER for basic scenarios.
Expected values verified against the external annotation library's
reference ``DiarizationErrorRate`` implementation.
"""
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, expected",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
{"DER": 0.0, "scored": 10.0},
id="perfect_match",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[],
{"DER": 1.0, "CER": 0.0, "FA": 0.0, "MISS": 1.0, "scored": 10.0},
id="complete_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
{"DER": 0.0, "scored": 10.0},
id="speaker_swap_optimal_mapping",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A")],
{"DER": 0.5, "MISS": 0.5, "scored": 10.0},
id="partial_miss",
),
pytest.param(
[(0, 10, "A")],
[(0, 10, "B")],
{"DER": 0.0},
id="single_speaker_confusion",
),
pytest.param(
[(0, 3, "A"), (7, 10, "B")],
[(0, 3, "A"), (7, 10, "B")],
{"DER": 0.0, "scored": 6.0},
id="gap_perfect",
),
pytest.param(
[(0, 3, "A"), (7, 10, "B")],
[(0, 3, "A"), (4, 6, "X"), (7, 10, "B")],
{"DER": 1 / 3, "FA": 1 / 3, "scored": 6.0},
id="false_alarm_in_gap",
),
pytest.param(
[(0, 5, "A")],
[(0, 10, "A")],
{"DER": 0.0, "scored": 5.0},
id="false_alarm_extend_no_uem",
),
],
)
def test_basic_no_uem(self, ref_segs, hyp_segs, expected):
r = _score_raw(ref_segs, hyp_segs)
for key, val in expected.items():
assert_der(r[key], val)
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, uem_segs, expected",
[
pytest.param(
[(0, 5, "A")],
[(0, 10, "A")],
[[0, 10]],
{"DER": 1.0, "FA": 1.0, "scored": 5.0},
id="false_alarm_extend_with_uem",
),
],
)
def test_basic_with_uem(self, ref_segs, hyp_segs, uem_segs, expected):
r = _score_raw(ref_segs, hyp_segs, uem_segs=uem_segs)
for key, val in expected.items():
assert_der(r[key], val)
class TestMdEvalCollar:
"""Verify collar (no-score zone) handling."""
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, collar, expected",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
0.25,
{"DER": 0.0, "scored": 9.0},
id="collar_perfect",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5.2, "A"), (5.2, 10, "B")],
0.25,
{"DER": 0.0, "scored": 9.0},
id="collar_absorbs_offset",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4.5, "A"), (5.5, 10, "B")],
0.25,
{"DER": 0.5 / 9.0, "MISS": 0.5 / 9.0},
id="collar_boundary_error_within",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4, "A"), (6, 10, "B")],
0.25,
{"DER": 1.5 / 9.0, "MISS": 1.5 / 9.0},
id="collar_boundary_error_exceeds",
),
pytest.param(
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
0.5,
{"DER": 0.0, "scored": 7.0},
id="collar_3spk_perfect",
),
],
)
def test_collar(self, ref_segs, hyp_segs, collar, expected):
r = _score_raw(ref_segs, hyp_segs, collar=collar)
for key, val in expected.items():
assert_der(r[key], val)
class TestMdEvalOverlap:
"""Verify overlap handling with skip_overlap / ignore_overlap."""
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, ignore_overlap, expected",
[
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 7, "A"), (5, 10, "B")],
True,
{"DER": 0.0, "scored": 8.0},
id="overlap_perfect_skip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 7, "A"), (5, 10, "B")],
False,
{"DER": 0.0, "scored": 12.0},
id="overlap_perfect_noskip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
True,
{"DER": 0.375, "CER": 0.375, "scored": 8.0},
id="overlap_miss_one_speaker_skip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
False,
{"DER": 5 / 12, "CER": 3 / 12, "MISS": 2 / 12, "scored": 12.0},
id="overlap_miss_one_speaker_noskip",
),
],
)
def test_overlap(self, ref_segs, hyp_segs, ignore_overlap, expected):
r = _score_raw(ref_segs, hyp_segs, ignore_overlap=ignore_overlap)
for key, val in expected.items():
assert_der(r[key], val)
class TestMdEvalSpeakerCount:
"""Verify speaker count mismatch scenarios."""
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, expected",
[
pytest.param(
[(0, 3, "A"), (3, 7, "B"), (7, 10, "C")],
[(0, 3, "A"), (3, 6, "B"), (6, 10, "C")],
{"DER": 0.1, "CER": 0.1, "scored": 10.0},
id="three_speakers_boundary_shift",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
{"DER": 0.2, "CER": 0.2},
id="extra_hyp_speaker",
),
pytest.param(
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
[(0, 5, "A"), (5, 10, "B")],
{"DER": 0.2, "CER": 0.2},
id="missing_hyp_speaker",
),
],
)
def test_speaker_count(self, ref_segs, hyp_segs, expected):
r = _score_raw(ref_segs, hyp_segs)
for key, val in expected.items():
assert_der(r[key], val)
class TestMdEvalUEM:
"""Verify UEM (Un-partitioned Evaluation Map) handling."""
@pytest.mark.unit
def test_uem_restricts_evaluation(self):
"""UEM restricts to [2, 8] out of [0, 10].
ref: A=[0,10]; hyp: A=[0,5], B=[5,10]. UEM=[2,8].
Scored region: ref A in [2,8] = 6.0.
B covers [5,8] of ref A → confusion = 3.0. DER = 3/6 = 0.5.
"""
r = _score_raw(
[(0, 10, "A")],
[(0, 5, "A"), (5, 10, "B")],
uem_segs=[[2, 8]],
)
assert_der(r["DER"], 0.5)
assert_der(r["CER"], 0.5)
assert_der(r["scored"], 6.0)
# ─── Tests: der.py public API (score_labels_from_rttm_labels) ────────────
class TestScoreLabelsFromRttmLabels:
"""Test the public ``score_labels_from_rttm_labels`` function in der.py.
Verifies: return type, DiarizationErrorResult interface, and DER values.
"""
@pytest.mark.unit
def test_perfect_match_returns_correct_types(self):
metric, mapping, (DER, CER, FA, MISS) = _score(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
)
assert isinstance(metric, DiarizationErrorResult)
assert isinstance(mapping, dict)
assert_der(DER, 0.0)
assert_der(CER, 0.0)
assert_der(FA, 0.0)
assert_der(MISS, 0.0)
@pytest.mark.unit
def test_result_abs_interface(self):
"""``abs(metric)`` returns overall DER."""
metric, _, _ = _score(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
)
assert_der(abs(metric), 0.0)
@pytest.mark.unit
def test_result_getitem_interface(self):
"""``metric['total']`` etc. return correct values."""
metric, _, _ = _score(
[(0, 10, "A")],
[(0, 5, "A")],
)
assert_der(metric["total"], 10.0)
assert_der(metric["confusion"], 0.0)
assert_der(metric["false alarm"], 0.0)
assert_der(metric["missed detection"], 5.0)
assert_der(abs(metric), 0.5)
@pytest.mark.unit
def test_result_optimal_mapping(self):
"""Speaker mapping is accessible via ``metric.optimal_mapping()``."""
metric, _, _ = _score(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
)
file_mapping = metric.optimal_mapping("file1", None)
assert "A" in file_mapping
assert file_mapping["A"] == "B"
assert file_mapping["B"] == "A"
@pytest.mark.unit
def test_result_report(self):
"""``metric.report()`` returns a non-empty string."""
metric, _, _ = _score(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
)
report = metric.report()
assert isinstance(report, str)
assert len(report) > 0
assert "file1" in report
@pytest.mark.unit
def test_results_list(self):
"""``metric.results_`` contains per-file score dicts."""
metric, _, _ = _score(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
)
assert len(metric.results_) == 1
file_id, scores = metric.results_[0]
assert file_id == "file1"
assert_der(scores["total"], 10.0)
assert_der(scores["confusion"], 0.0)
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, kwargs, checks",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[],
{},
{"DER": 1.0, "MISS": 1.0},
id="complete_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
{},
{"DER": 0.0},
id="speaker_swap",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A")],
{},
{"DER": 0.5, "MISS": 0.5},
id="partial_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
{"collar": 0.25},
{"DER": 0.0},
id="collar",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5.2, "A"), (5.2, 10, "B")],
{"collar": 0.25},
{"DER": 0.0},
id="collar_offset",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 7, "A"), (5, 10, "B")],
{"ignore_overlap": True},
{"DER": 0.0},
id="overlap_skip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
{"ignore_overlap": True},
{"DER": 0.375, "CER": 0.375},
id="overlap_miss_skip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
{"ignore_overlap": False},
{"DER": 5 / 12, "CER": 3 / 12, "MISS": 2 / 12},
id="overlap_miss_noskip",
),
pytest.param(
[(0, 3, "A"), (3, 7, "B"), (7, 10, "C")],
[(0, 3, "A"), (3, 6, "B"), (6, 10, "C")],
{},
{"DER": 0.1},
id="three_speakers",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
{},
{"DER": 0.2, "CER": 0.2},
id="extra_hyp_speaker",
),
pytest.param(
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
[(0, 5, "A"), (5, 10, "B")],
{},
{"DER": 0.2, "CER": 0.2},
id="missing_hyp_speaker",
),
pytest.param(
[(0, 3, "A"), (7, 10, "B")],
[(0, 3, "A"), (4, 6, "X"), (7, 10, "B")],
{},
{"DER": 1 / 3, "FA": 1 / 3},
id="false_alarm_in_gap",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A"), (5, 10, "B")],
{"uem_segs": [[2, 8]]},
{"DER": 0.5, "CER": 0.5},
id="uem_restrict",
),
],
)
def test_der_values(self, ref_segs, hyp_segs, kwargs, checks):
_, _, (DER, CER, FA, MISS) = _score(ref_segs, hyp_segs, **kwargs)
name_to_val = {"DER": DER, "CER": CER, "FA": FA, "MISS": MISS}
for key, val in checks.items():
assert_der(name_to_val[key], val)
@pytest.mark.unit
def test_length_mismatch_returns_none(self):
"""Mismatched ref/hyp list lengths should return None."""
result = score_labels_from_rttm_labels(
[("f1", _labels((0, 5, "A")))],
[("f1", _labels((0, 5, "A"))), ("f2", _labels((0, 5, "B")))],
verbose=False,
)
assert result is None
# ─── Tests: Multi-file scoring ───────────────────────────────────────────
class TestMultiFile:
"""Verify multi-file cumulative scoring."""
@pytest.mark.unit
def test_two_files_one_perfect_one_confusion(self):
"""File1: perfect. File2: all confusion (mapped away).
Combined: scored=10, DER=0 (optimal mapping maps C→B).
"""
ref_list = [
("file1", _labels((0, 5, "A"))),
("file2", _labels((0, 5, "B"))),
]
hyp_list = [
("file1", _labels((0, 5, "A"))),
("file2", _labels((0, 5, "C"))),
]
result = score_labels_from_rttm_labels(
ref_list,
hyp_list,
collar=0.0,
ignore_overlap=False,
verbose=False,
)
assert result is not None
metric, _, (DER, _, _, _) = result
assert_der(DER, 0.0)
assert_der(metric["total"], 10.0)
assert len(metric.results_) == 2
@pytest.mark.unit
def test_two_files_one_miss(self):
"""File1: perfect 5s. File2: complete miss 5s.
Combined: scored=10, missed=5, DER=0.5.
"""
ref_list = [
("file1", _labels((0, 5, "A"))),
("file2", _labels((0, 5, "B"))),
]
hyp_list = [
("file1", _labels((0, 5, "A"))),
("file2", []),
]
result = score_labels_from_rttm_labels(
ref_list,
hyp_list,
collar=0.0,
ignore_overlap=False,
verbose=False,
)
assert result is not None
_, _, (DER, _, _, MISS) = result
assert_der(DER, 0.5)
assert_der(MISS, 0.5)
# ─── Tests: External-engine-verified values (cross-validated) ────────────
class TestExternalEngineVerifiedValues:
"""Cross-validation against an external annotation library.
All expected values in this class have been computed with the external
annotation library's reference ``DiarizationErrorRate`` (3.x), using
its ``collar=2*collar_value`` convention, and hardcoded here.
This class does **not** import the external library; it only checks
that the md-eval engine reproduces the same numbers.
"""
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, collar, ignore_overlap, uem_segs, expected",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
0.0,
False,
None,
{"DER": 0.0},
id="external_perfect",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[],
0.0,
False,
None,
{"DER": 1.0, "MISS": 1.0},
id="external_complete_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
0.0,
False,
None,
{"DER": 0.0},
id="external_swap",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A")],
0.0,
False,
None,
{"DER": 0.5},
id="external_partial_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
0.25,
False,
None,
{"DER": 0.0, "scored": 9.0},
id="external_collar_perfect",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5.2, "A"), (5.2, 10, "B")],
0.25,
False,
None,
{"DER": 0.0},
id="external_collar_offset",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 7, "A"), (5, 10, "B")],
0.0,
True,
None,
{"DER": 0.0, "scored": 8.0},
id="external_overlap_skip_perfect",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 7, "A"), (5, 10, "B")],
0.0,
False,
None,
{"DER": 0.0, "scored": 12.0},
id="external_overlap_noskip_perfect",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
0.0,
True,
None,
{"DER": 0.375, "CER": 0.375},
id="external_overlap_miss_skip",
),
pytest.param(
[(0, 7, "A"), (5, 10, "B")],
[(0, 10, "A")],
0.0,
False,
None,
{"DER": 5 / 12, "CER": 3 / 12, "MISS": 2 / 12, "scored": 12.0},
id="external_overlap_miss_noskip",
),
pytest.param(
[(0, 3, "A"), (3, 7, "B"), (7, 10, "C")],
[(0, 3, "A"), (3, 6, "B"), (6, 10, "C")],
0.0,
False,
None,
{"DER": 0.1, "CER": 0.1},
id="external_3spk_boundary",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
0.0,
False,
None,
{"DER": 0.2, "CER": 0.2},
id="external_extra_hyp",
),
pytest.param(
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
[(0, 5, "A"), (5, 10, "B")],
0.0,
False,
None,
{"DER": 0.2, "CER": 0.2},
id="external_missing_hyp",
),
pytest.param(
[(0, 3, "A"), (7, 10, "B")],
[(0, 3, "A"), (7, 10, "B")],
0.0,
False,
None,
{"DER": 0.0, "scored": 6.0},
id="external_gap",
),
pytest.param(
[(0, 3, "A"), (7, 10, "B")],
[(0, 3, "A"), (4, 6, "X"), (7, 10, "B")],
0.0,
False,
None,
{"DER": 1 / 3, "FA": 1 / 3},
id="external_false_alarm_in_gap",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A"), (5, 10, "B")],
0.0,
False,
[[2, 8]],
{"DER": 0.5, "CER": 0.5, "scored": 6.0},
id="external_uem",
),
pytest.param(
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
0.5,
False,
None,
{"DER": 0.0, "scored": 7.0},
id="external_collar_3spk",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4.5, "A"), (5.5, 10, "B")],
0.25,
False,
None,
{"DER": 0.5 / 9.0, "MISS": 0.5 / 9.0},
id="external_collar_boundary_error",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4, "A"), (6, 10, "B")],
0.25,
False,
None,
{"DER": 1.5 / 9.0, "MISS": 1.5 / 9.0},
id="external_collar_boundary_error_large",
),
pytest.param(
[(0, 10, "A")],
[(0, 10, "B")],
0.0,
False,
None,
{"DER": 0.0},
id="external_single_speaker_confusion",
),
],
)
def test_external_values(self, ref_segs, hyp_segs, collar, ignore_overlap, uem_segs, expected):
r = _score_raw(ref_segs, hyp_segs, collar=collar, ignore_overlap=ignore_overlap, uem_segs=uem_segs)
for key, val in expected.items():
assert_der(r[key], val)
@pytest.mark.unit
def test_external_multi_file(self):
"""External engine, multi-file: file1 perfect + file2 relabelled → DER=0.
Both engines map C→B via Hungarian algorithm.
"""
ref_dicts = [
_labels_to_rttm_data("file1", _labels((0, 5, "A"))),
_labels_to_rttm_data("file2", _labels((0, 5, "B"))),
]
sys_dicts = [
_labels_to_rttm_data("file1", _labels((0, 5, "A"))),
_labels_to_rttm_data("file2", _labels((0, 5, "C"))),
]
ref_data = _merge_rttm_dicts(ref_dicts)
sys_data = _merge_rttm_dicts(sys_dicts)
_, cum = evaluate(ref_data, sys_data, uem_data=None, collar=0.0, opt_1=False, verbose=False)
scored = cum.get("SCORED_SPEAKER", 0.0) or EPSILON
DER = (
cum.get("MISSED_SPEAKER", 0.0) + cum.get("FALARM_SPEAKER", 0.0) + cum.get("SPEAKER_ERROR", 0.0)
) / scored
assert_der(DER, 0.0)
assert_der(scored, 10.0)
# ─── Tests: regression for no-UEM scoring (parity with external lib) ─────
class TestNoUemAutoUnion:
"""Regression tests for the auto-derived UEM used when no UEM is provided.
Historically NeMo's DER was computed via an external annotation library
that, in the no-UEM path, built its scoring map from the union of the
reference and system extents. NIST ``md-eval-22.pl`` (which our
:func:`md_eval.evaluate` faithfully ports) instead defaults to the
reference extent only. The high-level wrappers in :mod:`der` bridge the
two by auto-deriving a ``ref sys`` UEM whenever the caller does not
supply one. These tests pin down that behaviour with hardcoded values
independently verified by hand and previously by the external library.
"""
# Sortformer Diar 4spk-v1 dihard3-dev tutorial sample.
_REF = [(0.299, 2.770, "A"), (3.164, 5.147, "B")]
_HYP_RAW = [(0.400, 2.880, "spk0"), (3.200, 5.190, "spk1")]
_HYP_PP = [(0.340, 2.800, "spk0"), (3.220, 5.190, "spk1")]
@pytest.mark.unit
@pytest.mark.parametrize(
"hyp_attr, expected_der, expected_fa_num, expected_fa_den, expected_miss_num, expected_miss_den",
[
pytest.param(
"_HYP_RAW",
0.065110,
0.153,
4.454,
0.137,
4.454,
id="raw_binarization_matches_external_lib",
),
pytest.param(
"_HYP_PP",
0.038168,
0.073,
4.454,
0.097,
4.454,
id="post_processed_matches_external_lib",
),
],
)
def test_score_matches_external_lib(
self, hyp_attr, expected_der, expected_fa_num, expected_fa_den, expected_miss_num, expected_miss_den
):
hyp = getattr(self, hyp_attr)
r = _score(self._REF, hyp, collar=0.0, ignore_overlap=False)
DER, _CER, FA, MISS = r[2]
assert_der(DER, expected_der, tol=1e-5)
assert_der(FA, expected_fa_num / expected_fa_den, tol=1e-5)
assert_der(MISS, expected_miss_num / expected_miss_den, tol=1e-5)
@pytest.mark.unit
@pytest.mark.parametrize(
"hyp_attr, expected_der",
[
pytest.param("_HYP_RAW", 0.065110, id="lhotse_raw"),
pytest.param("_HYP_PP", 0.038168, id="lhotse_post"),
],
)
def test_score_labels_lhotse_path_matches_external_lib(self, hyp_attr, expected_der):
hyp = getattr(self, hyp_attr)
r = _score_lhotse(self._REF, hyp, collar=0.0, ignore_overlap=False)
DER, _CER, _FA, _MISS = r[2]
assert_der(DER, expected_der, tol=1e-5)
@pytest.mark.unit
def test_low_level_evaluate_keeps_nist_semantics(self):
"""The low-level ``evaluate`` API must keep the NIST ref-extent default.
Power users that call ``md_eval.evaluate`` directly should still see
the strict NIST behaviour (eval map = ref extent only) when they pass
``uem_data=None``. The auto-union behaviour is intentionally limited
to the high-level wrappers in :mod:`der`.
"""
r = _score_raw(self._REF, self._HYP_RAW, collar=0.0, ignore_overlap=False)
assert_der(r["DER"], 0.055456, tol=1e-5)
r = _score_raw(self._REF, self._HYP_PP, collar=0.0, ignore_overlap=False)
assert_der(r["DER"], 0.028514, tol=1e-5)
@pytest.mark.unit
def test_explicit_uem_overrides_auto_union(self):
"""An explicit UEM must always take precedence over the auto-derived one."""
r = _score(
self._REF,
self._HYP_RAW,
collar=0.0,
ignore_overlap=False,
uem_segs=[[0.299, 5.147]],
)
DER, _CER, _FA, _MISS = r[2]
assert_der(DER, 0.055456, tol=1e-5)
@pytest.mark.unit
@pytest.mark.parametrize(
"collar, hyp_attr, expected_der",
[
pytest.param(0.05, "_HYP_RAW", 0.026093, id="collar_nist_half_width_raw"),
pytest.param(0.05, "_HYP_PP", 0.001410, id="collar_nist_half_width_post"),
],
)
def test_collar_is_nist_half_width(self, collar, hyp_attr, expected_der):
hyp = getattr(self, hyp_attr)
r = _score(self._REF, hyp, collar=collar, ignore_overlap=False)
DER, _CER, _FA, _MISS = r[2]
assert_der(DER, expected_der, tol=1e-5)
@pytest.mark.unit
def test_collar_2x_equivalence_to_external_lib(self):
"""Cross-engine equivalence: NeMo ``collar=X`` ≡ external lib ``collar=2X``.
The external library reports RAW DER = 0.043638 / POST DER = 0.016077
when called directly with ``collar=0.10``. NeMo at ``collar=0.05``
must produce the same numbers — the historical doubling-then-halving
round trip, made explicit by passing ``collar`` straight through to
:func:`md_eval.evaluate` (which uses NIST half-width semantics
natively). Equivalently, NeMo at ``collar=0.025`` must match the
external lib at ``collar=0.05``.
"""
r = _score(self._REF, self._HYP_RAW, collar=0.025, ignore_overlap=False)
DER, _CER, _FA, _MISS = r[2]
assert_der(DER, 0.043638, tol=1e-5)
r = _score(self._REF, self._HYP_PP, collar=0.025, ignore_overlap=False)
DER, _CER, _FA, _MISS = r[2]
assert_der(DER, 0.016077, tol=1e-5)
@pytest.mark.unit
def test_collar_lhotse_path_matches_string_path(self):
"""The lhotse-backed ``score_labels`` collar semantics must agree with ``score_labels_from_rttm_labels``."""
for collar, expected_raw, expected_post in [
(0.05, 0.026093, 0.001410),
(0.025, 0.043638, 0.016077),
]:
r_raw = _score_lhotse(self._REF, self._HYP_RAW, collar=collar, ignore_overlap=False)
assert_der(r_raw[2][0], expected_raw, tol=1e-5)
r_post = _score_lhotse(self._REF, self._HYP_PP, collar=collar, ignore_overlap=False)
assert_der(r_post[2][0], expected_post, tol=1e-5)
@pytest.mark.unit
def test_default_uem_helper_builds_union(self):
"""The internal ``_default_uem_from_ref_sys`` builds the right span."""
from nemo.collections.asr.metrics.der import _default_uem_from_ref_sys
ref_data = _merge_rttm_dicts([_labels_to_rttm_data("file1", _labels(*self._REF))])
sys_data = _merge_rttm_dicts([_labels_to_rttm_data("file1", _labels(*self._HYP_RAW))])
uem = _default_uem_from_ref_sys(ref_data, sys_data)
assert "file1" in uem
seg = uem["file1"]["1"][0]
assert abs(seg["TBEG"] - 0.299) < 1e-9
assert abs(seg["TEND"] - 5.190) < 1e-9
# ─── Tests: lhotse-based replacement for the external annotation lib ─────
class TestLhotseShimHelpers:
"""Unit tests for the lhotse-based shim helpers in der.py.
These helpers (``make_diar_segment``, ``make_diar_annotation``,
``make_uem_timeline``, ``unique_speakers``, ``write_supervisions_to_rttm``)
replace the ``Annotation`` / ``Segment`` / ``Timeline`` types from the
external annotation library that NeMo previously depended on.
"""
@pytest.mark.unit
@pytest.mark.parametrize(
"start, end, spk, rec_id, expected_duration, expected_end",
[
pytest.param(1.5, 4.0, "spk0", "rec1", 2.5, 4.0, id="basic"),
pytest.param(5.0, 5.0, "A", None, 0.0, 5.0, id="zero_duration"),
pytest.param(5.0, 4.0, "A", None, 0.0, 5.0, id="inverted_clamped"),
],
)
def test_make_diar_segment(self, start, end, spk, rec_id, expected_duration, expected_end):
kwargs = {"recording_id": rec_id} if rec_id is not None else {}
seg = make_diar_segment(start, end, spk, **kwargs)
assert isinstance(seg, SupervisionSegment)
assert seg.start == start
assert seg.duration == expected_duration
assert seg.speaker == spk
if rec_id is not None:
assert seg.recording_id == rec_id
@pytest.mark.unit
def test_make_diar_segment_auto_id(self):
"""When ``segment_id`` is None, a deterministic id is generated."""
s1 = make_diar_segment(0.0, 1.0, "A", recording_id="r")
s2 = make_diar_segment(0.0, 1.0, "A", recording_id="r")
assert s1.id == s2.id
s3 = make_diar_segment(0.0, 2.0, "A", recording_id="r")
assert s1.id != s3.id
@pytest.mark.unit
def test_make_diar_annotation_from_labels(self):
labels = ["0.0 5.0 A", "5.0 10.0 B", "10.0 12.5 A"]
ann = make_diar_annotation(labels, uniq_name="rec42")
assert isinstance(ann, list)
assert len(ann) == 3
assert all(isinstance(s, SupervisionSegment) for s in ann)
assert all(s.recording_id == "rec42" for s in ann)
assert [s.speaker for s in ann] == ["A", "B", "A"]
assert [s.start for s in ann] == [0.0, 5.0, 10.0]
assert [s.end for s in ann] == [5.0, 10.0, 12.5]
@pytest.mark.unit
def test_make_diar_annotation_skips_malformed(self):
"""Lines with fewer than 3 tokens are ignored (defensive)."""
labels = ["0.0 5.0 A", "garbage", "", "5.0 10.0 B"]
ann = make_diar_annotation(labels, uniq_name="r")
assert len(ann) == 2
assert [s.speaker for s in ann] == ["A", "B"]
@pytest.mark.unit
def test_make_uem_timeline_basic(self):
uem = make_uem_timeline([[0.0, 5.0], [10.0, 12.0]], uniq_id="rec1")
assert len(uem) == 2
assert all(isinstance(s, SupervisionSegment) for s in uem)
assert all(s.speaker == "UEM" for s in uem)
assert all(s.recording_id == "rec1" for s in uem)
assert (uem[0].start, uem[0].end) == (0.0, 5.0)
assert (uem[1].start, uem[1].end) == (10.0, 12.0)
@pytest.mark.unit
def test_make_uem_timeline_empty(self):
assert make_uem_timeline([], uniq_id="r") == []
@pytest.mark.unit
@pytest.mark.parametrize(
"labels, expected",
[
pytest.param(
["0 1 B", "1 2 A", "2 3 B", "3 4 C", "4 5 A"],
["B", "A", "C"],
id="preserves_first_seen_order",
),
pytest.param(
[],
[],
id="empty",
),
],
)
def test_unique_speakers(self, labels, expected):
if labels:
ann = make_diar_annotation(labels, uniq_name="r")
else:
ann = []
result = unique_speakers(ann)
assert result == expected
@pytest.mark.unit
def test_unique_speakers_on_supervision_set(self):
ann = make_diar_annotation(["0 1 A", "1 2 B"], uniq_name="r")
ss = SupervisionSet.from_segments(ann)
assert sorted(unique_speakers(ss)) == ["A", "B"]
@pytest.mark.unit
def test_write_supervisions_to_rttm_format(self):
ann = make_diar_annotation(["0.0 1.5 A", "1.5 3.0 B"], uniq_name="rec1")
buf = io.StringIO()
write_supervisions_to_rttm(ann, buf)
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
assert len(lines) == 2
for ln in lines:
parts = ln.split()
assert parts[0] == "SPEAKER"
assert parts[1] == "rec1"
assert parts[2] == "1"
assert parts[5] == "<NA>" and parts[6] == "<NA>"
assert parts[8] == "<NA>" and parts[9] == "<NA>"
p0 = lines[0].split()
assert float(p0[3]) == pytest.approx(0.0)
assert float(p0[4]) == pytest.approx(1.5)
assert p0[7] == "A"
@pytest.mark.unit
def test_write_supervisions_to_rttm_skips_zero_duration(self):
ann = [
make_diar_segment(0.0, 1.0, "A", recording_id="rec1"),
make_diar_segment(2.0, 2.0, "B", recording_id="rec1"), # zero-duration
make_diar_segment(3.0, 4.5, "C", recording_id="rec1"),
]
buf = io.StringIO()
write_supervisions_to_rttm(ann, buf)
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
assert len(lines) == 2
speakers = [ln.split()[7] for ln in lines]
assert speakers == ["A", "C"]
@pytest.mark.unit
def test_write_supervisions_to_rttm_explicit_recording_id_override(self):
"""Explicit ``recording_id`` overrides per-segment ids."""
ann = make_diar_annotation(["0 1 A"], uniq_name="orig")
buf = io.StringIO()
write_supervisions_to_rttm(ann, buf, recording_id="overridden")
line = buf.getvalue().strip()
assert line.split()[1] == "overridden"
@pytest.mark.unit
def test_write_supervisions_to_rttm_round_trip(self):
"""Write annotations to RTTM, then read them back via lhotse.
Verifies our RTTM output is parseable by lhotse's RTTM reader,
confirming we follow the same format conventions.
"""
ann = make_diar_annotation(["0.0 2.5 alice", "2.5 5.0 bob", "5.0 7.25 alice"], uniq_name="conv1")
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".rttm", delete=False) as fh:
write_supervisions_to_rttm(ann, fh)
tmp_path = fh.name
try:
parsed = SupervisionSet.from_rttm(tmp_path)
parsed_segs = sorted(list(parsed), key=lambda s: s.start)
finally:
import os
os.unlink(tmp_path)
assert len(parsed_segs) == 3
assert [s.speaker for s in parsed_segs] == ["alice", "bob", "alice"]
assert [s.start for s in parsed_segs] == pytest.approx([0.0, 2.5, 5.0])
assert [s.end for s in parsed_segs] == pytest.approx([2.5, 5.0, 7.25])
class TestIterAnnotationSegments:
"""Verify ``md_eval._iter_annotation_segments`` accepts every supported type."""
@pytest.mark.unit
def test_iter_list_of_supervision_segments(self):
ann = make_diar_annotation(["0 1 A", "1 3 B"], uniq_name="r")
out = list(_iter_annotation_segments(ann))
assert out == [(0.0, 1.0, "A"), (1.0, 3.0, "B")]
@pytest.mark.unit
def test_iter_supervision_set(self):
ann = make_diar_annotation(["0 1 A", "1 3 B"], uniq_name="r")
ss = SupervisionSet.from_segments(ann)
out = list(_iter_annotation_segments(ss))
assert sorted(out) == [(0.0, 1.0, "A"), (1.0, 3.0, "B")]
@pytest.mark.unit
def test_iter_duck_typed_objects_with_end(self):
"""Plain dataclass-like objects with ``.start``, ``.end``, ``.speaker``."""
class _DT:
def __init__(self, start, end, speaker):
self.start = start
self.end = end
self.speaker = speaker
ann = [_DT(0.0, 2.0, "X"), _DT(2.0, 5.0, "Y")]
assert list(_iter_annotation_segments(ann)) == [(0.0, 2.0, "X"), (2.0, 5.0, "Y")]
@pytest.mark.unit
def test_iter_duck_typed_objects_with_duration(self):
"""Objects exposing ``.duration`` (no ``.end``) are also accepted."""
class _DT:
def __init__(self, start, duration, speaker):
self.start = start
self.duration = duration
self.speaker = speaker
self.end = None
ann = [_DT(0.0, 2.0, "X"), _DT(2.0, 3.0, "Y")]
assert list(_iter_annotation_segments(ann)) == [(0.0, 2.0, "X"), (2.0, 5.0, "Y")]
@pytest.mark.unit
def test_iter_legacy_itertracks_object(self):
"""Objects exposing ``.itertracks(yield_label=True)`` (legacy path).
This duck-typed fallback keeps backwards compatibility with the
external annotation library's ``Annotation`` API.
"""
class _Seg:
def __init__(self, s, e):
self.start = s
self.end = e
class _Ann:
def __init__(self, items):
self._items = items
def itertracks(self, yield_label=True):
for s, e, spk in self._items:
yield _Seg(s, e), "track", spk
ann = _Ann([(0.0, 1.5, "A"), (1.5, 4.0, "B")])
assert list(_iter_annotation_segments(ann)) == [(0.0, 1.5, "A"), (1.5, 4.0, "B")]
@pytest.mark.unit
def test_iter_missing_end_and_duration_raises(self):
class _Bad:
def __init__(self):
self.start = 0.0
self.speaker = "A"
with pytest.raises(TypeError, match="end.*duration"):
list(_iter_annotation_segments([_Bad()]))
@pytest.mark.unit
def test_iter_missing_speaker_raises(self):
class _Bad:
def __init__(self):
self.start = 0.0
self.end = 1.0
with pytest.raises(TypeError, match="speaker"):
list(_iter_annotation_segments([_Bad()]))
class TestLhotseAnnotation:
"""End-to-end DER tests using lhotse SupervisionSegment annotations.
Every scenario here is also covered by the legacy label-string tests
above (``TestScoreLabelsFromRttmLabels``); we re-run them through the
new lhotse pipeline (``score_labels`` + ``make_diar_annotation`` +
``make_uem_timeline``) and assert **bit-identical** DER/CER/FA/MISS.
Any divergence here means the lhotse adapter has regressed.
"""
@pytest.mark.unit
def test_perfect_match(self):
metric, mapping, (DER, CER, FA, MISS) = _score_lhotse(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
)
assert isinstance(metric, DiarizationErrorResult)
assert isinstance(mapping, dict)
assert_der(DER, 0.0)
assert_der(CER, 0.0)
assert_der(FA, 0.0)
assert_der(MISS, 0.0)
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, kwargs, checks",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[],
{},
{"DER": 1.0, "MISS": 1.0},
id="complete_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
{},
{"DER": 0.0},
id="speaker_swap_optimal_mapping",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A")],
{},
{"DER": 0.5, "MISS": 0.5},
id="partial_miss",
),
pytest.param(
[(0, 5, "A")],
[(0, 5, "A"), (5, 10, "A")],
{"uem_segs": [[0, 10]]},
{"DER": 1.0, "FA": 1.0},
id="partial_false_alarm",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "A")],
{},
{"DER": 0.5, "CER": 0.5},
id="confusion",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4.9, "A"), (5.1, 10, "B")],
{"collar": 0.25},
{"DER": 0.0},
id="collar_eliminates_boundary_error",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4, "A"), (6, 10, "B")],
{"collar": 0.25},
{"DER": 1.5 / 9.0, "MISS": 1.5 / 9.0},
id="collar_partial",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A"), (5, 10, "B")],
{"uem_segs": [[2, 8]]},
{"DER": 0.5, "CER": 0.5},
id="uem_restricts",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
{},
{"DER": 0.2, "CER": 0.2},
id="extra_hyp_speaker",
),
pytest.param(
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
[(0, 5, "A"), (5, 10, "B")],
{},
{"DER": 0.2, "CER": 0.2},
id="missing_hyp_speaker",
),
],
)
def test_der_values(self, ref_segs, hyp_segs, kwargs, checks):
_, _, (DER, CER, FA, MISS) = _score_lhotse(ref_segs, hyp_segs, **kwargs)
name_to_val = {"DER": DER, "CER": CER, "FA": FA, "MISS": MISS}
for key, val in checks.items():
assert_der(name_to_val[key], val)
@pytest.mark.unit
def test_ignore_overlap(self):
"""``ignore_overlap=True`` should suppress overlap-region scoring."""
_, _, (DER_no, _, _, _) = _score_lhotse(
[(0, 5, "A"), (3, 7, "B")],
[(0, 5, "A"), (3, 7, "B")],
ignore_overlap=False,
)
_, _, (DER_yes, _, _, _) = _score_lhotse(
[(0, 5, "A"), (3, 7, "B")],
[(0, 5, "A"), (3, 7, "B")],
ignore_overlap=True,
)
assert_der(DER_no, 0.0)
assert_der(DER_yes, 0.0)
@pytest.mark.unit
def test_accepts_supervision_set(self):
"""``score_labels`` should accept a ``SupervisionSet`` directly."""
ref = SupervisionSet.from_segments(make_diar_annotation(["0 5 A", "5 10 B"], uniq_name="f1"))
hyp = SupervisionSet.from_segments(make_diar_annotation(["0 5 A", "5 10 B"], uniq_name="f1"))
result = score_labels(
{"f1": {}},
[("f1", ref)],
[("f1", hyp)],
collar=0.0,
ignore_overlap=False,
verbose=False,
)
assert result is not None
_, _, (DER, _, _, _) = result
assert_der(DER, 0.0)
@pytest.mark.unit
def test_multi_file_scoring(self):
"""Two files, one perfect and one with confusion → averaged DER."""
f1_ref = make_diar_annotation(["0 5 A"], uniq_name="f1")
f1_hyp = make_diar_annotation(["0 5 A"], uniq_name="f1")
f2_ref = make_diar_annotation(["0 4 A", "4 8 B"], uniq_name="f2")
f2_hyp = make_diar_annotation(["0 8 A"], uniq_name="f2")
result = score_labels(
{"f1": {}, "f2": {}},
[("f1", f1_ref), ("f2", f2_ref)],
[("f1", f1_hyp), ("f2", f2_hyp)],
collar=0.0,
ignore_overlap=False,
verbose=False,
)
assert result is not None
metric, _, (DER, _, _, _) = result
assert_der(DER, 4.0 / 13.0)
assert len(metric.results_) == 2
class TestLhotseStringEquivalence:
"""The lhotse path and the legacy label-string path must agree on every metric.
Same reference + hypothesis fed through both ``score_labels`` (lhotse
annotations) and ``score_labels_from_rttm_labels`` (label strings) must
produce bit-identical (DER, CER, FA, MISS).
"""
@staticmethod
def _both(ref_segs, hyp_segs, **kw):
string_path = _score(ref_segs, hyp_segs, **kw)
lhotse_path = _score_lhotse(ref_segs, hyp_segs, **kw)
return string_path[2], lhotse_path[2] # itemized errors
@pytest.mark.unit
@pytest.mark.parametrize(
"ref_segs, hyp_segs, kwargs",
[
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 10, "B")],
{},
id="perfect",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[],
{},
id="complete_miss",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "B"), (5, 10, "A")],
{},
id="speaker_swap",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 4.9, "A"), (5.1, 10, "B")],
{"collar": 0.25},
id="collar",
),
pytest.param(
[(0, 10, "A")],
[(0, 5, "A"), (5, 10, "B")],
{"uem_segs": [[2, 8]]},
id="uem",
),
pytest.param(
[(0, 5, "A"), (3, 7, "B")],
[(0, 4, "A"), (3, 7, "B")],
{"ignore_overlap": True},
id="ignore_overlap",
),
pytest.param(
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
[(0, 4, "A"), (4, 7, "B"), (7, 10, "C")],
{"collar": 0.5},
id="three_speakers",
),
pytest.param(
[(0, 5, "A"), (5, 10, "B")],
[(0, 5, "A"), (5, 8, "B"), (8, 10, "C")],
{},
id="extra_hyp_speaker",
),
],
)
def test_equivalence(self, ref_segs, hyp_segs, kwargs):
string_path, lhotse_path = self._both(ref_segs, hyp_segs, **kwargs)
assert string_path == pytest.approx(lhotse_path)
# ─── Tests: audio_end clipping ────────────────────────────────────────────
class TestAudioEndClipping:
"""Verify that the ``audio_end`` clipping mechanism prevents hypothesis
segments from overshooting the manifest-declared audio duration.
Mirrors the real-world scenario where the diarization model's last
prediction frame extends past the audio boundary (e.g., DH_EVAL_0019:
manifest duration=80.1785 s but the final hypothesis segment ends at
80.23 s).
"""
@pytest.mark.unit
@pytest.mark.parametrize(
"labels, audio_end, expected_count, expected_last_end",
[
(["0.0 50.0 A", "75.280 80.230 B", "85.0 90.0 C"], 80.1785, 2, 80.1785),
(["0.0 10.0 A", "10.0 20.5 B"], 20.0, 2, 20.0),
(["0.0 5.0 A", "20.0 25.0 B"], 20.0, 1, 5.0),
(["0.0 100.0 A"], None, 1, 100.0),
],
ids=["overshoot+drop", "trim_last", "drop_past_boundary", "no_clip"],
)
def test_make_diar_annotation_audio_end(self, labels, audio_end, expected_count, expected_last_end):
"""Segments past audio_end are trimmed or dropped; None disables clipping."""
ann = make_diar_annotation(labels, uniq_name="sess1", audio_end=audio_end)
assert len(ann) == expected_count
last = ann[-1]
actual_end = last.start + last.duration
assert abs(actual_end - expected_last_end) < 1e-9
@pytest.mark.unit
@pytest.mark.parametrize(
"manifest, expect_zero_fa",
[
({"offset": 0, "duration": 80.0}, True),
({}, False),
],
ids=["with_manifest_clamp", "no_manifest_counts_fa"],
)
def test_score_labels_manifest_uem_clamp(self, manifest, expect_zero_fa):
"""Manifest duration clamps the auto-UEM; without it, overshoot is FA."""
file_id = "DH_EVAL_0019"
ref_ann = make_diar_annotation(_labels((0, 80, "A")), uniq_name=file_id)
hyp_ann = make_diar_annotation(_labels((0, 80.5, "A")), uniq_name=file_id)
audio_rttm_map = {file_id: manifest}
result = score_labels(
audio_rttm_map,
[(file_id, ref_ann)],
[(file_id, hyp_ann)],
collar=0.0,
ignore_overlap=False,
verbose=False,
)
assert result is not None
_, _, (DER, CER, FA, MISS) = result
if expect_zero_fa:
assert_der(FA, 0.0)
else:
assert FA > 0.0, "Without manifest duration, overshoot should be counted as FA"