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

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