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
393 lines
13 KiB
Python
393 lines
13 KiB
Python
# Copyright (c) 2025, 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.
|
|
import pytest
|
|
import torch
|
|
|
|
from nemo.collections.speechlm2.parts.metrics import BLEU, WER, Intelligibility
|
|
from nemo.collections.speechlm2.parts.metrics.empty_text import EmptyTextMetric
|
|
from nemo.collections.speechlm2.parts.metrics.perplexity import Perplexity
|
|
|
|
|
|
def test_bleu():
|
|
metric = BLEU(verbose=False)
|
|
metric.update(
|
|
name="dataset_1",
|
|
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
)
|
|
metric.update(
|
|
name="dataset_2",
|
|
refs=["a b c"],
|
|
hyps=["a b d"],
|
|
)
|
|
ans = metric.compute()
|
|
assert ans["txt_bleu_dataset_1"] == 100.0
|
|
assert ans["txt_bleu_dataset_2"] == 0.0
|
|
assert ans["txt_bleu"] == 50.0 # average across datasets
|
|
|
|
|
|
def test_wer():
|
|
metric = WER(verbose=False)
|
|
metric.update(
|
|
name="dataset_1",
|
|
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
)
|
|
metric.update(
|
|
name="dataset_2",
|
|
refs=["a b c"],
|
|
hyps=["a b d"],
|
|
)
|
|
ans = metric.compute()
|
|
assert ans["wer_dataset_1"] == 0.0
|
|
assert ans["wer_dataset_2"] == 1 / 3
|
|
assert ans["wer"] == 1 / 6 # average across datasets
|
|
|
|
|
|
def test_empty_text_metric():
|
|
"""Test EmptyTextMetric for detecting empty hypotheses"""
|
|
metric = EmptyTextMetric(verbose=False)
|
|
|
|
# Test with some empty and non-empty texts
|
|
hyps = ["hello world", "", " ", "test", " \n "]
|
|
metric.update("test_batch", hyps)
|
|
|
|
results = metric.compute()
|
|
|
|
# Should detect 3 empty texts out of 5
|
|
assert "empty_text_rate_test_batch" in results
|
|
assert results["empty_text_rate_test_batch"].item() == pytest.approx(0.6, abs=0.01) # 3/5 = 0.6
|
|
|
|
|
|
def test_empty_text_metric_reset():
|
|
"""Test EmptyTextMetric reset functionality"""
|
|
metric = EmptyTextMetric(verbose=False)
|
|
|
|
# Add some data
|
|
metric.update("test", ["hello", ""])
|
|
metric.reset()
|
|
|
|
# After reset, should have no data
|
|
results = metric.compute()
|
|
assert len(results) == 0
|
|
|
|
|
|
def test_empty_text_all_valid():
|
|
"""Test EmptyTextMetric with no empty texts"""
|
|
metric = EmptyTextMetric(verbose=False)
|
|
|
|
metric.update("test", ["hello", "world", "test"])
|
|
|
|
results = metric.compute()
|
|
assert results["empty_text_rate_test"].item() == 0.0
|
|
|
|
|
|
def test_empty_text_all_empty():
|
|
"""Test EmptyTextMetric with all empty texts"""
|
|
metric = EmptyTextMetric(verbose=False)
|
|
|
|
metric.update("test", ["", " ", "\n"])
|
|
|
|
results = metric.compute()
|
|
assert results["empty_text_rate_test"].item() == 1.0
|
|
|
|
|
|
def test_perplexity_basic():
|
|
"""Test basic perplexity calculation"""
|
|
metric = Perplexity(ignore_index=-100, verbose=False)
|
|
|
|
# Create simple logits and targets
|
|
vocab_size = 10
|
|
batch_size = 2
|
|
seq_len = 3
|
|
|
|
# Create perfect predictions (all correct)
|
|
logits = torch.zeros(batch_size, seq_len, vocab_size)
|
|
targets = torch.tensor([[1, 2, 3], [4, 5, 6]])
|
|
|
|
# Set logits to have high probability for correct tokens
|
|
for b in range(batch_size):
|
|
for s in range(seq_len):
|
|
logits[b, s, targets[b, s]] = 10.0 # High logit for correct token
|
|
|
|
ppl = metric.update("test", logits, targets)
|
|
|
|
# With perfect predictions, perplexity should be close to 1
|
|
assert ppl < 2.0 # Should be very low
|
|
|
|
|
|
def test_perplexity_with_padding():
|
|
"""Test perplexity with padding tokens"""
|
|
metric = Perplexity(ignore_index=-100, verbose=False)
|
|
|
|
vocab_size = 10
|
|
batch_size = 2
|
|
seq_len = 4
|
|
|
|
logits = torch.randn(batch_size, seq_len, vocab_size)
|
|
# Include padding tokens (ignore_index = -100)
|
|
targets = torch.tensor([[1, 2, 3, -100], [4, 5, -100, -100]])
|
|
|
|
ppl = metric.update("test", logits, targets)
|
|
|
|
# Should not fail with padding
|
|
assert ppl > 0
|
|
assert not torch.isnan(torch.tensor(ppl))
|
|
|
|
|
|
def test_perplexity_compute():
|
|
"""Test perplexity compute aggregation"""
|
|
metric = Perplexity(ignore_index=-100, verbose=False)
|
|
|
|
vocab_size = 10
|
|
logits = torch.randn(2, 3, vocab_size)
|
|
targets = torch.tensor([[1, 2, 3], [4, 5, 6]])
|
|
|
|
metric.update("test", logits, targets)
|
|
|
|
results = metric.compute()
|
|
assert "perplexity_test" in results
|
|
assert results["perplexity_test"] > 0
|
|
|
|
|
|
def test_turn_taking_import():
|
|
"""Test that turn taking metric function can be imported"""
|
|
from nemo.collections.speechlm2.parts.metrics.turn_taking import compute_turn_taking_metrics
|
|
|
|
# Test with dummy data
|
|
source_tokens = torch.tensor([[1, 2, 3, 4]]) # dummy tokens
|
|
pred_tokens = torch.tensor([[5, 6, 7, 8]]) # dummy tokens
|
|
eos_token_id = 4
|
|
bos_token_id = 5
|
|
|
|
accuracy, latency = compute_turn_taking_metrics(source_tokens, pred_tokens, eos_token_id, bos_token_id)
|
|
assert isinstance(accuracy, float)
|
|
assert isinstance(latency, float)
|
|
|
|
|
|
def test_mcq_evaluator_import():
|
|
"""Test that MCQ evaluator can be imported and initialized"""
|
|
import tempfile
|
|
|
|
from nemo.collections.speechlm2.parts.metrics.mcq_evaluator import MCQEvaluator
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
evaluator = MCQEvaluator(manifest_dir=tmpdir)
|
|
assert evaluator is not None
|
|
assert evaluator.manifest_dir == tmpdir
|
|
|
|
|
|
def test_results_logger_import():
|
|
"""Test that results logger can be imported"""
|
|
import tempfile
|
|
|
|
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
logger = ResultsLogger(save_path=tmpdir)
|
|
assert logger is not None
|
|
|
|
|
|
def test_results_logger_single_rank(tmp_path):
|
|
"""Test ResultsLogger with single rank (non-distributed)"""
|
|
from unittest.mock import patch
|
|
|
|
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
|
|
|
|
save_path = str(tmp_path / "single_rank")
|
|
|
|
# Mock distributed functions to simulate single rank
|
|
with (
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=1),
|
|
patch('torch.distributed.is_available', return_value=False),
|
|
):
|
|
|
|
logger = ResultsLogger(save_path=save_path)
|
|
|
|
# Add some test data
|
|
logger.update(
|
|
name="test_dataset",
|
|
refs=["hello world", "goodbye"],
|
|
hyps=["hello there", "goodbye"],
|
|
asr_hyps=[None, None],
|
|
samples_id=["sample1", "sample2"],
|
|
pred_audio=None,
|
|
pred_audio_sr=16000,
|
|
user_audio=None,
|
|
user_audio_sr=16000,
|
|
src_refs=["user input 1", "user input 2"],
|
|
src_hyps=["", ""],
|
|
)
|
|
|
|
# Compute and save
|
|
metrics = logger.compute_and_save(special_subset_names=[], mcq_subset_names=[])
|
|
|
|
# Check that files were created
|
|
import os
|
|
|
|
metadata_path = os.path.join(save_path, "metadatas", "test_dataset_rank0.json")
|
|
assert os.path.exists(metadata_path)
|
|
|
|
# Check final merged file (should be same as rank0 in single-rank case)
|
|
final_path = os.path.join(save_path, "metadatas", "test_dataset.json")
|
|
assert os.path.exists(final_path)
|
|
|
|
|
|
def test_results_logger_multi_rank(tmp_path):
|
|
"""Test ResultsLogger with multiple ranks (simulated distributed training)"""
|
|
import json
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
|
|
|
|
save_path = str(tmp_path / "multi_rank")
|
|
world_size = 4
|
|
|
|
# Simulate each rank saving its own results
|
|
for rank in range(world_size):
|
|
with (
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=rank),
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
|
|
patch('torch.distributed.is_available', return_value=True),
|
|
patch('torch.distributed.is_initialized', return_value=True),
|
|
patch('torch.distributed.barrier'),
|
|
): # Mock barrier to avoid actual distributed ops
|
|
|
|
logger = ResultsLogger(save_path=save_path)
|
|
|
|
# Each rank processes different samples
|
|
start_idx = rank * 2
|
|
logger.update(
|
|
name="test_dataset",
|
|
refs=[f"ref_{start_idx}", f"ref_{start_idx+1}"],
|
|
hyps=[f"hyp_{start_idx}", f"hyp_{start_idx+1}"],
|
|
asr_hyps=[None, None],
|
|
samples_id=[f"sample_{start_idx}", f"sample_{start_idx+1}"],
|
|
pred_audio=None,
|
|
pred_audio_sr=16000,
|
|
user_audio=None,
|
|
user_audio_sr=16000,
|
|
src_refs=[f"src_{start_idx}", f"src_{start_idx+1}"],
|
|
src_hyps=["", ""],
|
|
)
|
|
|
|
# Save rank-specific files (only this part, not the merge)
|
|
rank_json_path = os.path.join(save_path, "metadatas", f"test_dataset_rank{rank}.json")
|
|
os.makedirs(os.path.dirname(rank_json_path), exist_ok=True)
|
|
with open(rank_json_path, 'w', encoding='utf-8') as fout:
|
|
for item in logger.cached_results["test_dataset"]:
|
|
fout.write(json.dumps(item, ensure_ascii=False) + '\n')
|
|
|
|
# Now simulate rank 0 merging all results
|
|
with (
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
|
|
patch('torch.distributed.is_available', return_value=True),
|
|
patch('torch.distributed.is_initialized', return_value=True),
|
|
patch('torch.distributed.barrier'),
|
|
patch('torch.distributed.broadcast_object_list'),
|
|
): # Mock broadcast
|
|
|
|
logger = ResultsLogger(save_path=save_path)
|
|
# Manually set cached_results to simulate what rank 0 would have
|
|
logger.cached_results["test_dataset"] = []
|
|
|
|
# Call merge function directly
|
|
merged_results = logger._merge_rank_files("test_dataset")
|
|
|
|
# Verify all samples from all ranks are merged
|
|
assert len(merged_results) == world_size * 2 # 4 ranks * 2 samples each
|
|
|
|
# Verify sample IDs are from all ranks
|
|
sample_ids = [item["id"] for item in merged_results]
|
|
assert "sample_0" in sample_ids
|
|
assert "sample_1" in sample_ids
|
|
assert "sample_6" in sample_ids # Last rank's samples
|
|
assert "sample_7" in sample_ids
|
|
|
|
|
|
def test_results_logger_rank_file_wait(tmp_path):
|
|
"""Test that rank 0 waits for other ranks' files"""
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
|
|
|
|
save_path = str(tmp_path / "wait_test")
|
|
os.makedirs(os.path.join(save_path, "metadatas"), exist_ok=True)
|
|
world_size = 2
|
|
|
|
# Create rank 0's file immediately
|
|
rank0_file = os.path.join(save_path, "metadatas", "test_dataset_rank0.json")
|
|
with open(rank0_file, 'w') as f:
|
|
json.dump({"id": "sample_0", "pred_text": "test0"}, f)
|
|
f.write('\n')
|
|
|
|
# Simulate rank 1's file appearing after a delay
|
|
def create_rank1_file_delayed():
|
|
time.sleep(1) # 1 second delay
|
|
rank1_file = os.path.join(save_path, "metadatas", "test_dataset_rank1.json")
|
|
with open(rank1_file, 'w') as f:
|
|
json.dump({"id": "sample_1", "pred_text": "test1"}, f)
|
|
f.write('\n')
|
|
|
|
thread = threading.Thread(target=create_rank1_file_delayed)
|
|
thread.start()
|
|
|
|
# Rank 0 tries to merge - should wait for rank 1's file
|
|
with (
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
|
|
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
|
|
):
|
|
|
|
logger = ResultsLogger(save_path=save_path)
|
|
merged_results = logger._merge_rank_files("test_dataset")
|
|
|
|
# Should have results from both ranks
|
|
assert len(merged_results) == 2
|
|
sample_ids = [item["id"] for item in merged_results]
|
|
assert "sample_0" in sample_ids
|
|
assert "sample_1" in sample_ids
|
|
|
|
thread.join()
|
|
|
|
|
|
def test_intelligibility():
|
|
metric = Intelligibility(pretrained_asr=None, verbose=False, reuse_asr_hyps=True)
|
|
metric.update(
|
|
name="dataset_1",
|
|
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
asr_hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
|
|
pred_audio=None,
|
|
)
|
|
metric.update(
|
|
name="dataset_2",
|
|
refs=["a b c"],
|
|
asr_hyps=["a b d"],
|
|
pred_audio=None,
|
|
)
|
|
ans = metric.compute()
|
|
# wer
|
|
assert ans["wer_dataset_1"] == 0.0
|
|
assert ans["wer_dataset_2"] == 1 / 3
|
|
assert ans["wer"] == 1 / 6 # average across datasets
|
|
# cer
|
|
assert ans["cer_dataset_1"] == 0.0
|
|
assert ans["cer_dataset_2"] == 1 / 5
|