chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from tests.collections.asr.decoding.utils import make_preprocessor_deterministic, preserve_decoding_cfg_and_cpu_device
|
||||
|
||||
CHECKPOINTS_PATH = Path("/home/TestData/asr")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def an4_val_manifest_corrected(tmp_path_factory, test_data_dir):
|
||||
"""
|
||||
Correct an4_val manifest audio filepaths, e.g.,
|
||||
"tests/data/asr/test/an4/wav/an440-mjgm-b.wav" -> test_data_dir / "test/an4/wav/an440-mjgm-b.wav"
|
||||
"""
|
||||
an4_val_manifest_orig_path = Path(test_data_dir) / "asr/an4_val.json"
|
||||
an4_val_manifest_corrected_path = tmp_path_factory.mktemp("manifests") / "an4_val_corrected.json"
|
||||
an4_val_records = read_manifest(an4_val_manifest_orig_path)
|
||||
for record in an4_val_records:
|
||||
record["audio_filepath"] = record["audio_filepath"].replace(
|
||||
"tests/data/asr", str(an4_val_manifest_orig_path.resolve().parent)
|
||||
)
|
||||
write_manifest(an4_val_manifest_corrected_path, an4_val_records)
|
||||
return an4_val_manifest_corrected_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def an4_train_manifest_corrected(tmp_path_factory, test_data_dir):
|
||||
"""
|
||||
Correct an4_train manifest audio filepaths, e.g.,
|
||||
"tests/data/asr/test/an4/wav/an440-mjgm-b.wav" -> test_data_dir / "test/an4/wav/an440-mjgm-b.wav"
|
||||
"""
|
||||
an4_train_manifest_orig_path = Path(test_data_dir) / "asr/an4_train.json"
|
||||
an4_train_manifest_corrected_path = tmp_path_factory.mktemp("manifests") / "an4_train_corrected.json"
|
||||
an4_train_records = read_manifest(an4_train_manifest_orig_path)
|
||||
for record in an4_train_records:
|
||||
record["audio_filepath"] = record["audio_filepath"].replace(
|
||||
"tests/data/asr", str(an4_train_manifest_orig_path.resolve().parent)
|
||||
)
|
||||
write_manifest(an4_train_manifest_corrected_path, an4_train_records)
|
||||
return an4_train_manifest_corrected_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="package")
|
||||
def _stt_en_conformer_transducer_small_raw():
|
||||
if CHECKPOINTS_PATH.exists():
|
||||
model = ASRModel.restore_from(
|
||||
str(CHECKPOINTS_PATH / "stt_en_conformer_transducer_small.nemo"), map_location="cpu"
|
||||
)
|
||||
else:
|
||||
model_name = "stt_en_conformer_transducer_small"
|
||||
model = ASRModel.from_pretrained(model_name, map_location="cpu")
|
||||
make_preprocessor_deterministic(model)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="package")
|
||||
def _stt_en_fastconformer_transducer_large_raw():
|
||||
if CHECKPOINTS_PATH.exists():
|
||||
model = ASRModel.restore_from(
|
||||
str(CHECKPOINTS_PATH / "stt_en_fastconformer_transducer_large.nemo"), map_location="cpu"
|
||||
)
|
||||
else:
|
||||
model_name = "stt_en_fastconformer_transducer_large"
|
||||
model = ASRModel.from_pretrained(model_name, map_location="cpu")
|
||||
make_preprocessor_deterministic(model)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="package")
|
||||
def _stt_en_fastconformer_tdt_large_raw():
|
||||
if CHECKPOINTS_PATH.exists():
|
||||
model = ASRModel.restore_from(
|
||||
str(CHECKPOINTS_PATH / "stt_en_fastconformer_tdt_large.nemo"), map_location="cpu"
|
||||
)
|
||||
else:
|
||||
model_name = "nvidia/stt_en_fastconformer_tdt_large"
|
||||
model = ASRModel.from_pretrained(model_name, map_location="cpu")
|
||||
make_preprocessor_deterministic(model)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="package")
|
||||
def _canary_180m_flash_raw():
|
||||
model_name = "nvidia/canary-180m-flash"
|
||||
model = ASRModel.from_pretrained(model_name, map_location="cpu")
|
||||
make_preprocessor_deterministic(model)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stt_en_conformer_transducer_small(_stt_en_conformer_transducer_small_raw):
|
||||
"""Function-level fixture for model. Guarantees to preserve decoding config and device"""
|
||||
model = _stt_en_conformer_transducer_small_raw
|
||||
with preserve_decoding_cfg_and_cpu_device(model):
|
||||
yield model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stt_en_fastconformer_transducer_large(_stt_en_fastconformer_transducer_large_raw):
|
||||
"""Function-level fixture for model. Guarantees to preserve decoding config and device"""
|
||||
model = _stt_en_fastconformer_transducer_large_raw
|
||||
with preserve_decoding_cfg_and_cpu_device(model):
|
||||
yield model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stt_en_fastconformer_tdt_large(_stt_en_fastconformer_tdt_large_raw):
|
||||
"""Function-level fixture for model. Guarantees to preserve decoding config and device"""
|
||||
model = _stt_en_fastconformer_tdt_large_raw
|
||||
with preserve_decoding_cfg_and_cpu_device(model):
|
||||
yield model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canary_180m_flash(_canary_180m_flash_raw):
|
||||
"""Function-level fixture for model. Guarantees to preserve decoding config and device"""
|
||||
model = _canary_180m_flash_raw
|
||||
with preserve_decoding_cfg_and_cpu_device(model):
|
||||
yield model
|
||||
@@ -0,0 +1,883 @@
|
||||
# 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 copy
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import open_dict
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.models.ctc_models import EncDecCTCModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_beam_decoding import BeamBatchedCTCInfer
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel
|
||||
from nemo.collections.asr.parts.submodules.rnnt_beam_decoding import BeamBatchedRNNTInfer
|
||||
from nemo.collections.asr.parts.submodules.tdt_beam_decoding import BeamBatchedTDTInfer
|
||||
from nemo.collections.asr.parts.utils import rnnt_utils
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.cuda_python_utils import skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
from tests.collections.asr.decoding.utils import load_audio
|
||||
|
||||
RNNT_MODEL = "stt_en_conformer_transducer_small"
|
||||
CTC_MODEL = "nvidia/stt_en_conformer_ctc_small"
|
||||
TDT_MODEL = "nvidia/stt_en_fastconformer_tdt_large"
|
||||
MAX_SAMPLES = 10
|
||||
|
||||
DEVICES = [torch.device("cpu")]
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device('cuda'))
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
# available audio filename fixtures
|
||||
@pytest.fixture(scope="module")
|
||||
def test_audio_filenames(test_data_dir):
|
||||
return tuple(glob.glob(os.path.join(test_data_dir, "asr", "test", "an4", "wav", "*.wav")))
|
||||
|
||||
|
||||
# model fixtures
|
||||
@pytest.fixture(scope="module")
|
||||
def rnnt_model():
|
||||
model = ASRModel.from_pretrained(model_name=RNNT_MODEL, map_location="cpu")
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tdt_model():
|
||||
model = ASRModel.from_pretrained(model_name=TDT_MODEL, map_location="cpu")
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ctc_model():
|
||||
model = ASRModel.from_pretrained(model_name=CTC_MODEL, map_location="cpu")
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
# encoder output fixtures
|
||||
@pytest.fixture(scope="module")
|
||||
def get_rnnt_encoder_output(rnnt_model, test_audio_filenames):
|
||||
encoder_output, encoded_lengths = get_transducer_model_encoder_output(
|
||||
test_audio_filenames, MAX_SAMPLES, rnnt_model
|
||||
)
|
||||
return encoder_output, encoded_lengths
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def get_tdt_encoder_output(tdt_model, test_audio_filenames):
|
||||
encoder_output, encoded_lengths = get_transducer_model_encoder_output(test_audio_filenames, MAX_SAMPLES, tdt_model)
|
||||
return encoder_output, encoded_lengths
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def get_ctc_output(ctc_model, test_audio_filenames):
|
||||
encoder_output, encoded_lengths = get_ctc_model_output(test_audio_filenames, MAX_SAMPLES, ctc_model)
|
||||
return encoder_output, encoded_lengths
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kenlm_model_path(tmp_path_factory, test_data_dir):
|
||||
lm_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
assert os.path.exists(lm_path), f"LM file not found: {lm_path}"
|
||||
lm_nemo_path = tmp_path_factory.mktemp("lm") / f"{lm_path.name}.nemo"
|
||||
NGramGPULanguageModel.from_file(lm_path, vocab_size=1024).save_to(f"{lm_nemo_path}")
|
||||
return f"{lm_nemo_path}"
|
||||
|
||||
|
||||
def get_transducer_model_encoder_output(
|
||||
test_audio_filenames,
|
||||
num_samples: int,
|
||||
model: ASRModel,
|
||||
device: torch.device = torch.device("cpu"),
|
||||
dtype: torch.dtype = torch.float32,
|
||||
):
|
||||
audio_filepaths = test_audio_filenames[:num_samples]
|
||||
|
||||
with torch.no_grad():
|
||||
model.preprocessor.featurizer.dither = 0.0
|
||||
model.preprocessor.featurizer.pad_to = 0
|
||||
model.eval()
|
||||
|
||||
all_inputs, all_lengths = [], []
|
||||
for audio_file in tqdm(audio_filepaths, desc="Loading audio files"):
|
||||
audio_tensor, _ = load_audio(audio_file)
|
||||
all_inputs.append(audio_tensor)
|
||||
all_lengths.append(torch.tensor(audio_tensor.shape[0], dtype=torch.int64))
|
||||
|
||||
input_batch = torch.nn.utils.rnn.pad_sequence(all_inputs, batch_first=True).to(device=device, dtype=dtype)
|
||||
length_batch = torch.tensor(all_lengths, dtype=torch.int64).to(device)
|
||||
|
||||
encoded_outputs, encoded_length = model(input_signal=input_batch, input_signal_length=length_batch)
|
||||
|
||||
return encoded_outputs, encoded_length
|
||||
|
||||
|
||||
def get_ctc_model_output(
|
||||
test_audio_filenames,
|
||||
num_samples: int,
|
||||
model: ASRModel,
|
||||
device: torch.device = torch.device("cpu"),
|
||||
dtype: torch.dtype = torch.float32,
|
||||
):
|
||||
audio_filepaths = test_audio_filenames[:num_samples]
|
||||
|
||||
with torch.no_grad():
|
||||
model.preprocessor.featurizer.dither = 0.0
|
||||
model.preprocessor.featurizer.pad_to = 0
|
||||
model.eval()
|
||||
|
||||
all_inputs, all_lengths = [], []
|
||||
for audio_file in tqdm(audio_filepaths, desc="Loading audio files"):
|
||||
audio_tensor, _ = load_audio(audio_file)
|
||||
all_inputs.append(audio_tensor)
|
||||
all_lengths.append(torch.tensor(audio_tensor.shape[0], dtype=torch.int64))
|
||||
|
||||
input_batch = torch.nn.utils.rnn.pad_sequence(all_inputs, batch_first=True).to(device=device, dtype=dtype)
|
||||
length_batch = torch.tensor(all_lengths, dtype=torch.int64).to(device)
|
||||
|
||||
log_probs, encoded_length, _ = model(input_signal=input_batch, input_signal_length=length_batch)
|
||||
|
||||
return log_probs, encoded_length
|
||||
|
||||
|
||||
def print_unit_test_info(strategy, batch_size, beam_size, allow_cuda_graphs, device):
|
||||
print(
|
||||
f"""Beam search algorithm: {strategy},
|
||||
Batch size: {batch_size},
|
||||
Beam size: {beam_size},
|
||||
Cuda Graphs: {allow_cuda_graphs},
|
||||
Decoding device: {device}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def check_res_best_hyps(num_samples, hyps):
|
||||
assert type(hyps) == list
|
||||
assert type(hyps[0]) == rnnt_utils.Hypothesis
|
||||
|
||||
assert len(hyps) == num_samples
|
||||
|
||||
assert all(
|
||||
[
|
||||
hasattr(hyps[hyp_idx], "y_sequence")
|
||||
and hasattr(hyps[hyp_idx], "score")
|
||||
and hasattr(hyps[hyp_idx], "timestamp")
|
||||
for hyp_idx in range(num_samples)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def print_res_best_hyps(hyps):
|
||||
for hyp_idx, hyp in enumerate(hyps):
|
||||
print("Sample: ", hyp_idx)
|
||||
print("Decoded text: ", hyp.text)
|
||||
print("Score: ", hyp.score)
|
||||
print("Transcript", hyp.y_sequence)
|
||||
print("Timesteps", hyp.timestamp)
|
||||
print()
|
||||
|
||||
|
||||
def check_res_nbest_hyps(num_samples, batch_nbest_hyps):
|
||||
assert type(batch_nbest_hyps) == list
|
||||
assert type(batch_nbest_hyps[0]) == rnnt_utils.NBestHypotheses
|
||||
|
||||
assert len(batch_nbest_hyps) == num_samples
|
||||
|
||||
for idx in range(num_samples):
|
||||
assert all(
|
||||
[
|
||||
hasattr(batch_nbest_hyps[idx].n_best_hypotheses[hyp_idx], "y_sequence")
|
||||
and hasattr(batch_nbest_hyps[idx].n_best_hypotheses[hyp_idx], "score")
|
||||
and hasattr(batch_nbest_hyps[idx].n_best_hypotheses[hyp_idx], "timestamp")
|
||||
for hyp_idx in range(len(batch_nbest_hyps[idx].n_best_hypotheses))
|
||||
]
|
||||
)
|
||||
|
||||
# Empty transcript (blank-only beam) is valid; y_sequence and timestamp must stay aligned.
|
||||
assert all(
|
||||
len(batch_nbest_hyps[idx].n_best_hypotheses[hyp_idx].y_sequence)
|
||||
== len(batch_nbest_hyps[idx].n_best_hypotheses[hyp_idx].timestamp)
|
||||
for hyp_idx in range(len(batch_nbest_hyps[idx].n_best_hypotheses))
|
||||
)
|
||||
|
||||
|
||||
def print_res_nbest_hyps(batch_nbest_hyps):
|
||||
for batch_idx, nbest_hyps in enumerate(batch_nbest_hyps):
|
||||
print(f"Batch idx: {batch_idx}")
|
||||
for idx, hyp in enumerate(nbest_hyps):
|
||||
print(f"Hyp index: {idx + 1}")
|
||||
print("Text: ", hyp.text)
|
||||
print("Score: ", hyp.score)
|
||||
print("Transcripts: ", hyp.y_sequence)
|
||||
print("Timesteps: ", hyp.timestamp)
|
||||
print()
|
||||
|
||||
|
||||
def decode_text_from_hypotheses(hyps, model):
|
||||
if isinstance(model, EncDecCTCModel):
|
||||
return model.decoding.decode_hypothesis(hyps, fold_consecutive=False)
|
||||
else:
|
||||
return model.decoding.decode_hypothesis(hyps)
|
||||
|
||||
|
||||
def decode_text_from_nbest_hypotheses(hyps, model):
|
||||
if isinstance(model, EncDecCTCModel):
|
||||
return [
|
||||
model.decoding.decode_hypothesis(nbest_hyp.n_best_hypotheses, fold_consecutive=False) for nbest_hyp in hyps
|
||||
]
|
||||
else:
|
||||
return [model.decoding.decode_hypothesis(nbest_hyp.n_best_hypotheses) for nbest_hyp in hyps]
|
||||
|
||||
|
||||
class TestRNNTDecoding:
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
{"search_type": "maes_batch", "allow_cuda_graphs": False},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4, 16])
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_rnnt_beam_decoding_return_best_hypothesis(
|
||||
self, test_audio_filenames, rnnt_model, get_rnnt_encoder_output, beam_config, device, batch_size, beam_size
|
||||
):
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = rnnt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_rnnt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=True,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
{"search_type": "maes_batch", "allow_cuda_graphs": False},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
def test_rnnt_beam_decoding_return_nbest(
|
||||
self, test_audio_filenames, rnnt_model, get_rnnt_encoder_output, beam_config, device, beam_size, batch_size
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = rnnt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_rnnt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=False,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
batch_nbest_hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_nbest_hyps(num_samples, batch_nbest_hyps)
|
||||
batch_nbest_hyps = decode_text_from_nbest_hypotheses(batch_nbest_hyps, model)
|
||||
print_res_nbest_hyps(batch_nbest_hyps)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "maes_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("pruning_mode", ["late", "early"])
|
||||
@pytest.mark.parametrize("blank_lm_score_mode", ["no_score", "lm_weighted_full"])
|
||||
def test_rnnt_beam_decoding_kenlm(
|
||||
self,
|
||||
kenlm_model_path,
|
||||
test_audio_filenames,
|
||||
rnnt_model,
|
||||
get_rnnt_encoder_output,
|
||||
beam_config,
|
||||
device,
|
||||
batch_size,
|
||||
beam_size,
|
||||
pruning_mode,
|
||||
blank_lm_score_mode,
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = rnnt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_rnnt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
|
||||
fusion_models = [NGramGPULanguageModel.from_file(lm_path=kenlm_model_path, vocab_size=vocab_size)]
|
||||
fusion_models_alpha = [0.3]
|
||||
|
||||
decoding = BeamBatchedRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=True,
|
||||
pruning_mode=pruning_mode,
|
||||
blank_lm_score_mode=blank_lm_score_mode,
|
||||
fusion_models=fusion_models,
|
||||
fusion_models_alpha=fusion_models_alpha,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
|
||||
|
||||
class TestTDTDecoding:
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4, 16])
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_tdt_beam_decoding_return_best_hypothesis(
|
||||
self, test_audio_filenames, tdt_model, get_tdt_encoder_output, beam_config, device, batch_size, beam_size
|
||||
):
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = tdt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_tdt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
model_config = model.to_config_dict()
|
||||
durations = list(model_config["model_defaults"]["tdt_durations"])
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedTDTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
durations=durations,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=True,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
def test_tdt_beam_decoding_return_nbest(
|
||||
self, test_audio_filenames, tdt_model, get_tdt_encoder_output, beam_config, device, beam_size, batch_size
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = tdt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_tdt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
model_config = model.to_config_dict()
|
||||
durations = list(model_config["model_defaults"]["tdt_durations"])
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedTDTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
durations=durations,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=False,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
batch_nbest_hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_nbest_hyps(num_samples, batch_nbest_hyps)
|
||||
batch_nbest_hyps = decode_text_from_nbest_hypotheses(batch_nbest_hyps, model)
|
||||
print_res_nbest_hyps(batch_nbest_hyps)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": False},
|
||||
{"search_type": "malsd_batch", "allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("pruning_mode", ["late", "early"])
|
||||
@pytest.mark.parametrize("blank_lm_score_mode", ["lm_weighted_full", "no_score"])
|
||||
def test_tdt_beam_decoding_kenlm(
|
||||
self,
|
||||
kenlm_model_path,
|
||||
test_audio_filenames,
|
||||
tdt_model,
|
||||
get_tdt_encoder_output,
|
||||
beam_config,
|
||||
device,
|
||||
batch_size,
|
||||
beam_size,
|
||||
pruning_mode,
|
||||
blank_lm_score_mode,
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = tdt_model.to(device)
|
||||
encoder_output, encoded_lengths = get_tdt_encoder_output
|
||||
encoder_output, encoded_lengths = encoder_output[:num_samples].to(device), encoded_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
model_config = model.to_config_dict()
|
||||
durations = list(model_config["model_defaults"]["tdt_durations"])
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
|
||||
fusion_models = [NGramGPULanguageModel.from_file(lm_path=kenlm_model_path, vocab_size=vocab_size)]
|
||||
fusion_models_alpha = [0.3]
|
||||
|
||||
decoding = BeamBatchedTDTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=vocab_size,
|
||||
durations=durations,
|
||||
beam_size=beam_size,
|
||||
score_norm=True,
|
||||
return_best_hypothesis=True,
|
||||
pruning_mode=pruning_mode,
|
||||
blank_lm_score_mode=blank_lm_score_mode,
|
||||
fusion_models=fusion_models,
|
||||
fusion_models_alpha=fusion_models_alpha,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy=beam_config['search_type'],
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(encoder_output=encoder_output, encoded_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
|
||||
|
||||
class TestTransducerCudaGraphBeamDecoding:
|
||||
"""
|
||||
Tests CudaGraphs implementations from Transducer models (RNN-T and TDT)
|
||||
"""
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA decoder can run only on CUDA")
|
||||
@pytest.mark.parametrize("force_mode", ["no_graphs", "no_while_loops", "full_graph"])
|
||||
@pytest.mark.parametrize("model_type", ["rnnt", "tdt"])
|
||||
def test_stated_stateless(self, test_audio_filenames, rnnt_model, tdt_model, model_type, force_mode: str):
|
||||
"""
|
||||
Compares pure Pytorch and with three modes of statefull implementations for double floating point precision.
|
||||
1. Pure pytorch, but statefull implementation: no_graphs
|
||||
2. With CudaGrpahs: no_while_loops and full_graph.
|
||||
"""
|
||||
if force_mode == "full_graph":
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
|
||||
batch_size = 16
|
||||
device = torch.device("cuda")
|
||||
model = rnnt_model.to(device) if model_type == "rnnt" else tdt_model.to(device)
|
||||
decoding_config = copy.deepcopy(model.cfg.decoding)
|
||||
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["strategy"] = "malsd_batch"
|
||||
decoding_config["beam"]["beam_size"] = 4
|
||||
decoding_config["beam"]["return_best_hypothesis"] = False
|
||||
decoding_config["beam"]["allow_cuda_graphs"] = False
|
||||
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
|
||||
actual_hypotheses = model.transcribe(test_audio_filenames, batch_size=batch_size, num_workers=None)
|
||||
actual_transcripts = [[hyp.text for hyp in actual_beam] for actual_beam in actual_hypotheses]
|
||||
actual_scores = [[hyp.score for hyp in actual_beam] for actual_beam in actual_hypotheses]
|
||||
actual_timestamps = [[hyp.timestamp for hyp in actual_beam] for actual_beam in actual_hypotheses]
|
||||
|
||||
# transcribe with use implementation with cuda graphs
|
||||
decoding_config["beam"]["allow_cuda_graphs"] = True
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
model.decoding.decoding.decoding_computer.force_cuda_graphs_mode(mode=force_mode)
|
||||
|
||||
cudagraph_hypotheses = model.transcribe(test_audio_filenames, batch_size=batch_size, num_workers=None)
|
||||
cudagraph_transcripts = [[hyp.text for hyp in cudagraphs_beam] for cudagraphs_beam in cudagraph_hypotheses]
|
||||
cudagraph_scores = [[hyp.score for hyp in cudagraph_beam] for cudagraph_beam in cudagraph_hypotheses]
|
||||
cudagraph_timestamps = [[hyp.timestamp for hyp in cudagraph_beam] for cudagraph_beam in cudagraph_hypotheses]
|
||||
|
||||
for batch_idx in range(min(batch_size, len(test_audio_filenames))):
|
||||
assert len(actual_transcripts[batch_idx]) == len(cudagraph_transcripts[batch_idx])
|
||||
assert cudagraph_scores[batch_idx] == pytest.approx(
|
||||
actual_scores[batch_idx], abs=1e-2
|
||||
), f"Scores mismatch for batch_idx {batch_idx}"
|
||||
assert (
|
||||
cudagraph_timestamps[batch_idx] == actual_timestamps[batch_idx]
|
||||
), f"Timestamps mismatch for batch_idx {batch_idx}"
|
||||
|
||||
for actual, fast in zip(actual_transcripts[batch_idx], cudagraph_transcripts[batch_idx]):
|
||||
ref_words = actual.split()
|
||||
hyp_words = fast.split()
|
||||
wer = edit_distance(ref_words, hyp_words)['total'] / max(len(ref_words), 1)
|
||||
|
||||
assert wer <= 1e-3, "Cuda graph beam decoder should match original decoder implementation."
|
||||
|
||||
if actual != fast:
|
||||
print("Erroneous samples in batch:", batch_idx)
|
||||
print("Original transcript:", actual)
|
||||
print("New transcript:", fast)
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(
|
||||
not (torch.cuda.is_available() and torch.cuda.is_bf16_supported()), reason="CUDA decoder can run only on CUDA"
|
||||
)
|
||||
@pytest.mark.parametrize("model_type", ["rnnt", "tdt"])
|
||||
def test_stated_stateless_bf16(self, test_audio_filenames, rnnt_model, tdt_model, model_type):
|
||||
"""
|
||||
Checks that we are able to run without errors all decodings in bfloat16.
|
||||
Computational errors accumulate, so just checking if algorithms run without errors
|
||||
"""
|
||||
batch_size = 16
|
||||
device = torch.device("cuda")
|
||||
model = rnnt_model.to(device) if model_type == "rnnt" else tdt_model.to(device)
|
||||
decoding_config = copy.deepcopy(model.cfg.decoding)
|
||||
|
||||
# checking pytorch implementation
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["strategy"] = "malsd_batch"
|
||||
decoding_config["beam"]["beam_size"] = 4
|
||||
decoding_config["beam"]["return_best_hypothesis"] = False
|
||||
decoding_config["beam"]["allow_cuda_graphs"] = False
|
||||
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=True):
|
||||
model.transcribe(test_audio_filenames, batch_size=batch_size, num_workers=None)
|
||||
|
||||
modes = ["no_graphs", "no_while_loops", "full_graph"]
|
||||
for force_mode in modes:
|
||||
if force_mode == "full_graph":
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
|
||||
# transcribe with use implementation with cuda graphs
|
||||
decoding_config["beam"]["allow_cuda_graphs"] = True
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
model.decoding.decoding.decoding_computer.force_cuda_graphs_mode(mode=force_mode)
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=True):
|
||||
model.transcribe(test_audio_filenames, batch_size=batch_size, num_workers=None)
|
||||
|
||||
|
||||
class TestCTCDecoding:
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"allow_cuda_graphs": False},
|
||||
{"allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4, 16])
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_ctc_beam_decoding_return_best_hypothesis(
|
||||
self, test_audio_filenames, ctc_model, get_ctc_output, beam_config, device, batch_size, beam_size
|
||||
):
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = ctc_model.to(device)
|
||||
log_probs, encoded_lengths = get_ctc_output
|
||||
log_probs, encoded_lengths = log_probs[:num_samples].to(device), encoded_lengths[:num_samples].to(device)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedCTCInfer(
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
return_best_hypothesis=True,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy="beam_batch",
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(decoder_output=log_probs, decoder_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"allow_cuda_graphs": False},
|
||||
{"allow_cuda_graphs": True},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
def test_ctc_beam_decoding_return_nbest(
|
||||
self, test_audio_filenames, ctc_model, get_ctc_output, beam_config, device, beam_size, batch_size
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = ctc_model.to(device)
|
||||
log_probs, encoded_lengths = get_ctc_output
|
||||
log_probs, encoded_lengths = log_probs[:num_samples].to(device), encoded_lengths[:num_samples].to(device)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedCTCInfer(
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
return_best_hypothesis=False,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy="beam_batch",
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
batch_nbest_hyps = decoding(decoder_output=log_probs, decoder_lengths=encoded_lengths)[0]
|
||||
|
||||
check_res_nbest_hyps(num_samples, batch_nbest_hyps)
|
||||
batch_nbest_hyps = decode_text_from_nbest_hypotheses(batch_nbest_hyps, model)
|
||||
print_res_nbest_hyps(batch_nbest_hyps)
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"allow_cuda_graphs": False, "ngram_lm_alpha": 0.3, "beam_beta": 1.0},
|
||||
{"allow_cuda_graphs": False, "ngram_lm_alpha": 0.3, "beam_beta": 1.0},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
def test_ctc_beam_decoding_kenlm(
|
||||
self,
|
||||
kenlm_model_path,
|
||||
test_audio_filenames,
|
||||
ctc_model,
|
||||
get_ctc_output,
|
||||
beam_config,
|
||||
device,
|
||||
batch_size,
|
||||
beam_size,
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
beam_config["ngram_lm_model"] = kenlm_model_path
|
||||
|
||||
num_samples = min(batch_size, len(test_audio_filenames))
|
||||
model = ctc_model.to(device)
|
||||
decoder_output, decoder_lengths = get_ctc_output
|
||||
decoder_output, decoder_lengths = decoder_output[:num_samples].to(device), decoder_lengths[:num_samples].to(
|
||||
device
|
||||
)
|
||||
|
||||
vocab_size = model.tokenizer.vocab_size
|
||||
decoding = BeamBatchedCTCInfer(
|
||||
blank_index=vocab_size,
|
||||
beam_size=beam_size,
|
||||
return_best_hypothesis=True,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
print_unit_test_info(
|
||||
strategy="beam_batch",
|
||||
batch_size=batch_size,
|
||||
beam_size=beam_size,
|
||||
allow_cuda_graphs=beam_config.get('allow_cuda_graphs', True),
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding(decoder_output=decoder_output, decoder_lengths=decoder_lengths)[0]
|
||||
|
||||
check_res_best_hyps(num_samples, hyps)
|
||||
hyps = decode_text_from_hypotheses(hyps, model)
|
||||
print_res_best_hyps(hyps)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,797 @@
|
||||
# Copyright (c) 2023, 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.
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.submodules.transducer_decoding.batched_hyps import BatchedHyps
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import batched_hyps_to_hypotheses
|
||||
from tests.collections.asr.decoding.utils import avoid_sync_operations
|
||||
|
||||
DEVICES: List[torch.device] = [torch.device("cpu")]
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device("cuda"))
|
||||
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
DEVICES.append(torch.device("mps"))
|
||||
|
||||
# blank id that does not collide with any non-blank label used in the "no blank steps" tests
|
||||
NON_COLLIDING_BLANK_ID = 1024
|
||||
|
||||
|
||||
class TestBatchedHyps:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_instantiate(self, device: torch.device):
|
||||
hyps = BatchedHyps(batch_size=2, init_length=3, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
assert torch.is_tensor(hyps.timestamps)
|
||||
# device: for mps device we need to use `type`, not directly compare
|
||||
assert hyps.timestamps.device.type == device.type
|
||||
assert hyps.timestamps.shape == (2, 3)
|
||||
assert hyps.transcript.shape == (2, 3)
|
||||
assert hyps.scores.shape == (2,)
|
||||
assert hyps.current_lengths.tolist() == [0, 0]
|
||||
# optional storage is disabled by default
|
||||
assert hyps.token_durations is None
|
||||
assert hyps.step_confidence is None
|
||||
assert hyps.logits is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("batch_size", [-1, 0])
|
||||
def test_instantiate_incorrect_batch_size(self, batch_size):
|
||||
with pytest.raises(ValueError):
|
||||
_ = BatchedHyps(batch_size=batch_size, init_length=3, blank_id=0)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("init_length", [-1, 0])
|
||||
def test_instantiate_incorrect_init_length(self, init_length):
|
||||
with pytest.raises(ValueError):
|
||||
_ = BatchedHyps(batch_size=1, init_length=init_length, blank_id=0)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_instantiate_with_logits_requires_logits_dim(self):
|
||||
# `with_logits=True` without `logits_dim` is invalid
|
||||
with pytest.raises(ValueError):
|
||||
_ = BatchedHyps(batch_size=1, init_length=3, blank_id=0, with_logits=True, logits_dim=None)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_instantiate_optional_storage(self, device: torch.device):
|
||||
logits_dim = 7
|
||||
hyps = BatchedHyps(
|
||||
batch_size=2,
|
||||
init_length=3,
|
||||
blank_id=0,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
with_durations=True,
|
||||
with_step_confidence=True,
|
||||
with_duration_confidence=True,
|
||||
with_logits=True,
|
||||
)
|
||||
assert hyps.token_durations.shape == (2, 3)
|
||||
# duration confidence makes the confidence tensor store a pair (step + duration) per token
|
||||
assert hyps.step_confidence.shape == (2, 3, 2)
|
||||
assert hyps.logits.shape == (2, 3, logits_dim)
|
||||
|
||||
# without duration confidence the confidence tensor is 2d
|
||||
hyps_no_dur_conf = BatchedHyps(
|
||||
batch_size=2,
|
||||
init_length=3,
|
||||
blank_id=0,
|
||||
device=device,
|
||||
with_step_confidence=True,
|
||||
with_duration_confidence=False,
|
||||
)
|
||||
assert hyps_no_dur_conf.step_confidence.shape == (2, 3)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_results_masked(self, device: torch.device):
|
||||
# batch of size 2, add label for first utterance only (second is inactive)
|
||||
hyps = BatchedHyps(batch_size=2, init_length=1, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([5, 1], device=device),
|
||||
time_indices=torch.tensor([1, 0], device=device),
|
||||
scores=torch.tensor([0.5, 10.0], device=device),
|
||||
)
|
||||
assert hyps.current_lengths.tolist() == [1, 0]
|
||||
assert hyps.transcript.tolist()[0][:1] == [5]
|
||||
assert hyps.timestamps.tolist()[0][:1] == [1]
|
||||
assert hyps.scores.tolist() == pytest.approx([0.5, 0.0]) # inactive score should be ignored!
|
||||
assert hyps.last_nb_timestamp.tolist() == [1, -1]
|
||||
assert hyps.last_nb_timestamp_lasts.tolist() == [1, 0]
|
||||
assert hyps.last_nb_labels.tolist() == [5, -1]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_multiple_results_masked(self, device: torch.device):
|
||||
# batch of size 2, add label for first utterance, then add labels for both utterances
|
||||
hyps = BatchedHyps(batch_size=2, init_length=1, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([5, 2], device=device),
|
||||
time_indices=torch.tensor([1, 0], device=device),
|
||||
scores=torch.tensor([0.5, 10.0], device=device),
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([2, 4], device=device),
|
||||
time_indices=torch.tensor([1, 2], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
)
|
||||
assert hyps.current_lengths.tolist() == [2, 1]
|
||||
assert hyps.transcript.tolist()[0][:2] == [5, 2]
|
||||
assert hyps.transcript.tolist()[1][:1] == [4]
|
||||
assert hyps.timestamps.tolist()[0][:2] == [1, 1]
|
||||
assert hyps.timestamps.tolist()[1][:1] == [2]
|
||||
assert hyps.scores.tolist() == pytest.approx([1.5, 1.0])
|
||||
assert hyps.last_nb_timestamp.tolist() == [1, 2]
|
||||
assert hyps.last_nb_timestamp_lasts.tolist() == [2, 1]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_results_masked_no_checks(self, device: torch.device):
|
||||
# `check_lengths=False` must contain no host<->device synchronization (blocking) operations
|
||||
hyps = BatchedHyps(batch_size=2, init_length=4, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
active_mask = torch.tensor([True, False], device=device)
|
||||
time_indices = torch.tensor([1, 0], device=device)
|
||||
scores = torch.tensor([0.5, 10.0], device=device)
|
||||
labels = torch.tensor([5, 1], device=device)
|
||||
# check there are no blocking operations
|
||||
with avoid_sync_operations(device=device):
|
||||
hyps.add_results_masked_(
|
||||
active_mask=active_mask,
|
||||
labels=labels,
|
||||
time_indices=time_indices,
|
||||
scores=scores,
|
||||
check_lengths=False,
|
||||
)
|
||||
assert hyps.current_lengths.tolist() == [1, 0]
|
||||
assert hyps.transcript.tolist()[0][:1] == [5]
|
||||
assert hyps.timestamps.tolist()[0][:1] == [1]
|
||||
assert hyps.scores.tolist() == pytest.approx([0.5, 0.0]) # inactive score should be ignored!
|
||||
assert hyps.last_nb_timestamp.tolist() == [1, -1]
|
||||
assert hyps.last_nb_timestamp_lasts.tolist() == [1, 0]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_results_masked_reallocates(self, device: torch.device):
|
||||
# init_length is intentionally small; storage must grow transparently when check_lengths=True
|
||||
hyps = BatchedHyps(batch_size=2, init_length=1, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
for step in range(5):
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([step, step + 10], device=device),
|
||||
time_indices=torch.tensor([step, step], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
check_lengths=True,
|
||||
)
|
||||
assert hyps._max_length >= 5
|
||||
assert hyps.current_lengths.tolist() == [5, 5]
|
||||
assert hyps.transcript.tolist()[0][:5] == [0, 1, 2, 3, 4]
|
||||
assert hyps.transcript.tolist()[1][:5] == [10, 11, 12, 13, 14]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_results_masked_with_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=True: blank labels are stored in the transcript, but they do NOT advance
|
||||
# the score / last-non-blank tracking. Single utterance for clarity.
|
||||
blank_id = 0
|
||||
hyps = BatchedHyps(batch_size=1, init_length=2, blank_id=blank_id, device=device, with_blank_steps=True)
|
||||
# (label, time, score): two non-blank tokens at t=0, then blank, then non-blank at t=1, then blank
|
||||
steps = [
|
||||
(3, 0, 1.0), # non-blank
|
||||
(5, 0, 1.5), # non-blank, same timestamp
|
||||
(blank_id, 0, 0.1), # blank
|
||||
(7, 1, 2.0), # non-blank
|
||||
(blank_id, 1, 0.2), # blank
|
||||
]
|
||||
for label, time, score in steps:
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([label], device=device),
|
||||
time_indices=torch.tensor([time], device=device),
|
||||
scores=torch.tensor([score], device=device),
|
||||
)
|
||||
# all steps (including blanks) are stored
|
||||
assert hyps.current_lengths.tolist() == [5]
|
||||
assert hyps.transcript.tolist()[0][:5] == [3, 5, blank_id, 7, blank_id]
|
||||
assert hyps.timestamps.tolist()[0][:5] == [0, 0, 0, 1, 1]
|
||||
# only non-blank scores accumulate: 1.0 + 1.5 + 2.0
|
||||
assert hyps.scores.tolist() == pytest.approx([4.5])
|
||||
# last-non-blank tracking ignores blanks
|
||||
assert hyps.last_nb_timestamp.tolist() == [1]
|
||||
assert hyps.last_nb_timestamp_lasts.tolist() == [1]
|
||||
assert hyps.last_nb_labels.tolist() == [7]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_get_data_without_blank_no_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=False: data is returned as-is (it never contained blanks)
|
||||
hyps = BatchedHyps(batch_size=2, init_length=2, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([5, 4], device=device),
|
||||
time_indices=torch.tensor([0, 1], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([2, 0], device=device),
|
||||
time_indices=torch.tensor([1, 0], device=device),
|
||||
scores=torch.tensor([1.0, 0.0], device=device),
|
||||
)
|
||||
lengths, transcript, timestamps, durations, confidence = hyps.get_data_without_blank()
|
||||
# returned objects are the underlying (unmodified) tensors
|
||||
assert lengths is hyps.current_lengths
|
||||
assert transcript is hyps.transcript
|
||||
assert timestamps is hyps.timestamps
|
||||
assert durations is None
|
||||
assert confidence is None
|
||||
assert lengths.tolist() == [2, 1]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_get_data_without_blank_with_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=True: blanks are stripped and non-blank order is preserved
|
||||
blank_id = 0
|
||||
hyps = BatchedHyps(batch_size=2, init_length=2, blank_id=blank_id, device=device, with_blank_steps=True)
|
||||
# seq 0: [5, blank, 2, blank] -> [5, 2]
|
||||
# seq 1: [blank, 4] -> [4]
|
||||
steps = [
|
||||
# (labels, times, active_mask)
|
||||
([5, blank_id], [0, 0], [True, True]),
|
||||
([blank_id, 4], [0, 1], [True, True]),
|
||||
([2, blank_id], [1, 1], [True, False]),
|
||||
([blank_id, 0], [1, 0], [True, False]),
|
||||
]
|
||||
for labels, times, active in steps:
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor(active, device=device),
|
||||
labels=torch.tensor(labels, device=device),
|
||||
time_indices=torch.tensor(times, device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
)
|
||||
lengths, transcript, timestamps, durations, confidence = hyps.get_data_without_blank()
|
||||
assert lengths.tolist() == [2, 1]
|
||||
assert transcript[0, :2].tolist() == [5, 2]
|
||||
assert transcript[1, :1].tolist() == [4]
|
||||
assert timestamps[0, :2].tolist() == [0, 1]
|
||||
assert timestamps[1, :1].tolist() == [1]
|
||||
assert durations is None
|
||||
assert confidence is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_get_last_labels(self, device: torch.device):
|
||||
hyps = BatchedHyps(batch_size=2, init_length=2, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
# no labels yet -> pad_id everywhere
|
||||
assert hyps.get_last_labels(pad_id=-1).tolist() == [-1, -1]
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([5, 1], device=device),
|
||||
time_indices=torch.tensor([0, 0], device=device),
|
||||
scores=torch.tensor([1.0, 0.0], device=device),
|
||||
)
|
||||
assert hyps.get_last_labels(pad_id=-1).tolist() == [5, -1]
|
||||
assert hyps.get_last_labels(pad_id=100).tolist() == [5, 100]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_clear(self, device: torch.device):
|
||||
hyps = BatchedHyps(batch_size=2, init_length=2, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([5, 4], device=device),
|
||||
time_indices=torch.tensor([0, 0], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
)
|
||||
hyps.clear_()
|
||||
assert hyps.current_lengths.tolist() == [0, 0]
|
||||
assert hyps.scores.tolist() == pytest.approx([0.0, 0.0])
|
||||
assert hyps.transcript.tolist() == [[0, 0], [0, 0]]
|
||||
assert hyps.last_nb_timestamp.tolist() == [-1, -1]
|
||||
assert hyps.last_nb_timestamp_lasts.tolist() == [0, 0]
|
||||
assert hyps.last_nb_labels.tolist() == [-1, -1]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_clone(self, device: torch.device):
|
||||
logits_dim = 7
|
||||
hyps = BatchedHyps(
|
||||
batch_size=2,
|
||||
init_length=2,
|
||||
blank_id=0,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
with_durations=True,
|
||||
with_step_confidence=True,
|
||||
with_logits=True,
|
||||
with_blank_steps=True,
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([5, 4], device=device),
|
||||
time_indices=torch.tensor([0, 0], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
token_durations=torch.tensor([1, 2], device=device),
|
||||
confidence=torch.tensor([0.9, 0.8], device=device),
|
||||
logits=torch.rand((2, logits_dim), device=device),
|
||||
)
|
||||
clone = hyps.clone()
|
||||
# flags carried over
|
||||
assert clone.with_durations and clone.with_step_confidence and clone.with_logits and clone.with_blank_steps
|
||||
assert clone.blank_id == hyps.blank_id
|
||||
# values copied
|
||||
assert clone.current_lengths.tolist() == hyps.current_lengths.tolist()
|
||||
assert torch.equal(clone.transcript, hyps.transcript)
|
||||
assert torch.allclose(clone.logits, hyps.logits)
|
||||
assert torch.allclose(clone.step_confidence, hyps.step_confidence)
|
||||
assert torch.equal(clone.token_durations, hyps.token_durations)
|
||||
# clone is independent of the original
|
||||
hyps.transcript.fill_(0)
|
||||
hyps.scores.fill_(0.0)
|
||||
assert clone.transcript.tolist()[0][:1] == [5]
|
||||
assert clone.scores.tolist() == pytest.approx([1.0, 1.0])
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_merge(self, device: torch.device):
|
||||
# merge two batched hypotheses (basic case: no blank steps, no optional storage)
|
||||
def build(labels_per_step, times_per_step, masks_per_step, scores_per_step):
|
||||
hyps = BatchedHyps(batch_size=2, init_length=4, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
for labels, times, mask, scores in zip(labels_per_step, times_per_step, masks_per_step, scores_per_step):
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor(mask, device=device),
|
||||
labels=torch.tensor(labels, device=device),
|
||||
time_indices=torch.tensor(times, device=device),
|
||||
scores=torch.tensor(scores, device=device),
|
||||
)
|
||||
return hyps
|
||||
|
||||
# A: seq0=[5, 2], seq1=[4]
|
||||
hyps_a = build(
|
||||
labels_per_step=[[5, 4], [2, 0]],
|
||||
times_per_step=[[0, 0], [1, 0]],
|
||||
masks_per_step=[[True, True], [True, False]],
|
||||
scores_per_step=[[0.5, 0.7], [0.5, 0.0]],
|
||||
)
|
||||
# B: seq0=[7], seq1=[8, 9]
|
||||
hyps_b = build(
|
||||
labels_per_step=[[7, 8], [0, 9]],
|
||||
times_per_step=[[2, 2], [0, 3]],
|
||||
masks_per_step=[[True, True], [False, True]],
|
||||
scores_per_step=[[0.3, 0.3], [0.0, 0.4]],
|
||||
)
|
||||
|
||||
hyps_a.merge_(hyps_b)
|
||||
assert hyps_a.current_lengths.tolist() == [3, 3]
|
||||
assert hyps_a.transcript[0, :3].tolist() == [5, 2, 7]
|
||||
assert hyps_a.transcript[1, :3].tolist() == [4, 8, 9]
|
||||
assert hyps_a.scores.tolist() == pytest.approx([1.3, 1.4])
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_merge_with_logits(self, device: torch.device):
|
||||
# Regression test: merge_ must use dim=1 (sequence axis) for logits scatter/cat,
|
||||
# not dim=-1 (logits-dim axis), which would silently corrupt the data.
|
||||
logits_dim = 5
|
||||
blank_id = NON_COLLIDING_BLANK_ID
|
||||
|
||||
def build_with_logits(labels_per_step, times_per_step, masks_per_step, scores_per_step, logits_per_step):
|
||||
hyps = BatchedHyps(
|
||||
batch_size=2,
|
||||
init_length=4,
|
||||
blank_id=blank_id,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
float_dtype=torch.float32,
|
||||
with_logits=True,
|
||||
)
|
||||
for labels, times, mask, scores, logits in zip(
|
||||
labels_per_step, times_per_step, masks_per_step, scores_per_step, logits_per_step
|
||||
):
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor(mask, device=device),
|
||||
labels=torch.tensor(labels, device=device),
|
||||
time_indices=torch.tensor(times, device=device),
|
||||
scores=torch.tensor(scores, device=device),
|
||||
logits=logits,
|
||||
)
|
||||
return hyps
|
||||
|
||||
# Fixed logits so we can assert exact values after merge
|
||||
logits_a0 = torch.full((2, logits_dim), 1.0, device=device) # step for seq0=[5], seq1=[4]
|
||||
logits_a1 = torch.full((2, logits_dim), 2.0, device=device) # step for seq0=[2], seq1 inactive
|
||||
|
||||
logits_b0 = torch.full((2, logits_dim), 3.0, device=device) # step for seq0=[7], seq1=[8]
|
||||
logits_b1 = torch.full((2, logits_dim), 4.0, device=device) # step for seq0 inactive, seq1=[9]
|
||||
|
||||
# A: seq0=[5, 2], seq1=[4]
|
||||
hyps_a = build_with_logits(
|
||||
labels_per_step=[[5, 4], [2, 0]],
|
||||
times_per_step=[[0, 0], [1, 0]],
|
||||
masks_per_step=[[True, True], [True, False]],
|
||||
scores_per_step=[[0.5, 0.7], [0.5, 0.0]],
|
||||
logits_per_step=[logits_a0, logits_a1],
|
||||
)
|
||||
# B: seq0=[7], seq1=[8, 9]
|
||||
hyps_b = build_with_logits(
|
||||
labels_per_step=[[7, 8], [0, 9]],
|
||||
times_per_step=[[2, 2], [0, 3]],
|
||||
masks_per_step=[[True, True], [False, True]],
|
||||
scores_per_step=[[0.3, 0.3], [0.0, 0.4]],
|
||||
logits_per_step=[logits_b0, logits_b1],
|
||||
)
|
||||
|
||||
hyps_a.merge_(hyps_b)
|
||||
|
||||
# Sequence lengths: seq0=2+1=3, seq1=1+2=3
|
||||
assert hyps_a.current_lengths.tolist() == [3, 3]
|
||||
|
||||
# seq0 logits: positions [0,1] from A (value=1 then 2), position [2] from B (value=3)
|
||||
assert torch.allclose(hyps_a.logits[0, 0], torch.full((logits_dim,), 1.0, device=device))
|
||||
assert torch.allclose(hyps_a.logits[0, 1], torch.full((logits_dim,), 2.0, device=device))
|
||||
assert torch.allclose(hyps_a.logits[0, 2], torch.full((logits_dim,), 3.0, device=device))
|
||||
|
||||
# seq1 logits: position [0] from A (value=1), positions [1,2] from B (value=3 then 4)
|
||||
assert torch.allclose(hyps_a.logits[1, 0], torch.full((logits_dim,), 1.0, device=device))
|
||||
assert torch.allclose(hyps_a.logits[1, 1], torch.full((logits_dim,), 3.0, device=device))
|
||||
assert torch.allclose(hyps_a.logits[1, 2], torch.full((logits_dim,), 4.0, device=device))
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_merge_with_logits_triggers_reallocation(self, device: torch.device):
|
||||
# When the combined length exceeds _max_length, merge_ must reallocate logits along dim=1
|
||||
# (sequence axis). With the dim=-1 bug, the reallocation would expand the logits-dim
|
||||
# axis instead, causing a shape mismatch on the subsequent scatter_.
|
||||
logits_dim = 5
|
||||
blank_id = NON_COLLIDING_BLANK_ID
|
||||
|
||||
# Use init_length=1 to force reallocation during merge
|
||||
hyps_a = BatchedHyps(
|
||||
batch_size=1,
|
||||
init_length=1,
|
||||
blank_id=blank_id,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
float_dtype=torch.float32,
|
||||
with_logits=True,
|
||||
)
|
||||
hyps_b = BatchedHyps(
|
||||
batch_size=1,
|
||||
init_length=1,
|
||||
blank_id=blank_id,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
float_dtype=torch.float32,
|
||||
with_logits=True,
|
||||
)
|
||||
|
||||
logits_a = torch.full((1, logits_dim), 1.0, device=device)
|
||||
logits_b = torch.full((1, logits_dim), 2.0, device=device)
|
||||
|
||||
hyps_a.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([5], device=device),
|
||||
time_indices=torch.tensor([0], device=device),
|
||||
scores=torch.tensor([1.0], device=device),
|
||||
logits=logits_a,
|
||||
)
|
||||
hyps_b.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([7], device=device),
|
||||
time_indices=torch.tensor([1], device=device),
|
||||
scores=torch.tensor([1.0], device=device),
|
||||
logits=logits_b,
|
||||
)
|
||||
|
||||
# cur_len=1, other_len=1 -> combined=2 >= init_length=1, so reallocation is triggered
|
||||
hyps_a.merge_(hyps_b)
|
||||
|
||||
assert hyps_a.current_lengths.tolist() == [2]
|
||||
assert hyps_a.logits.shape == (1, hyps_a._max_length, logits_dim)
|
||||
assert torch.allclose(hyps_a.logits[0, 0], torch.full((logits_dim,), 1.0, device=device))
|
||||
assert torch.allclose(hyps_a.logits[0, 1], torch.full((logits_dim,), 2.0, device=device))
|
||||
|
||||
|
||||
class TestConvertToHypotheses:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_no_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=False: transcript already contains only non-blank labels
|
||||
hyps = BatchedHyps(batch_size=2, init_length=1, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([5, 0], device=device),
|
||||
time_indices=torch.tensor([1, 0], device=device),
|
||||
scores=torch.tensor([0.5, 0.0], device=device),
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([2, 4], device=device),
|
||||
time_indices=torch.tensor([1, 2], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert (hypotheses[0].y_sequence == torch.tensor([5, 2], device="cpu")).all()
|
||||
assert (hypotheses[1].y_sequence == torch.tensor([4], device="cpu")).all()
|
||||
assert hypotheses[0].score == pytest.approx(1.5)
|
||||
assert hypotheses[1].score == pytest.approx(1.0)
|
||||
assert (hypotheses[0].timestamp == torch.tensor([1, 1], device="cpu")).all()
|
||||
assert (hypotheses[1].timestamp == torch.tensor([2], device="cpu")).all()
|
||||
# no blank steps -> no alignments / frame confidence
|
||||
assert hypotheses[0].alignments is None
|
||||
assert hypotheses[1].alignments is None
|
||||
assert hypotheses[0].frame_confidence is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_batch_size_arg(self, device: torch.device):
|
||||
# batch_size arg returns only the first `batch_size` hypotheses (CUDA-graph constant batch)
|
||||
hyps = BatchedHyps(batch_size=4, init_length=1, blank_id=NON_COLLIDING_BLANK_ID, device=device)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True, True, True], device=device),
|
||||
labels=torch.tensor([5, 4, 3, 2], device=device),
|
||||
time_indices=torch.tensor([0, 0, 0, 0], device=device),
|
||||
scores=torch.tensor([1.0, 1.0, 1.0, 1.0], device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps, batch_size=2)
|
||||
assert len(hypotheses) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_with_blank_steps_strips_blanks(self, device: torch.device):
|
||||
# with_blank_steps=True but no logits/confidence: blanks must be stripped from y_sequence/timestamps,
|
||||
# while alignments are NOT produced (no logits recorded)
|
||||
blank_id = 0
|
||||
hyps = BatchedHyps(batch_size=2, init_length=2, blank_id=blank_id, device=device, with_blank_steps=True)
|
||||
# seq 0: [5, blank, 2, blank] -> [5, 2]
|
||||
# seq 1: [blank, 4] -> [4]
|
||||
steps = [
|
||||
([5, blank_id], [0, 0], [True, True], [0.5, 0.1]),
|
||||
([blank_id, 4], [0, 1], [True, True], [0.1, 1.0]),
|
||||
([2, blank_id], [1, 1], [True, False], [1.0, 0.0]),
|
||||
([blank_id, 0], [1, 0], [True, False], [0.1, 0.0]),
|
||||
]
|
||||
for labels, times, active, scores in steps:
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor(active, device=device),
|
||||
labels=torch.tensor(labels, device=device),
|
||||
time_indices=torch.tensor(times, device=device),
|
||||
scores=torch.tensor(scores, device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert (hypotheses[0].y_sequence == torch.tensor([5, 2], device="cpu")).all()
|
||||
assert (hypotheses[1].y_sequence == torch.tensor([4], device="cpu")).all()
|
||||
assert (hypotheses[0].timestamp == torch.tensor([0, 1], device="cpu")).all()
|
||||
assert (hypotheses[1].timestamp == torch.tensor([1], device="cpu")).all()
|
||||
# only non-blank scores accumulated
|
||||
assert hypotheses[0].score == pytest.approx(1.5)
|
||||
assert hypotheses[1].score == pytest.approx(1.0)
|
||||
# no logits recorded -> alignments stay None even though blank steps were stored
|
||||
assert hypotheses[0].alignments is None
|
||||
assert hypotheses[1].alignments is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_logits_no_alignments_without_blank_steps(self, device: torch.device):
|
||||
# logits are recorded but with_blank_steps=False -> alignments must NOT be produced
|
||||
logits_dim = 7
|
||||
hyps = BatchedHyps(
|
||||
batch_size=2,
|
||||
init_length=2,
|
||||
blank_id=6,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
with_logits=True,
|
||||
with_blank_steps=False,
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([5, 4], device=device),
|
||||
time_indices=torch.tensor([0, 0], device=device),
|
||||
scores=torch.tensor([1.0, 1.0], device=device),
|
||||
logits=torch.rand((2, logits_dim), device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert hypotheses[0].alignments is None
|
||||
assert hypotheses[1].alignments is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_with_blank_steps_and_logits_alignments(self, device: torch.device):
|
||||
# Reproduces alignment recovery: with_blank_steps=True + with_logits=True
|
||||
batch_size = 2
|
||||
logits_dim = 7
|
||||
blank_index = 6
|
||||
hyps = BatchedHyps(
|
||||
batch_size=batch_size,
|
||||
init_length=1,
|
||||
blank_id=blank_index,
|
||||
logits_dim=logits_dim,
|
||||
device=device,
|
||||
with_logits=True,
|
||||
with_blank_steps=True,
|
||||
)
|
||||
# sequence 0: [[5, blank], [2, blank]] -> [5, 2]
|
||||
# sequence 1: [[blank ], [4, blank]] -> [4]
|
||||
# one logits row per (batch, add-call); rows belonging to inactive entries are ignored
|
||||
L = [torch.rand((batch_size, logits_dim), device=device) for _ in range(4)]
|
||||
# call0: seq0=5@t0, seq1=blank@t0
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([5, blank_index], device=device),
|
||||
time_indices=torch.tensor([0, 0], device=device),
|
||||
scores=torch.tensor([0.5, 0.1], device=device),
|
||||
logits=L[0],
|
||||
)
|
||||
# call1: seq0=blank@t0, seq1=4@t1
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([blank_index, 4], device=device),
|
||||
time_indices=torch.tensor([0, 1], device=device),
|
||||
scores=torch.tensor([0.1, 1.0], device=device),
|
||||
logits=L[1],
|
||||
)
|
||||
# call2: seq0=2@t1, seq1=blank@t1
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, True], device=device),
|
||||
labels=torch.tensor([2, blank_index], device=device),
|
||||
time_indices=torch.tensor([1, 1], device=device),
|
||||
scores=torch.tensor([1.0, 0.1], device=device),
|
||||
logits=L[2],
|
||||
)
|
||||
# call3: seq0=blank@t1, seq1 inactive
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True, False], device=device),
|
||||
labels=torch.tensor([blank_index, 0], device=device),
|
||||
time_indices=torch.tensor([1, 0], device=device),
|
||||
scores=torch.tensor([0.1, 0.0], device=device),
|
||||
logits=L[3],
|
||||
)
|
||||
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert (hypotheses[0].y_sequence == torch.tensor([5, 2], device="cpu")).all()
|
||||
assert (hypotheses[1].y_sequence == torch.tensor([4], device="cpu")).all()
|
||||
assert hypotheses[0].score == pytest.approx(1.5)
|
||||
assert hypotheses[1].score == pytest.approx(1.0)
|
||||
assert (hypotheses[0].timestamp == torch.tensor([0, 1], device="cpu")).all()
|
||||
assert (hypotheses[1].timestamp == torch.tensor([1], device="cpu")).all()
|
||||
|
||||
# alignments are grouped by timestamp; each entry is a (logits, label) tuple
|
||||
etalon = [
|
||||
[
|
||||
[(L[0][0].cpu(), 5), (L[1][0].cpu(), blank_index)],
|
||||
[(L[2][0].cpu(), 2), (L[3][0].cpu(), blank_index)],
|
||||
],
|
||||
[
|
||||
[(L[0][1].cpu(), blank_index)],
|
||||
[(L[1][1].cpu(), 4), (L[2][1].cpu(), blank_index)],
|
||||
],
|
||||
]
|
||||
for batch_i in range(batch_size):
|
||||
assert len(hypotheses[batch_i].alignments) == len(etalon[batch_i])
|
||||
for t, group_for_timestamp in enumerate(etalon[batch_i]):
|
||||
assert len(hypotheses[batch_i].alignments[t]) == len(group_for_timestamp)
|
||||
for step, (current_logits, label) in enumerate(group_for_timestamp):
|
||||
assert torch.allclose(hypotheses[batch_i].alignments[t][step][0], current_logits)
|
||||
assert hypotheses[batch_i].alignments[t][step][1] == label
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_with_durations(self, device: torch.device):
|
||||
# TDT-style: token durations are stored and (with blank steps) stripped together with blanks
|
||||
blank_id = 0
|
||||
hyps = BatchedHyps(
|
||||
batch_size=1,
|
||||
init_length=2,
|
||||
blank_id=blank_id,
|
||||
device=device,
|
||||
with_durations=True,
|
||||
with_blank_steps=True,
|
||||
)
|
||||
# transcript [3, blank, 7, blank] with durations [2, 1, 4, 1] -> [3, 7] with durations [2, 4]
|
||||
steps = [
|
||||
(3, 0, 2, 1.0),
|
||||
(blank_id, 2, 1, 0.1),
|
||||
(7, 3, 4, 2.0),
|
||||
(blank_id, 7, 1, 0.2),
|
||||
]
|
||||
for label, time, duration, score in steps:
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([label], device=device),
|
||||
time_indices=torch.tensor([time], device=device),
|
||||
scores=torch.tensor([score], device=device),
|
||||
token_durations=torch.tensor([duration], device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert (hypotheses[0].y_sequence == torch.tensor([3, 7], device="cpu")).all()
|
||||
assert (hypotheses[0].timestamp == torch.tensor([0, 3], device="cpu")).all()
|
||||
assert (hypotheses[0].token_duration == torch.tensor([2, 4], device="cpu")).all()
|
||||
assert hypotheses[0].score == pytest.approx(3.0)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_with_step_confidence_no_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=False: per-token confidence is precomputed, no frame_confidence (no blank steps)
|
||||
hyps = BatchedHyps(
|
||||
batch_size=1,
|
||||
init_length=2,
|
||||
blank_id=NON_COLLIDING_BLANK_ID,
|
||||
device=device,
|
||||
with_step_confidence=True,
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([5], device=device),
|
||||
time_indices=torch.tensor([0], device=device),
|
||||
scores=torch.tensor([1.0], device=device),
|
||||
confidence=torch.tensor([0.9], device=device),
|
||||
)
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([2], device=device),
|
||||
time_indices=torch.tensor([1], device=device),
|
||||
scores=torch.tensor([1.0], device=device),
|
||||
confidence=torch.tensor([0.8], device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
assert hypotheses[0].non_blank_step_confidence_precomputed == pytest.approx([0.9, 0.8])
|
||||
assert hypotheses[0].frame_confidence is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_convert_with_step_confidence_and_blank_steps(self, device: torch.device):
|
||||
# with_blank_steps=True: frame_confidence is grouped per timestamp (incl. blanks),
|
||||
# while non_blank_step_confidence_precomputed holds only non-blank tokens
|
||||
blank_id = 0
|
||||
hyps = BatchedHyps(
|
||||
batch_size=1,
|
||||
init_length=2,
|
||||
blank_id=blank_id,
|
||||
device=device,
|
||||
with_step_confidence=True,
|
||||
with_blank_steps=True,
|
||||
)
|
||||
# transcript [3, blank, 7], confidence [0.9, 0.5, 0.8], timestamps [0, 0, 1]
|
||||
steps = [
|
||||
(3, 0, 0.9, 1.0),
|
||||
(blank_id, 0, 0.5, 0.1),
|
||||
(7, 1, 0.8, 2.0),
|
||||
]
|
||||
for label, time, confidence, score in steps:
|
||||
hyps.add_results_masked_(
|
||||
active_mask=torch.tensor([True], device=device),
|
||||
labels=torch.tensor([label], device=device),
|
||||
time_indices=torch.tensor([time], device=device),
|
||||
scores=torch.tensor([score], device=device),
|
||||
confidence=torch.tensor([confidence], device=device),
|
||||
)
|
||||
hypotheses = batched_hyps_to_hypotheses(hyps)
|
||||
# non-blank tokens only
|
||||
assert hypotheses[0].non_blank_step_confidence_precomputed == pytest.approx([0.9, 0.8])
|
||||
# grouped by timestamp: t=0 has 2 steps (token + blank), t=1 has 1 step
|
||||
frame_confidence = hypotheses[0].frame_confidence
|
||||
assert len(frame_confidence) == 2
|
||||
assert len(frame_confidence[0]) == 2
|
||||
assert len(frame_confidence[1]) == 1
|
||||
flat = [float(c) for group in frame_confidence for c in group]
|
||||
assert flat == pytest.approx([0.9, 0.5, 0.8])
|
||||
@@ -0,0 +1,279 @@
|
||||
# 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.asr.parts.context_biasing.biasing_multi_model import (
|
||||
GPUBiasingMultiModel,
|
||||
GPUBiasingMultiModelReference,
|
||||
)
|
||||
from nemo.collections.asr.parts.context_biasing.boosting_graph_batched import (
|
||||
BoostingTreeModelConfig,
|
||||
GPUBoostingTreeModel,
|
||||
)
|
||||
from nemo.core.utils.optional_libs import TRITON_AVAILABLE
|
||||
|
||||
DEVICES = [torch.device("cpu")]
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device("cuda"))
|
||||
if hasattr(torch, "mps") and torch.mps.is_available():
|
||||
DEVICES.append(torch.device("mps"))
|
||||
|
||||
# Triton only works on CUDA, so only test use_triton=True if Triton is available
|
||||
USE_TRITON_OPTIONS = [False, True] if TRITON_AVAILABLE else [False]
|
||||
|
||||
|
||||
def create_boosting_model(phrases: list[str], tokenizer, device: torch.device) -> GPUBoostingTreeModel:
|
||||
"""Helper to create boosting model from phrases"""
|
||||
cfg = BoostingTreeModelConfig(key_phrases_list=phrases, context_score=1.0)
|
||||
model = GPUBoostingTreeModel.from_config(cfg, tokenizer=tokenizer)
|
||||
return model.to(device)
|
||||
|
||||
|
||||
class TestGPUBiasingMultiModel:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_models_incremental(self, stt_en_conformer_transducer_small, device: torch.device):
|
||||
"""Test adding 2 boosting models one-by-one, verifying arcs and states are correctly merged."""
|
||||
tokenizer = stt_en_conformer_transducer_small.tokenizer
|
||||
vocab_size = tokenizer.vocab_size
|
||||
|
||||
# Create empty multi-model
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size).to(device)
|
||||
|
||||
# Initially empty
|
||||
assert multi_model.num_models == 0
|
||||
assert multi_model.has_models() is False
|
||||
assert multi_model.num_states_total == 0
|
||||
assert multi_model.num_arcs_extended_total == 0
|
||||
|
||||
# Create and add first model
|
||||
model1 = create_boosting_model(["hello", "world"], tokenizer, device)
|
||||
model_id1 = multi_model.add_model(model1, alpha=1.0)
|
||||
|
||||
# Verify after first model
|
||||
assert model_id1 == 0
|
||||
assert multi_model.num_models == 1
|
||||
assert multi_model.has_models() is True
|
||||
assert multi_model.model2active[model_id1].item() is True
|
||||
assert multi_model.num_states_total == model1.num_states
|
||||
assert multi_model.num_arcs_extended_total == model1.num_arcs_extended
|
||||
assert multi_model.model2num_states[model_id1].item() == model1.num_states
|
||||
assert multi_model.model2num_arcs_extended[model_id1].item() == model1.num_arcs_extended
|
||||
|
||||
# Create and add second model
|
||||
model2 = create_boosting_model(["test", "one", "two"], tokenizer, device)
|
||||
model_id2 = multi_model.add_model(model2, alpha=1.5)
|
||||
|
||||
# Verify after second model
|
||||
assert model_id2 == 1
|
||||
assert multi_model.num_models == 2
|
||||
assert multi_model.has_models() is True
|
||||
assert multi_model.model2active[model_id1].item() is True
|
||||
assert multi_model.model2active[model_id2].item() is True
|
||||
assert multi_model.num_states_total == model1.num_states + model2.num_states
|
||||
assert multi_model.num_arcs_extended_total == model1.num_arcs_extended + model2.num_arcs_extended
|
||||
|
||||
# Verify offsets
|
||||
assert multi_model.model2states_offset[model_id1].item() == 0
|
||||
assert multi_model.model2states_offset[model_id2].item() == model1.num_states
|
||||
assert multi_model.model2arcs_offset[model_id1].item() == 0
|
||||
assert multi_model.model2arcs_offset[model_id2].item() == model1.num_arcs_extended
|
||||
|
||||
# Verify init states work
|
||||
init_states = multi_model.get_init_states(batch_size=4, bos=True)
|
||||
assert init_states.shape == (4,)
|
||||
assert init_states.device.type == device.type
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_add_then_remove_model(self, stt_en_conformer_transducer_small, device: torch.device):
|
||||
"""Test adding 2 models then removing the first one."""
|
||||
tokenizer = stt_en_conformer_transducer_small.tokenizer
|
||||
vocab_size = tokenizer.vocab_size
|
||||
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size).to(device)
|
||||
|
||||
# Add two models
|
||||
model1 = create_boosting_model(["alpha", "beta"], tokenizer, device)
|
||||
model2 = create_boosting_model(["gamma", "delta"], tokenizer, device)
|
||||
|
||||
model_id1 = multi_model.add_model(model1, alpha=1.0)
|
||||
model_id2 = multi_model.add_model(model2, alpha=2.0)
|
||||
|
||||
# Store counts before removal
|
||||
model1_num_states = model1.num_states
|
||||
model1_num_arcs = model1.num_arcs_extended
|
||||
total_states_before = multi_model.num_states_total
|
||||
total_arcs_before = multi_model.num_arcs_extended_total
|
||||
|
||||
assert multi_model.model2active[model_id1].item() is True
|
||||
assert multi_model.model2active[model_id2].item() is True
|
||||
|
||||
# Remove first model
|
||||
multi_model.remove_model(model_id1)
|
||||
|
||||
# Verify removal
|
||||
assert model_id1 in multi_model.free_ids
|
||||
assert multi_model.model2active[model_id1].item() is False
|
||||
assert multi_model.model2active[model_id2].item() is True
|
||||
assert multi_model.model2alpha[model_id1].item() == 0.0
|
||||
assert multi_model.model2alpha[model_id2].item() == 2.0
|
||||
|
||||
# Verify state/arc counts decreased
|
||||
assert multi_model.num_states_total == total_states_before - model1_num_states
|
||||
assert multi_model.num_arcs_extended_total == total_arcs_before - model1_num_arcs
|
||||
|
||||
# Verify model2 offset updated (shifted left)
|
||||
assert multi_model.model2states_offset[model_id2].item() == 0
|
||||
assert multi_model.model2arcs_offset[model_id2].item() == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_model_id_reuse(self, stt_en_conformer_transducer_small, device):
|
||||
"""Test that removed model IDs are reused."""
|
||||
tokenizer = stt_en_conformer_transducer_small.tokenizer
|
||||
vocab_size = tokenizer.vocab_size
|
||||
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size).to(device)
|
||||
|
||||
# Add model1 -> id=0
|
||||
model1 = create_boosting_model(["first"], tokenizer, device)
|
||||
model_id1 = multi_model.add_model(model1)
|
||||
assert model_id1 == 0
|
||||
|
||||
# Add model2 -> id=1
|
||||
model2 = create_boosting_model(["second"], tokenizer, device)
|
||||
model_id2 = multi_model.add_model(model2)
|
||||
assert model_id2 == 1
|
||||
|
||||
# Remove model1
|
||||
multi_model.remove_model(model_id1)
|
||||
assert model_id1 in multi_model.free_ids
|
||||
|
||||
# Add model3 -> should reuse id=0
|
||||
model3 = create_boosting_model(["third"], tokenizer, device)
|
||||
model_id3 = multi_model.add_model(model3)
|
||||
assert model_id3 == model_id1 # Reused ID
|
||||
assert model_id1 not in multi_model.free_ids # No longer free
|
||||
|
||||
# Verify model3 is active
|
||||
assert multi_model.model2active[model_id3].item() is True
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("batch_size", [1, 4])
|
||||
@pytest.mark.parametrize("use_triton", USE_TRITON_OPTIONS)
|
||||
@pytest.mark.parametrize("bos", [True, False])
|
||||
def test_advance_matches_reference(
|
||||
self, stt_en_conformer_transducer_small, device: torch.device, batch_size: int, use_triton, bos: bool
|
||||
):
|
||||
"""Verify GPUBiasingMultiModel produces same scores/states as reference implementation."""
|
||||
tokenizer = stt_en_conformer_transducer_small.tokenizer
|
||||
vocab_size = tokenizer.vocab_size
|
||||
|
||||
# Create both implementations
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size, use_triton=use_triton).to(device)
|
||||
reference = GPUBiasingMultiModelReference(vocab_size=vocab_size).to(device)
|
||||
|
||||
# Create boosting models with same phrases
|
||||
phrases1 = ["hello world", "test"]
|
||||
phrases2 = ["neural", "network"]
|
||||
|
||||
model1_mm = create_boosting_model(phrases1, tokenizer, device)
|
||||
model1_ref = create_boosting_model(phrases1, tokenizer, device)
|
||||
model2_mm = create_boosting_model(phrases2, tokenizer, device)
|
||||
model2_ref = create_boosting_model(phrases2, tokenizer, device)
|
||||
|
||||
# Add models to both with same alpha values
|
||||
alpha1, alpha2 = 1.0, 1.5
|
||||
model_id1_mm = multi_model.add_model(model1_mm, alpha=alpha1)
|
||||
model_id1_ref = reference.add_model(model1_ref, alpha=alpha1)
|
||||
model_id2_mm = multi_model.add_model(model2_mm, alpha=alpha2)
|
||||
model_id2_ref = reference.add_model(model2_ref, alpha=alpha2)
|
||||
|
||||
assert model_id1_mm == model_id1_ref
|
||||
assert model_id2_mm == model_id2_ref
|
||||
|
||||
# Get initial states
|
||||
states_mm = multi_model.get_init_states(batch_size=batch_size, bos=bos)
|
||||
states_ref = reference.get_init_states(batch_size=batch_size, bos=bos)
|
||||
|
||||
# Create model_ids tensor with alternating models
|
||||
model_ids = torch.tensor(
|
||||
[model_id1_mm if i % 2 == 0 else model_id2_mm for i in range(batch_size)],
|
||||
dtype=torch.long,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Call advance on both
|
||||
scores_mm, next_states_mm = multi_model.advance(states_mm, model_ids)
|
||||
scores_ref, next_states_ref = reference.advance(states_ref, model_ids)
|
||||
|
||||
# Verify shapes
|
||||
assert scores_mm.shape == (batch_size, vocab_size)
|
||||
assert next_states_mm.shape == (batch_size, vocab_size)
|
||||
assert scores_ref.shape == (batch_size, vocab_size)
|
||||
assert next_states_ref.shape == (batch_size, vocab_size)
|
||||
|
||||
# Verify scores and states match
|
||||
assert torch.allclose(
|
||||
scores_mm, scores_ref, atol=1e-5
|
||||
), f"Scores mismatch: max diff = {(scores_mm - scores_ref).abs().max()}"
|
||||
assert torch.equal(next_states_mm, next_states_ref), "Next states mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_empty_multi_model(self, device: torch.device):
|
||||
"""Test behavior of empty multi-model."""
|
||||
vocab_size = 100
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size, use_triton=False).to(device)
|
||||
|
||||
# Verify empty state
|
||||
assert multi_model.has_models() is False
|
||||
assert multi_model.num_models == 0
|
||||
assert multi_model.num_states_total == 0
|
||||
assert multi_model.num_arcs_extended_total == 0
|
||||
|
||||
# get_init_states should work and return START_STATE
|
||||
init_states = multi_model.get_init_states(batch_size=4, bos=True)
|
||||
assert init_states.shape == (4,)
|
||||
assert (init_states == GPUBiasingMultiModel.START_STATE).all()
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_get_alphas(self, stt_en_conformer_transducer_small, device: torch.device):
|
||||
"""Per-stream alpha lookup returns model weight or 0 for invalid ids."""
|
||||
tokenizer = stt_en_conformer_transducer_small.tokenizer
|
||||
vocab_size = tokenizer.vocab_size
|
||||
multi_model = GPUBiasingMultiModel(vocab_size=vocab_size).to(device)
|
||||
model1 = create_boosting_model(["hello"], tokenizer, device)
|
||||
model2 = create_boosting_model(["world"], tokenizer, device)
|
||||
model_id1 = multi_model.add_model(model1, alpha=1.0)
|
||||
model_id2 = multi_model.add_model(model2, alpha=2.5)
|
||||
|
||||
model_ids = torch.tensor([model_id1, model_id2, -1, model_id1], device=device, dtype=torch.long)
|
||||
alphas = multi_model.get_alphas(model_ids)
|
||||
|
||||
assert alphas.shape == (4,)
|
||||
assert alphas[0].item() == pytest.approx(1.0)
|
||||
assert alphas[1].item() == pytest.approx(2.5)
|
||||
assert alphas[2].item() == pytest.approx(0.0)
|
||||
assert alphas[3].item() == pytest.approx(1.0)
|
||||
@@ -0,0 +1,487 @@
|
||||
# Copyright (c) 2022, 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 copy
|
||||
import os
|
||||
from functools import cached_property, lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import DictConfig, open_dict
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.parts.mixins import mixins
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import (
|
||||
CTCBPEDecoding,
|
||||
CTCBPEDecodingConfig,
|
||||
CTCDecoding,
|
||||
CTCDecodingConfig,
|
||||
)
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm.ngram_lm_batched import NGramGPULanguageModel
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceConfig
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.core.utils.cuda_python_utils import skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported
|
||||
from tests.collections.asr.decoding.test_timestamps import BaseTimestampsTest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def audio_file(test_data_dir):
|
||||
return os.path.join(test_data_dir, "asr/test/an4/wav/cen3-mjwl-b.wav")
|
||||
|
||||
|
||||
CTC_MODEL = "nvidia/stt_en_conformer_ctc_small"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kenlm_model_path(tmp_path_factory, test_data_dir):
|
||||
lm_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
assert os.path.exists(lm_path), f"LM file not found: {lm_path}"
|
||||
lm_nemo_path = tmp_path_factory.mktemp("lm") / f"{lm_path.name}.nemo"
|
||||
NGramGPULanguageModel.from_file(lm_path, vocab_size=1024).save_to(f"{lm_nemo_path}")
|
||||
return f"{lm_nemo_path}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ctc_model():
|
||||
model = ASRModel.from_pretrained(model_name=CTC_MODEL, map_location="cpu")
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
def char_vocabulary():
|
||||
return [' ', 'a', 'b', 'c', 'd', 'e', 'f', '.']
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@lru_cache(maxsize=8)
|
||||
def tmp_tokenizer(test_data_dir):
|
||||
cfg = DictConfig({'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'})
|
||||
|
||||
class _TmpASRBPE(mixins.ASRBPEMixin):
|
||||
def register_artifact(self, _, vocab_path):
|
||||
return vocab_path
|
||||
|
||||
asrbpe = _TmpASRBPE()
|
||||
asrbpe._setup_tokenizer(cfg)
|
||||
return asrbpe.tokenizer
|
||||
|
||||
|
||||
class TestCTCDecoding:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self):
|
||||
cfg = CTCDecodingConfig()
|
||||
vocab = char_vocabulary()
|
||||
decoding = CTCDecoding(decoding_cfg=cfg, vocabulary=vocab)
|
||||
assert decoding is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_subword(self, tmp_tokenizer):
|
||||
cfg = CTCBPEDecodingConfig()
|
||||
decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
assert decoding is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_char_decoding_greedy_forward(
|
||||
self,
|
||||
):
|
||||
cfg = CTCDecodingConfig(strategy='greedy')
|
||||
vocab = char_vocabulary()
|
||||
decoding = CTCDecoding(decoding_cfg=cfg, vocabulary=vocab)
|
||||
|
||||
B, T = 4, 20
|
||||
V = len(char_vocabulary()) + 1
|
||||
input_signal = torch.randn(size=(B, T, V))
|
||||
length = torch.randint(low=1, high=T, size=[B])
|
||||
|
||||
with torch.no_grad():
|
||||
hypotheses = decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=False
|
||||
)
|
||||
texts = [hyp.text for hyp in hypotheses]
|
||||
|
||||
for text in texts:
|
||||
assert isinstance(text, str)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('alignments', [False, True])
|
||||
@pytest.mark.parametrize('timestamps', [False, True])
|
||||
def test_char_decoding_greedy_forward_hypotheses(self, alignments, timestamps):
|
||||
cfg = CTCDecodingConfig(strategy='greedy', preserve_alignments=alignments, compute_timestamps=timestamps)
|
||||
vocab = char_vocabulary()
|
||||
decoding = CTCDecoding(decoding_cfg=cfg, vocabulary=vocab)
|
||||
|
||||
B, T = 4, 20
|
||||
V = len(char_vocabulary()) + 1
|
||||
input_signal = torch.randn(size=(B, T, V))
|
||||
length = torch.randint(low=1, high=T, size=[B])
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
for idx, hyp in enumerate(hyps):
|
||||
assert isinstance(hyp, Hypothesis)
|
||||
assert torch.is_tensor(hyp.y_sequence)
|
||||
assert isinstance(hyp.text, str)
|
||||
|
||||
# alignments check
|
||||
if alignments:
|
||||
assert hyp.alignments is not None
|
||||
assert isinstance(hyp.alignments, tuple)
|
||||
assert len(hyp.alignments[0]) == length[idx]
|
||||
assert len(hyp.alignments[1]) == length[idx]
|
||||
|
||||
# timestamps check
|
||||
if timestamps:
|
||||
BaseTimestampsTest.check_char_timestamps(hyp, decoding)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_subword_decoding_greedy_forward(self, tmp_tokenizer):
|
||||
cfg = CTCBPEDecodingConfig(strategy='greedy')
|
||||
decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
|
||||
B, T = 4, 20
|
||||
V = decoding.tokenizer.tokenizer.vocab_size + 1
|
||||
input_signal = torch.randn(size=(B, T, V))
|
||||
length = torch.randint(low=1, high=T, size=[B])
|
||||
|
||||
with torch.no_grad():
|
||||
hypotheses = decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=False
|
||||
)
|
||||
texts = [hyp.text for hyp in hypotheses]
|
||||
|
||||
for text in texts:
|
||||
assert isinstance(text, str)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('alignments', [False, True])
|
||||
@pytest.mark.parametrize('timestamps', [False, True])
|
||||
@pytest.mark.pleasefixme
|
||||
def test_subword_decoding_greedy_forward_hypotheses(self, tmp_tokenizer, alignments, timestamps):
|
||||
cfg = CTCBPEDecodingConfig(strategy='greedy', preserve_alignments=alignments, compute_timestamps=timestamps)
|
||||
decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
|
||||
B, T = 4, 20
|
||||
V = decoding.tokenizer.tokenizer.vocab_size + 1
|
||||
input_signal = torch.randn(size=(B, T, V))
|
||||
length = torch.randint(low=1, high=T, size=[B])
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
for idx, hyp in enumerate(hyps):
|
||||
assert isinstance(hyp, Hypothesis)
|
||||
assert torch.is_tensor(hyp.y_sequence)
|
||||
assert isinstance(hyp.text, str)
|
||||
|
||||
# alignments check
|
||||
if alignments:
|
||||
assert hyp.alignments is not None
|
||||
assert isinstance(hyp.alignments, tuple)
|
||||
assert len(hyp.alignments[0]) == length[idx]
|
||||
assert len(hyp.alignments[1]) == length[idx]
|
||||
|
||||
# timestamps check
|
||||
if timestamps:
|
||||
BaseTimestampsTest.check_subword_timestamps(hyp, decoding)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('alignments', [False, True])
|
||||
@pytest.mark.parametrize('timestamps', [False, True])
|
||||
@pytest.mark.parametrize('preserve_frame_confidence', [False, True])
|
||||
@pytest.mark.parametrize('length_is_none', [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"logprobs_device",
|
||||
[
|
||||
torch.device("cpu"),
|
||||
pytest.param(
|
||||
torch.device("cuda"),
|
||||
marks=pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason='CUDA required for test.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"length_device",
|
||||
[
|
||||
torch.device("cpu"),
|
||||
pytest.param(
|
||||
torch.device("cuda"),
|
||||
marks=pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason='CUDA required for test.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_batched_decoding_logprobs(
|
||||
self,
|
||||
tmp_tokenizer,
|
||||
alignments,
|
||||
timestamps,
|
||||
preserve_frame_confidence,
|
||||
length_is_none,
|
||||
logprobs_device,
|
||||
length_device,
|
||||
):
|
||||
cfg = CTCBPEDecodingConfig(
|
||||
strategy='greedy',
|
||||
preserve_alignments=alignments,
|
||||
compute_timestamps=timestamps,
|
||||
confidence_cfg=ConfidenceConfig(preserve_frame_confidence=preserve_frame_confidence),
|
||||
)
|
||||
unbatched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
|
||||
cfg.strategy = 'greedy_batch'
|
||||
batched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
|
||||
torch.manual_seed(1)
|
||||
B, T = 4, 20
|
||||
V = unbatched_decoding.tokenizer.tokenizer.vocab_size + 1
|
||||
input_signal = torch.randn(size=(B, T, V), device=logprobs_device)
|
||||
# Set the blank index to a very high probability to make sure
|
||||
# that we always handle at least a few blanks.
|
||||
input_signal[:, 0, unbatched_decoding.tokenizer.tokenizer.vocab_size] = 1000
|
||||
input_signal[:, 1, unbatched_decoding.tokenizer.tokenizer.vocab_size] = 1000
|
||||
if length_is_none:
|
||||
length = None
|
||||
else:
|
||||
length = torch.randint(low=1, high=T, size=[B], device=length_device)
|
||||
|
||||
with torch.inference_mode():
|
||||
hyps = unbatched_decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
batched_hyps = batched_decoding.ctc_decoder_predictions_tensor(
|
||||
input_signal, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
assert len(hyps) == len(batched_hyps) == B
|
||||
for hyp, batched_hyp in zip(hyps, batched_hyps):
|
||||
assert torch.abs(hyp.score - batched_hyp.score) <= 1e-5
|
||||
assert torch.all(hyp.y_sequence == batched_hyp.y_sequence)
|
||||
if timestamps:
|
||||
assert hyp.timestamp == batched_hyp.timestamp
|
||||
if alignments:
|
||||
assert torch.all(hyp.alignments[0] == batched_hyp.alignments[0])
|
||||
assert torch.all(hyp.alignments[1] == batched_hyp.alignments[1])
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('timestamps', [False, True])
|
||||
@pytest.mark.parametrize('length_is_none', [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"labels_device",
|
||||
[
|
||||
torch.device("cpu"),
|
||||
pytest.param(
|
||||
torch.device("cuda"),
|
||||
marks=pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason='CUDA required for test.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"length_device",
|
||||
[
|
||||
torch.device("cpu"),
|
||||
pytest.param(
|
||||
torch.device("cuda"),
|
||||
marks=pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason='CUDA required for test.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_batched_decoding_labels(self, tmp_tokenizer, timestamps, length_is_none, labels_device, length_device):
|
||||
cfg = CTCBPEDecodingConfig(strategy='greedy', compute_timestamps=timestamps)
|
||||
unbatched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
cfg.strategy = 'greedy_batch'
|
||||
batched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
|
||||
|
||||
torch.manual_seed(1)
|
||||
B, T = 4, 20
|
||||
V = unbatched_decoding.tokenizer.tokenizer.vocab_size + 1
|
||||
input_labels = torch.randint(V, size=(B, T), device=labels_device)
|
||||
# Set some indices to blank to make sure that we always handle
|
||||
# at least a few blanks.
|
||||
input_labels[:, 0] = unbatched_decoding.tokenizer.tokenizer.vocab_size
|
||||
input_labels[:, 1] = unbatched_decoding.tokenizer.tokenizer.vocab_size
|
||||
if length_is_none:
|
||||
length = None
|
||||
else:
|
||||
length = torch.randint(low=1, high=T, size=[B], device=length_device)
|
||||
|
||||
with torch.inference_mode():
|
||||
hyps = unbatched_decoding.ctc_decoder_predictions_tensor(
|
||||
input_labels, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
batched_hyps = batched_decoding.ctc_decoder_predictions_tensor(
|
||||
input_labels, length, fold_consecutive=True, return_hypotheses=True
|
||||
)
|
||||
|
||||
assert len(hyps) == len(batched_hyps) == B
|
||||
for hyp, batched_hyp in zip(hyps, batched_hyps):
|
||||
assert abs(hyp.score - batched_hyp.score) <= 1e-5
|
||||
assert torch.all(hyp.y_sequence == batched_hyp.y_sequence)
|
||||
if timestamps:
|
||||
assert hyp.timestamp == batched_hyp.timestamp
|
||||
|
||||
|
||||
class TestCTCTimestamps(BaseTimestampsTest):
|
||||
"""CTC-specific timestamp tests that inherit from BaseTimestampsTest"""
|
||||
|
||||
@cached_property
|
||||
def decoding_char(self):
|
||||
cfg = CTCDecodingConfig()
|
||||
vocab = char_vocabulary()
|
||||
decoding = CTCDecoding(decoding_cfg=cfg, vocabulary=vocab)
|
||||
return decoding
|
||||
|
||||
@cached_property
|
||||
def decoding_subword_wpe(self):
|
||||
cfg = CTCBPEDecodingConfig(compute_timestamps=True)
|
||||
decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=self.tmp_tokenizer)
|
||||
return decoding
|
||||
|
||||
@cached_property
|
||||
def decoding_subword_bpe(self):
|
||||
cfg = CTCBPEDecodingConfig(compute_timestamps=True)
|
||||
decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=self.bpe_tokenizer)
|
||||
return decoding
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_offsets_subword_wpe(self, tmp_tokenizer):
|
||||
self.tmp_tokenizer = tmp_tokenizer
|
||||
super().test_word_offsets_subword_wpe()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_offsets_subword_wpe_other_delimiter(self, tmp_tokenizer):
|
||||
self.tmp_tokenizer = tmp_tokenizer
|
||||
super().test_word_offsets_subword_wpe_other_delimiter()
|
||||
|
||||
|
||||
class TestCTCGreedyDecodingWithNGPU_LM:
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Test is only GPU-based decoding")
|
||||
def test_ctc_decoding_gpulm(
|
||||
self,
|
||||
audio_file,
|
||||
kenlm_model_path,
|
||||
ctc_model,
|
||||
):
|
||||
device = torch.device("cuda")
|
||||
model = ctc_model.to(device)
|
||||
|
||||
gt_hyp = model.transcribe([audio_file], num_workers=None)
|
||||
|
||||
decoding_config = copy.deepcopy(model.cfg.decoding)
|
||||
with open_dict(model.decoding.cfg) as cfg:
|
||||
cfg.greedy["ngram_lm_model"] = kenlm_model_path
|
||||
cfg.greedy["ngram_lm_alpha"] = 0.0
|
||||
model.change_decoding_strategy(cfg)
|
||||
lm_hyp = model.transcribe([audio_file], num_workers=None)
|
||||
|
||||
assert gt_hyp[0].text == lm_hyp[0].text
|
||||
assert abs(gt_hyp[0].score - lm_hyp[0].score) <= 1e-3
|
||||
|
||||
with open_dict(model.decoding.cfg) as cfg:
|
||||
cfg.greedy["ngram_lm_model"] = kenlm_model_path
|
||||
cfg.greedy["ngram_lm_alpha"] = 10.0
|
||||
model.change_decoding_strategy(cfg)
|
||||
lm_hyp = model.transcribe([audio_file], num_workers=None)
|
||||
assert gt_hyp[0].text != lm_hyp[0].text
|
||||
assert abs(gt_hyp[0].score - lm_hyp[0].score) > 1e-3
|
||||
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
|
||||
|
||||
class TestCTCGreedyDecodingCudaGrpahs:
|
||||
"""
|
||||
Tests CudaGraphs implementations from CTC models greedy decoding
|
||||
"""
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA decoder can run only on CUDA")
|
||||
@pytest.mark.parametrize("force_mode", ["no_graphs", "no_while_loops", "full_graph"])
|
||||
def test_stated_stateless(self, audio_file, kenlm_model_path, ctc_model, force_mode: str):
|
||||
"""
|
||||
Compares pure Pytorch and with three modes of statefull implementations for double floating point precision.
|
||||
1. Pure pytorch, but statefull implementation: no_graphs
|
||||
2. With CudaGrpahs: no_while_loops and full_graph.
|
||||
"""
|
||||
if force_mode == "full_graph":
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
|
||||
device = torch.device("cuda")
|
||||
model = ctc_model.to(device)
|
||||
decoding_config = copy.deepcopy(model.cfg.decoding)
|
||||
|
||||
with open_dict(model.decoding.cfg) as cfg:
|
||||
cfg.greedy["ngram_lm_model"] = kenlm_model_path
|
||||
cfg.greedy["ngram_lm_alpha"] = 0.2
|
||||
cfg.greedy["allow_cuda_graphs"] = False
|
||||
|
||||
model.change_decoding_strategy(cfg)
|
||||
|
||||
actual_hypotheses = model.transcribe([audio_file], num_workers=None)
|
||||
actual_transcripts = [hyp.text for hyp in actual_hypotheses]
|
||||
actual_scores = [hyp.score for hyp in actual_hypotheses]
|
||||
actual_timestamps = [hyp.timestamp for hyp in actual_hypotheses]
|
||||
|
||||
# transcribe with use implementation with cuda graphs
|
||||
model.decoding.cfg["greedy"]["allow_cuda_graphs"] = True
|
||||
model.change_decoding_strategy(model.decoding.cfg)
|
||||
model.decoding.decoding.force_cuda_graphs_mode(mode=force_mode)
|
||||
|
||||
cudagraph_hypotheses = model.transcribe([audio_file], num_workers=None)
|
||||
cudagraph_transcripts = [hyp.text for hyp in cudagraph_hypotheses]
|
||||
cudagraph_scores = [hyp.score for hyp in cudagraph_hypotheses]
|
||||
cudagraph_timestamps = [hyp.timestamp for hyp in cudagraph_hypotheses]
|
||||
|
||||
for batch_idx in range(len(actual_transcripts)):
|
||||
assert len(actual_transcripts[batch_idx]) == len(cudagraph_transcripts[batch_idx])
|
||||
assert cudagraph_scores[batch_idx] == pytest.approx(
|
||||
actual_scores[batch_idx], abs=1e-2
|
||||
), f"Scores mismatch for batch_idx {batch_idx}"
|
||||
assert (
|
||||
cudagraph_timestamps[batch_idx] == actual_timestamps[batch_idx]
|
||||
), f"Timestamps mismatch for batch_idx {batch_idx}"
|
||||
|
||||
ref_words = actual_transcripts[batch_idx].split()
|
||||
hyp_words = cudagraph_transcripts[batch_idx].split()
|
||||
wer = edit_distance(ref_words, hyp_words)['total'] / max(len(ref_words), 1)
|
||||
|
||||
assert wer <= 1e-3, "Cuda graph greedy decoder should match original decoder implementation."
|
||||
|
||||
for actual, fast in zip(actual_transcripts[batch_idx], cudagraph_transcripts[batch_idx]):
|
||||
if actual != fast:
|
||||
print("Erroneous samples in batch:", batch_idx)
|
||||
print("Original transcript:", actual)
|
||||
print("New transcript:", fast)
|
||||
|
||||
model.change_decoding_strategy(decoding_config)
|
||||
@@ -0,0 +1,368 @@
|
||||
# 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 copy
|
||||
import glob
|
||||
import types
|
||||
|
||||
import lightning.pytorch as ptl
|
||||
import pytest
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import DictConfig, open_dict
|
||||
|
||||
from nemo.core.config.pytorch_lightning import TrainerConfig
|
||||
from nemo.core.utils.cuda_python_utils import skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported
|
||||
|
||||
|
||||
# These tests move the model to CUDA before calling transcribe(), so avoid forking DataLoader workers afterwards.
|
||||
CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS = 0
|
||||
|
||||
|
||||
def test_forced_full_graph_compile_does_not_fallback():
|
||||
from nemo.collections.asr.parts.submodules.transducer_decoding.rnnt_label_looping import (
|
||||
GreedyBatchedRNNTLabelLoopingComputer,
|
||||
)
|
||||
|
||||
accelerator_error = getattr(torch, "AcceleratorError", RuntimeError)
|
||||
computer = GreedyBatchedRNNTLabelLoopingComputer.__new__(GreedyBatchedRNNTLabelLoopingComputer)
|
||||
computer.cuda_graphs_allow_fallback = False
|
||||
|
||||
with pytest.raises(RuntimeError, match="Full CUDA graph decoding failed"):
|
||||
computer._raise_or_warn_no_while_loop_cuda_graphs(accelerator_error("CUDA error: invalid argument"))
|
||||
|
||||
|
||||
def test_conditional_node_restores_previous_stream_on_body_error(monkeypatch):
|
||||
from nemo.core.utils import cuda_python_utils
|
||||
|
||||
if not cuda_python_utils.CUDA_PYTHON_AVAILABLE:
|
||||
pytest.skip("cuda-python is required to test with_conditional_node")
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.cuda_stream = name
|
||||
|
||||
class FakeTorchCuda:
|
||||
def __init__(self):
|
||||
self.parent_stream = FakeStream("parent")
|
||||
self.body_stream = FakeStream("body")
|
||||
self.current = self.parent_stream
|
||||
self.set_calls = []
|
||||
|
||||
def current_stream(self, device=None):
|
||||
return self.current
|
||||
|
||||
def Stream(self, device=None):
|
||||
return self.body_stream
|
||||
|
||||
def set_stream(self, stream):
|
||||
self.current = stream
|
||||
self.set_calls.append(stream)
|
||||
|
||||
class FakeCudart:
|
||||
cudaStreamCaptureStatus = types.SimpleNamespace(cudaStreamCaptureStatusActive="active")
|
||||
cudaStreamUpdateCaptureDependenciesFlags = types.SimpleNamespace(cudaStreamSetCaptureDependencies="set")
|
||||
cudaStreamCaptureMode = types.SimpleNamespace(cudaStreamCaptureModeThreadLocal="thread_local")
|
||||
|
||||
def __init__(self):
|
||||
self.ended_streams = []
|
||||
|
||||
def cudaStreamGetCaptureInfo(self, stream):
|
||||
return ("active", None, "graph", ["dependency"])
|
||||
|
||||
def cudaStreamUpdateCaptureDependencies(self, *args):
|
||||
return ()
|
||||
|
||||
def cudaStreamBeginCaptureToGraph(self, *args):
|
||||
return ()
|
||||
|
||||
def cudaStreamEndCapture(self, stream):
|
||||
self.ended_streams.append(stream)
|
||||
return ()
|
||||
|
||||
class FakeCuda:
|
||||
CUgraphNodeType = types.SimpleNamespace(CU_GRAPH_NODE_TYPE_CONDITIONAL="conditional")
|
||||
CUgraphConditionalNodeType = types.SimpleNamespace(CU_GRAPH_COND_TYPE_WHILE="while")
|
||||
|
||||
class CUgraphNodeParams:
|
||||
def __init__(self):
|
||||
self.conditional = types.SimpleNamespace(phGraph_out=["body_graph"])
|
||||
|
||||
def cuGraphAddNode(self, *args):
|
||||
return ("node",)
|
||||
|
||||
def cuCtxGetCurrent(self):
|
||||
return ("ctx",)
|
||||
|
||||
def cuLaunchKernel(self, *args):
|
||||
return ()
|
||||
|
||||
fake_torch_cuda = FakeTorchCuda()
|
||||
fake_cudart = FakeCudart()
|
||||
fake_args = types.SimpleNamespace(ctypes=types.SimpleNamespace(data=1234))
|
||||
fake_handle = types.SimpleNamespace(getPtr=lambda: 5678)
|
||||
|
||||
monkeypatch.setattr(cuda_python_utils, "cu_call", lambda result: result)
|
||||
monkeypatch.setattr(cuda_python_utils, "cuda", FakeCuda())
|
||||
monkeypatch.setattr(cuda_python_utils, "cudart", fake_cudart)
|
||||
monkeypatch.setattr(cuda_python_utils, "cuda_python_version", "13.0.0")
|
||||
monkeypatch.setattr(cuda_python_utils.torch, "cuda", fake_torch_cuda)
|
||||
|
||||
with pytest.raises(RuntimeError, match="body failed"):
|
||||
with cuda_python_utils.with_conditional_node("kernel", fake_args, fake_handle, device="cuda"):
|
||||
assert fake_torch_cuda.current_stream(device="cuda") is fake_torch_cuda.body_stream
|
||||
raise RuntimeError("body failed")
|
||||
|
||||
assert fake_torch_cuda.current_stream(device="cuda") is fake_torch_cuda.parent_stream
|
||||
assert fake_torch_cuda.set_calls == [fake_torch_cuda.body_stream, fake_torch_cuda.parent_stream]
|
||||
assert fake_cudart.ended_streams == ["body"]
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA decoder can run only on CUDA")
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "batch_size", "enable_bfloat16"),
|
||||
[
|
||||
("stt_en_fastconformer_transducer_large", 8, False),
|
||||
("stt_en_fastconformer_transducer_large", 8, True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("loop_labels", [False, True])
|
||||
def test_cuda_graph_rnnt_greedy_decoder(model_name, batch_size, enable_bfloat16, loop_labels: bool, request):
|
||||
if not loop_labels:
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
if enable_bfloat16 and not torch.cuda.is_bf16_supported():
|
||||
pytest.skip("bfloat16 is not supported")
|
||||
|
||||
device = torch.device("cuda")
|
||||
nemo_model = request.getfixturevalue(model_name).to(device)
|
||||
decoding_config = copy.deepcopy(nemo_model.cfg.decoding)
|
||||
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["greedy"]["max_symbols"] = 5
|
||||
decoding_config["greedy"]["loop_labels"] = loop_labels
|
||||
decoding_config["greedy"]["use_cuda_graph_decoder"] = False
|
||||
|
||||
nemo_model.change_decoding_strategy(decoding_config)
|
||||
audio_filepaths = glob.glob("tests/.data/asr/test/an4/wav/*.wav")
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=enable_bfloat16):
|
||||
actual_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
|
||||
actual_transcripts = [hyp.text for hyp in actual_hypotheses]
|
||||
actual_y_sequences = [hyp.y_sequence for hyp in actual_hypotheses]
|
||||
|
||||
decoding_config["greedy"]["use_cuda_graph_decoder"] = True
|
||||
|
||||
nemo_model.change_decoding_strategy(decoding_config)
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=enable_bfloat16):
|
||||
fast_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
|
||||
fast_transcripts = [hyp.text for hyp in fast_hypotheses]
|
||||
fast_y_sequences = [hyp.y_sequence for hyp in fast_hypotheses]
|
||||
|
||||
total_dist = sum(
|
||||
edit_distance(r.split(), h.split())['total'] for r, h in zip(actual_transcripts, fast_transcripts)
|
||||
)
|
||||
total_words = sum(len(r.split()) for r in actual_transcripts)
|
||||
wer = total_dist / total_words if total_words > 0 else 0.0
|
||||
y_sequence_eq = [torch.equal(act_y, fast_y) for (act_y, fast_y) in zip(actual_y_sequences, fast_y_sequences)]
|
||||
|
||||
assert wer <= 1e-3, "Cuda graph greedy decoder should match original decoder implementation."
|
||||
assert all(y_sequence_eq), "Cuda graph greedy decoder should match original decoder implementation."
|
||||
|
||||
for actual, fast in zip(actual_transcripts, fast_transcripts):
|
||||
if actual != fast:
|
||||
print("erroneous samples:")
|
||||
print("Original transcript:", actual)
|
||||
print("New transcript:", fast)
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA decoder can run only on CUDA")
|
||||
@pytest.mark.parametrize("force_mode", ["no_graphs", "no_while_loops", "full_graph"])
|
||||
@pytest.mark.parametrize("enable_bfloat16", [False, True])
|
||||
def test_loop_labels_cuda_graph_rnnt_greedy_decoder_forced_mode(
|
||||
stt_en_fastconformer_transducer_large, force_mode: str, enable_bfloat16: bool
|
||||
):
|
||||
"""
|
||||
Testing Label-Looping algorithm with CUDA graphs in forced mode.
|
||||
This test guarantees that we check that the fallback behavior is working.
|
||||
NB: Since it is impossible to directly debug CUDA graphs, when making changes,
|
||||
start testing and debugging the code with forced "no_graphs" mode.
|
||||
"""
|
||||
if enable_bfloat16 and not torch.cuda.is_bf16_supported():
|
||||
pytest.skip("bfloat16 is not supported")
|
||||
|
||||
if force_mode == "full_graph":
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
|
||||
batch_size = 16
|
||||
device = torch.device("cuda")
|
||||
nemo_model = stt_en_fastconformer_transducer_large.to(device)
|
||||
decoding_config = copy.deepcopy(nemo_model.cfg.decoding)
|
||||
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["greedy"]["max_symbols"] = 5
|
||||
decoding_config["greedy"]["loop_labels"] = True
|
||||
decoding_config["greedy"]["use_cuda_graph_decoder"] = False
|
||||
# test that alignments and confidence do not introduce failures
|
||||
decoding_config["greedy"]["preserve_alignments"] = True
|
||||
decoding_config["greedy"]["preserve_frame_confidence"] = True
|
||||
|
||||
nemo_model.change_decoding_strategy(decoding_config)
|
||||
audio_filepaths = glob.glob("tests/.data/asr/test/an4/wav/*.wav")
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=enable_bfloat16):
|
||||
actual_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
actual_transcripts = [hyp.text for hyp in actual_hypotheses]
|
||||
|
||||
# transcribe with use implementation with cuda graphs
|
||||
decoding_config["greedy"]["use_cuda_graph_decoder"] = True
|
||||
nemo_model.change_decoding_strategy(decoding_config)
|
||||
backup_cuda_graph_mode = nemo_model.decoding.decoding.decoding_computer.cuda_graphs_mode
|
||||
try:
|
||||
nemo_model.decoding.decoding.decoding_computer.force_cuda_graphs_mode(mode=force_mode)
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=enable_bfloat16):
|
||||
fast_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
fast_transcripts = [hyp.text for hyp in fast_hypotheses]
|
||||
|
||||
total_dist = sum(
|
||||
edit_distance(r.split(), h.split())['total'] for r, h in zip(actual_transcripts, fast_transcripts)
|
||||
)
|
||||
total_words = sum(len(r.split()) for r in actual_transcripts)
|
||||
wer = total_dist / total_words if total_words > 0 else 0.0
|
||||
|
||||
assert wer <= 1e-3, "Cuda graph greedy decoder should match original decoder implementation."
|
||||
|
||||
for actual, fast in zip(actual_transcripts, fast_transcripts):
|
||||
if actual != fast:
|
||||
print("erroneous samples:")
|
||||
print("Original transcript:", actual)
|
||||
print("New transcript:", fast)
|
||||
finally:
|
||||
nemo_model.decoding.decoding.decoding_computer.force_cuda_graphs_mode(mode=backup_cuda_graph_mode)
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(
|
||||
not (torch.cuda.is_available() and torch.cuda.is_bf16_supported()),
|
||||
reason="Test requires CUDA device with bf16 support",
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
def test_loop_labels_cuda_graph_ddp_mixed_precision(
|
||||
tmp_path_factory,
|
||||
an4_train_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
is_tdt: bool,
|
||||
):
|
||||
"""CUDA graphs with DDP and mixed precision have bugs. We need to test that validation works with these settings."""
|
||||
batch_size = 16
|
||||
|
||||
# instantiate trainer with bf16 mixed precision
|
||||
trainer_cfg = TrainerConfig(devices=[0], accelerator="cuda", strategy="ddp", max_epochs=1, precision="bf16-mixed")
|
||||
trainer = ptl.Trainer(**DictConfig(trainer_cfg))
|
||||
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
|
||||
# setup validation data
|
||||
val_ds_cfg = model.cfg.validation_ds
|
||||
with open_dict(val_ds_cfg):
|
||||
val_ds_cfg.manifest_filepath = [str(an4_train_manifest_corrected)]
|
||||
val_ds_cfg.batch_size = batch_size
|
||||
val_ds_cfg.is_tarred = False
|
||||
val_ds_cfg.use_lhotse = False
|
||||
if is_tdt:
|
||||
# TDT model has config with missing mandatory values, this results in errors when setting up validation data
|
||||
# we set all the mandatory values to dummy values
|
||||
model.cfg.train_ds.tarred_audio_filepaths = None
|
||||
model.cfg.train_ds.manifest_filepath = None
|
||||
model.cfg.test_ds.manifest_filepath = None
|
||||
model.cfg.tokenizer.dir = None
|
||||
|
||||
model.setup_multiple_validation_data(val_ds_cfg)
|
||||
|
||||
# validate using trainer
|
||||
val_results = trainer.validate(model)
|
||||
wer = val_results[0]["val_wer"]
|
||||
|
||||
# explicitly free resources, then test conditions
|
||||
trainer._teardown()
|
||||
# teardown from the trainer is not enough, and problem with CPU will still exist, related issue:
|
||||
# https://github.com/Lightning-AI/pytorch-lightning/issues/18803)
|
||||
# solution is to destroy torch process group explicitly
|
||||
torch.distributed.destroy_process_group()
|
||||
|
||||
assert wer <= 0.1, f"WER is too high: {wer}"
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.skipif(not torch.cuda.is_available() or torch.cuda.device_count() < 2, reason="Test requires 2 GPUs")
|
||||
@pytest.mark.parametrize("loop_labels", [False, True])
|
||||
def test_change_devices(loop_labels: bool, stt_en_fastconformer_transducer_large):
|
||||
if not loop_labels:
|
||||
skip_cuda_python_test_if_cuda_graphs_conditional_nodes_not_supported()
|
||||
|
||||
first_device = torch.device("cuda:0")
|
||||
second_device = torch.device("cuda:1")
|
||||
|
||||
batch_size = 8
|
||||
|
||||
nemo_model = stt_en_fastconformer_transducer_large.to(second_device)
|
||||
decoding_config = copy.deepcopy(nemo_model.cfg.decoding)
|
||||
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["greedy"]["max_symbols"] = 5
|
||||
decoding_config["greedy"]["loop_labels"] = loop_labels
|
||||
decoding_config["greedy"]["use_cuda_graph_decoder"] = True
|
||||
|
||||
nemo_model.change_decoding_strategy(decoding_config)
|
||||
|
||||
# Test that the model can run successfully when it is first
|
||||
# initialized on second_device and then transferred to
|
||||
# true_device
|
||||
nemo_model.to(first_device)
|
||||
audio_filepaths = glob.glob("tests/.data/asr/test/an4/wav/*.wav")
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=True):
|
||||
second_device_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
second_device_transcripts = [hyp.text for hyp in second_device_hypotheses]
|
||||
|
||||
# Test that the model can run successfully back on second_device
|
||||
# after having been first run on first_device. Because the
|
||||
# decoder's data structures are lazily initialized, this activates
|
||||
# slightly different code than the first case (where the decoder
|
||||
# has not run at all), so we want to exercise both cases.
|
||||
nemo_model.to(second_device)
|
||||
|
||||
with torch.cuda.amp.autocast(dtype=torch.bfloat16, enabled=True):
|
||||
first_device_hypotheses = nemo_model.transcribe(
|
||||
audio_filepaths, batch_size=batch_size, num_workers=CUDA_GRAPH_TRANSCRIBE_NUM_WORKERS
|
||||
)
|
||||
first_device_transcripts = [hyp.text for hyp in first_device_hypotheses]
|
||||
# Sanity check: The device we run on should not change execution
|
||||
# output.
|
||||
assert first_device_transcripts == second_device_transcripts
|
||||
@@ -0,0 +1,342 @@
|
||||
# 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.
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.modules.transformer.transformer import TransformerDecoderNM
|
||||
from nemo.collections.asr.modules.transformer.transformer_generators import (
|
||||
BeamSearchSequenceGenerator,
|
||||
BeamSearchSequenceGeneratorWithFusionModels,
|
||||
GreedySequenceGenerator,
|
||||
)
|
||||
from nemo.collections.asr.parts.context_biasing import GPUBoostingTreeModel
|
||||
from nemo.collections.asr.parts.submodules.multitask_beam_decoding import TransformerAEDBeamInfer
|
||||
from nemo.collections.asr.parts.submodules.multitask_greedy_decoding import TransformerAEDGreedyInfer
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel
|
||||
from nemo.collections.asr.parts.submodules.token_classifier import TokenClassifier
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def deterministic_rng():
|
||||
state = torch.get_rng_state()
|
||||
torch.manual_seed(0)
|
||||
yield
|
||||
torch.set_rng_state(state)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def decoder_nm(deterministic_rng):
|
||||
return TransformerDecoderNM(
|
||||
vocab_size=8,
|
||||
hidden_size=2,
|
||||
num_layers=1,
|
||||
inner_size=4,
|
||||
num_attention_heads=1,
|
||||
max_sequence_length=32,
|
||||
).eval()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nnet(decoder_nm):
|
||||
ans = (
|
||||
decoder_nm.embedding,
|
||||
decoder_nm.decoder,
|
||||
TokenClassifier(hidden_size=2, num_classes=8),
|
||||
)
|
||||
ans = tuple(m.eval() for m in ans)
|
||||
return ans
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def inputs():
|
||||
B, T, C = 1, 5, 2
|
||||
return (
|
||||
torch.tensor([[1]], dtype=torch.long), # decoder_input_ids
|
||||
torch.ones(B, T, C, dtype=torch.float), # encoder_hidden_states
|
||||
torch.ones(B, T, dtype=torch.float), # encoder_input_mask
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tokenizer():
|
||||
tok = Mock()
|
||||
tok.pad = 0
|
||||
tok.bos = 1
|
||||
tok.eos = 2
|
||||
return tok
|
||||
|
||||
|
||||
@pytest.mark.parametrize('with_confidence', [False, True])
|
||||
@pytest.mark.parametrize('return_xattn_scores', [False, True])
|
||||
def test_greedy_decoding(inputs, nnet, deterministic_rng, with_confidence, return_xattn_scores):
|
||||
gen = GreedySequenceGenerator(
|
||||
*nnet, return_xattn_scores=return_xattn_scores, preserve_step_confidence=with_confidence
|
||||
)
|
||||
output = gen(*inputs)
|
||||
|
||||
assert len(output) == 4
|
||||
best_path, hypotheses, confidence, xattn_list = output
|
||||
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
assert best_path.shape == (1, 25)
|
||||
if return_xattn_scores:
|
||||
assert len(xattn_list) == len(nnet[1].layers)
|
||||
assert xattn_list[0].shape == (1, 1, 24, 5)
|
||||
else:
|
||||
assert xattn_list is None
|
||||
|
||||
assert hypotheses is None
|
||||
|
||||
if with_confidence:
|
||||
assert confidence is not None
|
||||
assert torch.is_tensor(confidence)
|
||||
assert confidence.shape == best_path.shape
|
||||
else:
|
||||
assert confidence is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('return_xattn_scores', [False, True])
|
||||
def test_temperature_sampling_decoding(inputs, nnet, return_xattn_scores):
|
||||
gen = GreedySequenceGenerator(*nnet, return_xattn_scores=return_xattn_scores, temperature=10.0, n_samples=2)
|
||||
output = gen(*inputs)
|
||||
|
||||
assert len(output) == 4
|
||||
best_path, hypotheses, _, xatt_list = output
|
||||
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
assert best_path.shape[0] == 1
|
||||
|
||||
assert isinstance(hypotheses, list)
|
||||
assert len(hypotheses) == 1
|
||||
(seq0,) = hypotheses
|
||||
assert seq0.shape[0] == 2
|
||||
assert (seq0[0] != seq0[1]).any()
|
||||
|
||||
if return_xattn_scores:
|
||||
assert len(xatt_list) == len(nnet[1].layers)
|
||||
assert xatt_list[0].shape == (2, 1, 24, 5)
|
||||
else:
|
||||
assert xatt_list is None
|
||||
|
||||
|
||||
def test_beam_decoding_beam_scores_false(inputs, nnet):
|
||||
gen = BeamSearchSequenceGenerator(*nnet, beam_size=2)
|
||||
output = gen(*inputs, return_beam_scores=False)
|
||||
|
||||
assert len(output) == 1
|
||||
(best_path,) = output
|
||||
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
assert best_path.shape == (26,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('return_xattn_scores', [False, True])
|
||||
def test_beam_decoding_beam_scores_true(inputs, nnet, return_xattn_scores):
|
||||
gen = BeamSearchSequenceGenerator(*nnet, return_xattn_scores=return_xattn_scores, beam_size=2)
|
||||
output = gen(*inputs, return_beam_scores=True)
|
||||
|
||||
assert len(output) == 4
|
||||
beam_paths, scores, best_path, xatt_scores_list = output
|
||||
|
||||
assert beam_paths is not None
|
||||
assert isinstance(beam_paths, list)
|
||||
assert len(beam_paths) == 1
|
||||
(beam_paths_seq0,) = beam_paths
|
||||
assert torch.is_tensor(beam_paths_seq0)
|
||||
assert beam_paths_seq0.shape == (2, 26)
|
||||
|
||||
assert scores is not None
|
||||
assert isinstance(scores, list)
|
||||
assert len(scores) == 1
|
||||
(scores_seq0,) = scores
|
||||
assert torch.is_tensor(scores_seq0)
|
||||
assert scores_seq0.shape == (2,)
|
||||
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
assert best_path.shape == (1, 26)
|
||||
|
||||
if return_xattn_scores:
|
||||
assert xatt_scores_list is not None
|
||||
assert isinstance(xatt_scores_list, list)
|
||||
assert torch.is_tensor(xatt_scores_list[0])
|
||||
assert xatt_scores_list[0].shape == (1, 1, 25, 5)
|
||||
else:
|
||||
assert xatt_scores_list is None
|
||||
|
||||
|
||||
def test_beam_decoding_beam_scores_true_with_fusion_models(inputs, nnet):
|
||||
"""Test decoding with dummy unigram LM and boosting tree"""
|
||||
# load dummy ngpu-lm
|
||||
lm = NGramGPULanguageModel.dummy_unigram_lm(vocab_size=8)
|
||||
|
||||
# load dummy boosting tree
|
||||
boosting_tree = GPUBoostingTreeModel.dummy_boosting_tree(vocab_size=8)
|
||||
|
||||
fusion_models = [lm, boosting_tree]
|
||||
fusion_models_alpha = [0.2, 0.2]
|
||||
|
||||
gen = BeamSearchSequenceGeneratorWithFusionModels(
|
||||
*nnet,
|
||||
return_xattn_scores=True,
|
||||
fusion_models=fusion_models,
|
||||
fusion_models_alpha=fusion_models_alpha,
|
||||
beam_size=2,
|
||||
)
|
||||
output = gen(*inputs, return_beam_scores=True)
|
||||
|
||||
assert len(output) == 4
|
||||
beam_paths, scores, best_path, xatt_scores_list = output
|
||||
|
||||
assert beam_paths is not None
|
||||
assert isinstance(beam_paths, list)
|
||||
assert len(beam_paths) == 1
|
||||
(beam_paths_seq0,) = beam_paths
|
||||
assert torch.is_tensor(beam_paths_seq0)
|
||||
assert beam_paths_seq0.shape == (2, 26)
|
||||
|
||||
assert scores is not None
|
||||
assert isinstance(scores, list)
|
||||
assert len(scores) == 1
|
||||
(scores_seq0,) = scores
|
||||
assert torch.is_tensor(scores_seq0)
|
||||
assert scores_seq0.shape == (2,)
|
||||
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
assert best_path.shape == (1, 26)
|
||||
|
||||
assert xatt_scores_list is not None
|
||||
assert isinstance(xatt_scores_list, list)
|
||||
assert torch.is_tensor(xatt_scores_list[0])
|
||||
assert xatt_scores_list[0].shape == (1, 1, 25, 5)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prompted_inputs():
|
||||
B, T, C = 1, 5, 2
|
||||
return (
|
||||
torch.tensor([[1, 0, 2, 3, 4]], dtype=torch.long), # prompt
|
||||
torch.ones(B, T, C, dtype=torch.float), # encoder_hidden_states
|
||||
torch.ones(B, T, dtype=torch.float), # encoder_input_mask
|
||||
)
|
||||
|
||||
|
||||
def test_transformer_aed_beam_infer_strips_prompt(prompted_inputs, decoder_nm, nnet, tokenizer):
|
||||
decoder_input_ids, encoder_hidden_states, encoder_input_mask = prompted_inputs
|
||||
*_, classifier = nnet
|
||||
|
||||
# Run the actual top-level module used by MultiTask AED model for decoding.
|
||||
# This module is expected to trim the prompt from the beginning, and eos and pad from the end.
|
||||
gen = TransformerAEDBeamInfer(decoder_nm, classifier, tokenizer)
|
||||
ans = gen(
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
encoder_input_mask=encoder_input_mask,
|
||||
decoder_input_ids=decoder_input_ids,
|
||||
)
|
||||
best_path = ans[0][0].y_sequence
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
|
||||
# Now run the underlying beam search generator that doesn't trim anything.
|
||||
*_, (untrimmed,), _ = gen.beam_search(*prompted_inputs, return_beam_scores=True)
|
||||
assert untrimmed is not None
|
||||
assert torch.is_tensor(untrimmed)
|
||||
|
||||
# Check that the expected trimming has indeed been done.
|
||||
torch.testing.assert_close(
|
||||
untrimmed[decoder_input_ids.shape[1] :], best_path
|
||||
) # stripped the prompt from the beggining
|
||||
|
||||
|
||||
def test_transformer_aed_greedy_infer_strips_prompt(prompted_inputs, decoder_nm, nnet, tokenizer):
|
||||
decoder_input_ids, encoder_hidden_states, encoder_input_mask = prompted_inputs
|
||||
decoder_input_ids = torch.tensor([[1, 0, 2, 3, 4]], dtype=torch.long) # prompt
|
||||
*_, classifier = nnet
|
||||
|
||||
# Run the actual top-level module used by MultiTask AED model for decoding.
|
||||
# This module is expected to trim the prompt from the beginning, and eos and pad from the end.
|
||||
gen = TransformerAEDGreedyInfer(decoder_nm, classifier, tokenizer)
|
||||
ans = gen(
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
encoder_input_mask=encoder_input_mask,
|
||||
decoder_input_ids=decoder_input_ids,
|
||||
)
|
||||
best_path = ans[0][0].y_sequence
|
||||
assert best_path is not None
|
||||
assert torch.is_tensor(best_path)
|
||||
|
||||
# Now run the underlying beam search generator that doesn't trim anything.
|
||||
(untrimmed,), _, _, _ = gen.greedy_search(*prompted_inputs)
|
||||
assert untrimmed is not None
|
||||
assert torch.is_tensor(untrimmed)
|
||||
|
||||
# Check that the expected trimming has indeed been done.
|
||||
torch.testing.assert_close(
|
||||
untrimmed[decoder_input_ids.shape[1] :], best_path
|
||||
) # stripped the prompt from the beggining
|
||||
|
||||
|
||||
def test_transformer_aed_beam_infer_trims_xatt_scores(prompted_inputs, decoder_nm, nnet, tokenizer):
|
||||
decoder_input_ids, encoder_hidden_states, encoder_input_mask = prompted_inputs
|
||||
*_, classifier = nnet
|
||||
|
||||
# Run the actual top-level module used by MultiTask AED model for decoding.
|
||||
# This module is expected to trim eos and pads in xatt from the end.
|
||||
gen = TransformerAEDBeamInfer(decoder_nm, classifier, tokenizer, return_xattn_scores=True)
|
||||
ans = gen(
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
encoder_input_mask=encoder_input_mask,
|
||||
decoder_input_ids=decoder_input_ids,
|
||||
)
|
||||
hyp = ans[0][0]
|
||||
|
||||
assert hyp.xatt_scores is not None
|
||||
seq_len = hyp.y_sequence.shape[0]
|
||||
decoder_input_ids_len = decoder_input_ids.shape[1]
|
||||
total_expected_len = seq_len + decoder_input_ids_len - 1
|
||||
|
||||
# Check that the expected trimming has indeed been done.
|
||||
for layer_scores in hyp.xatt_scores:
|
||||
assert layer_scores.shape[1] == total_expected_len
|
||||
|
||||
|
||||
def test_transformer_aed_greedy_infer_trims_xatt_scores(prompted_inputs, decoder_nm, nnet, tokenizer):
|
||||
decoder_input_ids, encoder_hidden_states, encoder_input_mask = prompted_inputs
|
||||
*_, classifier = nnet
|
||||
|
||||
# Run the actual top-level module used by MultiTask AED model for decoding.
|
||||
# This module is expected to trim eos and pads in xatt from the end.
|
||||
gen = TransformerAEDGreedyInfer(decoder_nm, classifier, tokenizer, return_xattn_scores=True)
|
||||
ans = gen(
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
encoder_input_mask=encoder_input_mask,
|
||||
decoder_input_ids=decoder_input_ids,
|
||||
)
|
||||
hyp = ans[0][0]
|
||||
|
||||
assert hyp.xatt_scores is not None
|
||||
seq_len = hyp.y_sequence.shape[0]
|
||||
decoder_input_ids_len = decoder_input_ids.shape[1]
|
||||
total_expected_len = seq_len + decoder_input_ids_len - 1
|
||||
|
||||
# Check that the expected trimming has indeed been done.
|
||||
for layer_scores in hyp.xatt_scores:
|
||||
assert layer_scores.shape[1] == total_expected_len
|
||||
@@ -0,0 +1,192 @@
|
||||
# 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 kaldialign import edit_distance
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.collections.asr.models.aed_multitask_models import lens_to_mask
|
||||
from nemo.collections.asr.parts.submodules.aed_decoding import (
|
||||
GreedyBatchedStreamingAEDComputer,
|
||||
return_decoder_input_ids,
|
||||
)
|
||||
from nemo.collections.asr.parts.submodules.multitask_decoding import (
|
||||
AEDStreamingDecodingConfig,
|
||||
MultiTaskDecodingConfig,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.collections.asr.parts.utils.streaming_utils import ContextSize
|
||||
from tests.collections.asr.decoding.utils import load_audio, make_preprocessor_deterministic
|
||||
|
||||
DEVICES = [torch.device("cpu")]
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device("cuda:0"))
|
||||
|
||||
if torch.mps.is_available():
|
||||
DEVICES.append(torch.device("mps"))
|
||||
|
||||
|
||||
def get_batch_encoder_outputs_from_records(records, model, device):
|
||||
"""Helper function to get encoder outputs for a batch of manifest records"""
|
||||
local_batch_size = len(records)
|
||||
filenames = [record["audio_filepath"] for record in records]
|
||||
audio_filepaths = filenames[:local_batch_size]
|
||||
|
||||
with torch.no_grad():
|
||||
make_preprocessor_deterministic(model)
|
||||
model.eval()
|
||||
|
||||
all_inputs, all_lengths = [], []
|
||||
for audio_file in tqdm(audio_filepaths, desc="Loading audio files"):
|
||||
audio_tensor, _ = load_audio(audio_file)
|
||||
all_inputs.append(audio_tensor)
|
||||
all_lengths.append(torch.tensor(audio_tensor.shape[0], dtype=torch.int64))
|
||||
|
||||
input_batch = torch.nn.utils.rnn.pad_sequence(all_inputs, batch_first=True).to(
|
||||
device=device, dtype=torch.float32
|
||||
)
|
||||
length_batch = torch.tensor(all_lengths, dtype=torch.int64).to(device)
|
||||
# get encoder output using full audio signal
|
||||
_, encoded_length, encoded_output, _ = model(input_signal=input_batch, input_signal_length=length_batch)
|
||||
|
||||
return encoded_output, encoded_length
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("decoding_policy", ["waitk", "alignatt"])
|
||||
@pytest.mark.parametrize("chunk_size", [3, 4])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
def test_multi_task_streaming_decoding(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
canary_180m_flash,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
decoding_policy: str,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
):
|
||||
"""Test streaming decoding with multi-task model for different decoding policies"""
|
||||
model = canary_180m_flash
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
# setup streaming decoding config
|
||||
streaming_decoding_cfg = AEDStreamingDecodingConfig()
|
||||
streaming_decoding_cfg.streaming_policy = decoding_policy
|
||||
streaming_decoding_cfg.chunk_secs = 1
|
||||
streaming_decoding_cfg.right_context_secs = 0.0
|
||||
streaming_decoding_cfg.batch_size = batch_size
|
||||
streaming_decoding_cfg.prompt = OmegaConf.create({'pnc': 'no', 'task': 'asr'})
|
||||
|
||||
context_encoder_frames = ContextSize(
|
||||
left=100,
|
||||
chunk=chunk_size,
|
||||
right=0.0,
|
||||
)
|
||||
|
||||
# setup decoding strategy
|
||||
if hasattr(model, 'change_decoding_strategy'):
|
||||
multitask_decoding = MultiTaskDecodingConfig()
|
||||
multitask_decoding.strategy = "greedy"
|
||||
model.change_decoding_strategy(multitask_decoding)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
|
||||
all_hyps = []
|
||||
tokens_frame_alignment = []
|
||||
predicted_token_ids = []
|
||||
decoding_computer = GreedyBatchedStreamingAEDComputer(
|
||||
model,
|
||||
frame_chunk_size=chunk_size,
|
||||
decoding_cfg=streaming_decoding_cfg,
|
||||
)
|
||||
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[i : i + batch_size], model=model, device=device
|
||||
)
|
||||
local_batch_size = encoder_output_len.shape[0]
|
||||
decoder_input_ids = return_decoder_input_ids(streaming_decoding_cfg, model)
|
||||
model_state = GreedyBatchedStreamingAEDComputer.initialize_aed_model_state(
|
||||
asr_model=model,
|
||||
decoder_input_ids=decoder_input_ids,
|
||||
batch_size=local_batch_size,
|
||||
context_encoder_frames=context_encoder_frames,
|
||||
chunk_secs=streaming_decoding_cfg.chunk_secs,
|
||||
right_context_secs=streaming_decoding_cfg.right_context_secs,
|
||||
)
|
||||
|
||||
# decode encoder output by chunks, passing state between decoder invocations
|
||||
for t in range(0, encoder_output.shape[1], chunk_size):
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=t + chunk_size)
|
||||
current_len = torch.minimum(current_len, encoder_output_len)
|
||||
model_state.is_last_chunk_batch = current_len >= encoder_output_len
|
||||
encoder_input_mask = lens_to_mask(current_len, encoder_output[:, : t + chunk_size].shape[1]).to(
|
||||
encoder_output.dtype
|
||||
)
|
||||
|
||||
model_state = decoding_computer(
|
||||
encoder_output=encoder_output[:, : t + chunk_size],
|
||||
encoder_output_len=current_len,
|
||||
encoder_input_mask=encoder_input_mask,
|
||||
prev_batched_state=model_state,
|
||||
)
|
||||
# get final results for each sample in the batch
|
||||
for j in range(local_batch_size):
|
||||
transcription_idx = model_state.pred_tokens_ids[
|
||||
j, model_state.decoder_input_ids.size(-1) : model_state.current_context_lengths[j]
|
||||
]
|
||||
transcription = model.tokenizer.ids_to_text(transcription_idx.tolist()).strip()
|
||||
all_hyps.append(transcription)
|
||||
tokens_frame_alignment.append(model_state.tokens_frame_alignment[j])
|
||||
predicted_token_ids.append(
|
||||
model_state.pred_tokens_ids[
|
||||
j, model_state.decoder_input_ids.size(-1) : model_state.current_context_lengths[j]
|
||||
]
|
||||
)
|
||||
|
||||
# compare decoding results with reference transcripts
|
||||
ref_transcripts = [item['text'] for item in manifest]
|
||||
assert (
|
||||
edit_distance(ref_transcripts, all_hyps)['total'] <= len(ref_transcripts) * 0.1
|
||||
) # Expected WER is less than 10%
|
||||
|
||||
# compute latency
|
||||
audio_encoder_fs = 80 # in ms
|
||||
laal_list = None
|
||||
if decoding_policy == "waitk":
|
||||
laal_list = decoding_computer.compute_waitk_lagging(
|
||||
manifest, predicted_token_ids, context_encoder_frames, audio_encoder_fs, BOW_PREFIX="\u2581"
|
||||
)
|
||||
elif decoding_policy == "alignatt":
|
||||
laal_list = decoding_computer.compute_alignatt_lagging(
|
||||
manifest,
|
||||
predicted_token_ids,
|
||||
tokens_frame_alignment,
|
||||
context_encoder_frames,
|
||||
audio_encoder_fs,
|
||||
BOW_PREFIX="\u2581",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Decoding policy {decoding_policy} is not supported")
|
||||
laal = sum(laal_list) / len(laal_list)
|
||||
assert 300 <= laal <= 900 # Expected LAAL is between 300ms and 900ms depending on the decoding policy
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2022, 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.
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import pytest
|
||||
import torch.cuda
|
||||
from examples.asr.transcribe_speech import TranscriptionConfig
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.models import EncDecRNNTBPEModel
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.asr.parts.utils.transcribe_utils import prepare_audio_data
|
||||
|
||||
DEVICES = []
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append('cuda')
|
||||
|
||||
if torch.mps.is_available():
|
||||
DEVICES.append('mps')
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stt_en_conformer_transducer_small_model():
|
||||
model = EncDecRNNTBPEModel.from_pretrained(model_name="stt_en_conformer_transducer_small", map_location="cpu")
|
||||
return model
|
||||
|
||||
|
||||
def get_rnnt_alignments(
|
||||
strategy: str,
|
||||
manifest_path: Union[Path, str],
|
||||
model: EncDecRNNTBPEModel,
|
||||
loop_labels: bool = True,
|
||||
use_cuda_graph_decoder=False,
|
||||
device="cuda",
|
||||
) -> list[Hypothesis]:
|
||||
cfg = OmegaConf.structured(TranscriptionConfig())
|
||||
cfg.rnnt_decoding.confidence_cfg.preserve_frame_confidence = True
|
||||
cfg.rnnt_decoding.confidence_cfg.exclude_blank = False
|
||||
cfg.rnnt_decoding.preserve_alignments = True
|
||||
cfg.rnnt_decoding.strategy = strategy
|
||||
if cfg.rnnt_decoding.strategy == "greedy_batch":
|
||||
cfg.rnnt_decoding.greedy.loop_labels = loop_labels
|
||||
cfg.rnnt_decoding.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
cfg.dataset_manifest = str(manifest_path)
|
||||
filepaths = prepare_audio_data(cfg)[0][:8] # selecting 8 files only
|
||||
# NB: 9th file has the same transcription but a bit different alignment for batched/non-batched decoding
|
||||
|
||||
model = model.to(device)
|
||||
model.change_decoding_strategy(cfg.rnnt_decoding)
|
||||
|
||||
transcriptions: list[Hypothesis] = model.transcribe(
|
||||
audio=filepaths,
|
||||
batch_size=cfg.batch_size,
|
||||
num_workers=cfg.num_workers,
|
||||
return_hypotheses=True,
|
||||
channel_selector=cfg.channel_selector,
|
||||
)
|
||||
|
||||
for transcription in transcriptions:
|
||||
for align_elem, frame_confidence in zip(transcription.alignments, transcription.frame_confidence):
|
||||
assert len(align_elem) == len(frame_confidence) # frame confidences have to match alignments
|
||||
assert len(align_elem) > 0 # no empty alignments
|
||||
for idx, pred in enumerate(align_elem):
|
||||
if idx < len(align_elem) - 1:
|
||||
assert pred[1].item() != model.decoder.blank_idx # all except last have to be non-blank
|
||||
else:
|
||||
assert pred[1].item() == model.decoder.blank_idx # last one has to be blank
|
||||
return transcriptions
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_local_folder():
|
||||
"""Overriding global fixture to make sure it's not applied for this test.
|
||||
|
||||
Otherwise, there will be errors in the CI in github.
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
# TODO: add the same tests for multi-blank RNNT decoding
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("loop_labels", [True, False])
|
||||
@pytest.mark.parametrize("use_cuda_graph_decoder", [True, False])
|
||||
@pytest.mark.with_downloads
|
||||
def test_rnnt_alignments(
|
||||
loop_labels: bool,
|
||||
use_cuda_graph_decoder: bool,
|
||||
device: str,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_conformer_transducer_small_model,
|
||||
):
|
||||
if use_cuda_graph_decoder and device != "cuda":
|
||||
pytest.skip("CUDA decoder works only with CUDA")
|
||||
if not loop_labels and use_cuda_graph_decoder:
|
||||
pytest.skip("Frame-Looping algorithm with CUDA graphs does not yet support alignments")
|
||||
# using greedy as baseline and comparing all other configurations to it
|
||||
ref_transcriptions = get_rnnt_alignments(
|
||||
"greedy",
|
||||
manifest_path=an4_val_manifest_corrected,
|
||||
model=stt_en_conformer_transducer_small_model,
|
||||
device=device,
|
||||
)
|
||||
transcriptions = get_rnnt_alignments(
|
||||
"greedy_batch",
|
||||
loop_labels=loop_labels,
|
||||
use_cuda_graph_decoder=use_cuda_graph_decoder,
|
||||
manifest_path=an4_val_manifest_corrected,
|
||||
model=stt_en_conformer_transducer_small_model,
|
||||
device=device,
|
||||
)
|
||||
# comparing that label sequence in alignments is exactly the same
|
||||
# we can't compare logits as well, because they are expected to be
|
||||
# slightly different in batched and single-sample mode
|
||||
assert len(ref_transcriptions) == len(transcriptions)
|
||||
for ref_transcription, transcription in zip(ref_transcriptions, transcriptions):
|
||||
for ref_align_elem, align_elem in zip(ref_transcription.alignments, transcription.alignments):
|
||||
assert len(ref_align_elem) == len(align_elem)
|
||||
for ref_pred, pred in zip(ref_align_elem, align_elem):
|
||||
assert ref_pred[1].item() == pred[1].item()
|
||||
@@ -0,0 +1,661 @@
|
||||
# Copyright (c) 2023, 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 copy
|
||||
import os
|
||||
from functools import cached_property, lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.modules import RNNTDecoder, RNNTJoint
|
||||
from nemo.collections.asr.parts.context_biasing import BoostingTreeModelConfig, GPUBoostingTreeModel
|
||||
from nemo.collections.asr.parts.mixins import mixins
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.asr.parts.submodules import tdt_beam_decoding
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTBPEDecoding, RNNTDecoding, RNNTDecodingConfig
|
||||
from nemo.collections.asr.parts.utils import rnnt_utils
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
from tests.collections.asr.decoding.test_timestamps import BaseTimestampsTest
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
def char_vocabulary():
|
||||
return [' ', 'a', 'b', 'c', 'd', 'e', 'f', '.']
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@lru_cache(maxsize=8)
|
||||
def tmp_tokenizer(test_data_dir):
|
||||
cfg = DictConfig({'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'})
|
||||
|
||||
class _TmpASRBPE(mixins.ASRBPEMixin):
|
||||
def register_artifact(self, _, vocab_path):
|
||||
return vocab_path
|
||||
|
||||
asrbpe = _TmpASRBPE()
|
||||
asrbpe._setup_tokenizer(cfg)
|
||||
return asrbpe.tokenizer
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def get_rnnt_decoder(vocab_size, decoder_output_size=4):
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
torch.manual_seed(0)
|
||||
decoder = RNNTDecoder(prednet=prednet_cfg, vocab_size=vocab_size)
|
||||
decoder.freeze()
|
||||
return decoder
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def get_rnnt_joint(vocab_size, vocabulary=None, encoder_output_size=4, decoder_output_size=4, joint_output_shape=4):
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
torch.manual_seed(0)
|
||||
joint = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=vocabulary)
|
||||
joint.freeze()
|
||||
return joint
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_model_encoder_output(data_dir, model_name):
|
||||
# Import inside function to avoid issues with dependencies
|
||||
import librosa
|
||||
|
||||
audio_filepath = os.path.join(data_dir, 'asr', 'test', 'an4', 'wav', 'cen3-fjlp-b.wav')
|
||||
|
||||
with torch.no_grad():
|
||||
model = ASRModel.from_pretrained(model_name, map_location='cpu') # type: ASRModel
|
||||
model.preprocessor.featurizer.dither = 0.0
|
||||
model.preprocessor.featurizer.pad_to = 0
|
||||
model.eval()
|
||||
|
||||
audio, sr = librosa.load(path=audio_filepath, sr=16000, mono=True)
|
||||
|
||||
input_signal = torch.tensor(audio, dtype=torch.float32).unsqueeze(0)
|
||||
input_signal_length = torch.tensor([len(audio)], dtype=torch.int32)
|
||||
|
||||
encoded, encoded_len = model(input_signal=input_signal, input_signal_length=input_signal_length)
|
||||
|
||||
return model, encoded, encoded_len
|
||||
|
||||
|
||||
def decode_text_from_greedy_hypotheses(hyps, decoding):
|
||||
decoded_hyps = decoding.decode_hypothesis(hyps) # type: List[str]
|
||||
|
||||
return decoded_hyps
|
||||
|
||||
|
||||
def decode_text_from_nbest_hypotheses(hyps, decoding):
|
||||
hypotheses = []
|
||||
all_hypotheses = []
|
||||
|
||||
for nbest_hyp in hyps: # type: rnnt_utils.NBestHypotheses
|
||||
n_hyps = nbest_hyp.n_best_hypotheses # Extract all hypotheses for this sample
|
||||
decoded_hyps = decoding.decode_hypothesis(n_hyps) # type: List[str]
|
||||
|
||||
hypotheses.append(decoded_hyps[0]) # best hypothesis
|
||||
all_hypotheses.append(decoded_hyps)
|
||||
|
||||
return hypotheses, all_hypotheses
|
||||
|
||||
|
||||
def check_beam_decoding(test_data_dir, beam_config):
|
||||
beam_size = beam_config.pop("beam_size", 1)
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, 'nvidia/parakeet-tdt_ctc-110m')
|
||||
|
||||
model_config = model.to_config_dict()
|
||||
durations = list(model_config["model_defaults"]["tdt_durations"])
|
||||
|
||||
beam = tdt_beam_decoding.BeamTDTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
beam_size=beam_size,
|
||||
return_best_hypothesis=False,
|
||||
durations=durations,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
enc_out = encoded
|
||||
enc_len = encoded_len
|
||||
|
||||
with torch.no_grad():
|
||||
hyps: rnnt_utils.Hypothesis = beam(encoder_output=enc_out, encoded_lengths=enc_len)[0]
|
||||
_, all_hyps = decode_text_from_nbest_hypotheses(hyps, model.decoding)
|
||||
all_hyps = all_hyps[0]
|
||||
|
||||
print("Beam search algorithm :", beam_config['search_type'])
|
||||
for idx, hyp_ in enumerate(all_hyps):
|
||||
print("Hyp index", idx + 1, "text :", hyp_.text)
|
||||
|
||||
assert len(hyp_.timestamp) > 0
|
||||
print("Timesteps", hyp_.timestamp)
|
||||
print()
|
||||
|
||||
|
||||
def check_tdt_greedy_decoding(
|
||||
test_data_dir,
|
||||
use_cuda_graph_decoder: bool,
|
||||
lm_path: Optional[str | Path] = None,
|
||||
boosting_tree: Optional[BoostingTreeModelConfig] = None,
|
||||
enable_per_stream_biasing: bool = False,
|
||||
):
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, 'nvidia/parakeet-tdt_ctc-110m')
|
||||
|
||||
model_config = model.to_config_dict()
|
||||
|
||||
fusion_models, fusion_models_alpha = None, None
|
||||
if lm_path or boosting_tree:
|
||||
fusion_models = []
|
||||
fusion_models_alpha = []
|
||||
|
||||
if lm_path:
|
||||
fusion_models.append(NGramGPULanguageModel.from_file(lm_path=lm_path, vocab_size=model.decoder.blank_idx))
|
||||
fusion_models_alpha.append(0.5)
|
||||
if boosting_tree:
|
||||
fusion_models.append(GPUBoostingTreeModel.from_config(boosting_tree, tokenizer=model.tokenizer))
|
||||
fusion_models_alpha.append(0.5)
|
||||
|
||||
decoding_algo = greedy_decode.GreedyBatchedTDTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=model.decoder.blank_idx,
|
||||
durations=list(model_config["model_defaults"]["tdt_durations"]),
|
||||
max_symbols_per_step=10,
|
||||
preserve_alignments=False,
|
||||
preserve_frame_confidence=False,
|
||||
use_cuda_graph_decoder=use_cuda_graph_decoder,
|
||||
fusion_models=fusion_models,
|
||||
fusion_models_alpha=fusion_models_alpha,
|
||||
enable_per_stream_biasing=enable_per_stream_biasing,
|
||||
)
|
||||
|
||||
enc_out = encoded
|
||||
enc_len = encoded_len
|
||||
|
||||
with torch.no_grad():
|
||||
hyps: rnnt_utils.Hypothesis = decoding_algo(encoder_output=enc_out, encoded_lengths=enc_len)[0]
|
||||
all_hyps = decode_text_from_greedy_hypotheses(hyps, model.decoding)
|
||||
|
||||
print("Decoding result")
|
||||
for idx, hyp_ in enumerate(all_hyps):
|
||||
print(f"Hyp index {idx + 1} | text : {hyp_.text}")
|
||||
assert len(hyp_.timestamp) > 0
|
||||
print("Timesteps", hyp_.timestamp)
|
||||
print()
|
||||
|
||||
|
||||
class TestRNNTDecoding:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self):
|
||||
cfg = RNNTDecodingConfig()
|
||||
vocab = char_vocabulary()
|
||||
decoder = get_rnnt_decoder(vocab_size=len(vocab))
|
||||
joint = get_rnnt_joint(vocab_size=len(vocab))
|
||||
decoding = RNNTDecoding(decoding_cfg=cfg, decoder=decoder, joint=joint, vocabulary=vocab)
|
||||
assert decoding is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_subword(self, tmp_tokenizer):
|
||||
cfg = RNNTDecodingConfig()
|
||||
vocab = tmp_tokenizer.vocab
|
||||
decoder = get_rnnt_decoder(vocab_size=len(vocab))
|
||||
joint = get_rnnt_joint(vocab_size=len(vocab))
|
||||
decoding = RNNTBPEDecoding(decoding_cfg=cfg, decoder=decoder, joint=joint, tokenizer=tmp_tokenizer)
|
||||
assert decoding is not None
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
def test_greedy_decoding_preserve_alignments(self, test_data_dir):
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, 'stt_en_conformer_transducer_small')
|
||||
|
||||
beam = greedy_decode.GreedyRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=model.joint.num_classes_with_blank - 1,
|
||||
max_symbols_per_step=5,
|
||||
preserve_alignments=True,
|
||||
)
|
||||
|
||||
enc_out = encoded
|
||||
enc_len = encoded_len
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = beam(encoder_output=enc_out, encoded_lengths=enc_len)[0] # type: rnnt_utils.Hypothesis
|
||||
hyp = decode_text_from_greedy_hypotheses(hyps, model.decoding)
|
||||
hyp = hyp[0]
|
||||
|
||||
assert hyp.alignments is not None
|
||||
|
||||
# Use the following commented print statements to check
|
||||
# the alignment of other algorithms compared to the default
|
||||
print("Text", hyp.text)
|
||||
for t in range(len(hyp.alignments)):
|
||||
t_u = []
|
||||
for u in range(len(hyp.alignments[t])):
|
||||
logp, label = hyp.alignments[t][u]
|
||||
assert torch.is_tensor(logp)
|
||||
assert torch.is_tensor(label)
|
||||
|
||||
t_u.append(int(label))
|
||||
|
||||
print(f"Tokens at timestamp {t} = {t_u}")
|
||||
print()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("loop_labels", [True, False])
|
||||
def test_batched_greedy_decoding_preserve_alignments(self, test_data_dir, loop_labels: bool):
|
||||
"""Test batched greedy decoding using non-batched decoding as a reference"""
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, 'stt_en_conformer_transducer_small')
|
||||
|
||||
search_algo = greedy_decode.GreedyBatchedRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=model.joint.num_classes_with_blank - 1,
|
||||
max_symbols_per_step=5,
|
||||
preserve_alignments=True,
|
||||
loop_labels=loop_labels,
|
||||
)
|
||||
|
||||
etalon_search_algo = greedy_decode.GreedyRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
blank_index=model.joint.num_classes_with_blank - 1,
|
||||
max_symbols_per_step=5,
|
||||
preserve_alignments=True,
|
||||
)
|
||||
|
||||
enc_out = encoded
|
||||
enc_len = encoded_len
|
||||
|
||||
with torch.no_grad():
|
||||
hyps: list[rnnt_utils.Hypothesis] = search_algo(encoder_output=enc_out, encoded_lengths=enc_len)[0]
|
||||
hyp = decode_text_from_greedy_hypotheses(hyps, model.decoding)[0]
|
||||
etalon_hyps: list[rnnt_utils.Hypothesis] = etalon_search_algo(
|
||||
encoder_output=enc_out, encoded_lengths=enc_len
|
||||
)[0]
|
||||
etalon_hyp = decode_text_from_greedy_hypotheses(etalon_hyps, model.decoding)[0]
|
||||
|
||||
assert hyp.alignments is not None
|
||||
assert etalon_hyp.alignments is not None
|
||||
|
||||
assert hyp.text == etalon_hyp.text
|
||||
assert len(hyp.alignments) == len(etalon_hyp.alignments)
|
||||
|
||||
for t in range(len(hyp.alignments)):
|
||||
t_u = []
|
||||
for u in range(len(hyp.alignments[t])):
|
||||
logp, label = hyp.alignments[t][u]
|
||||
assert torch.is_tensor(logp)
|
||||
assert torch.is_tensor(label)
|
||||
etalon_logp, etalon_label = etalon_hyp.alignments[t][u]
|
||||
assert label == etalon_label
|
||||
assert torch.allclose(logp, etalon_logp, atol=1e-4, rtol=1e-4)
|
||||
|
||||
t_u.append(int(label))
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "greedy"},
|
||||
{
|
||||
"search_type": "default",
|
||||
"beam_size": 2,
|
||||
},
|
||||
{
|
||||
"search_type": "alsd",
|
||||
"alsd_max_target_len": 0.5,
|
||||
"beam_size": 2,
|
||||
},
|
||||
{
|
||||
"search_type": "tsd",
|
||||
"tsd_max_sym_exp_per_step": 3,
|
||||
"beam_size": 2,
|
||||
},
|
||||
{"search_type": "maes", "maes_num_steps": 2, "maes_expansion_beta": 2, "beam_size": 2},
|
||||
{"search_type": "maes", "maes_num_steps": 3, "maes_expansion_beta": 1, "beam_size": 2},
|
||||
],
|
||||
)
|
||||
def test_rnnt_beam_decoding_preserve_alignments(self, test_data_dir, beam_config):
|
||||
beam_size = beam_config.pop("beam_size", 1)
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, 'stt_en_conformer_transducer_small')
|
||||
beam = rnnt_beam_decoding.BeamRNNTInfer(
|
||||
model.decoder,
|
||||
model.joint,
|
||||
beam_size=beam_size,
|
||||
return_best_hypothesis=False,
|
||||
preserve_alignments=True,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
enc_out = encoded
|
||||
enc_len = encoded_len
|
||||
blank_id = torch.tensor(model.joint.num_classes_with_blank - 1, dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
hyps = beam(encoder_output=enc_out, encoded_lengths=enc_len)[0] # type: rnnt_utils.Hypothesis
|
||||
hyp, all_hyps = decode_text_from_nbest_hypotheses(hyps, model.decoding)
|
||||
hyp = hyp[0] # best hypothesis
|
||||
all_hyps = all_hyps[0]
|
||||
|
||||
assert hyp.alignments is not None
|
||||
|
||||
if beam_config['search_type'] == 'alsd':
|
||||
assert len(all_hyps) <= int(beam_config['alsd_max_target_len'] * float(enc_len[0]))
|
||||
|
||||
print("Beam search algorithm :", beam_config['search_type'])
|
||||
# Use the following commented print statements to check
|
||||
# the alignment of other algorithms compared to the default
|
||||
for idx, hyp_ in enumerate(all_hyps): # type: (int, rnnt_utils.Hypothesis)
|
||||
print("Hyp index", idx + 1, "text :", hyp_.text)
|
||||
|
||||
# Alignment length (T) must match audio length (T)
|
||||
# NOTE: increase length threshold to two to prevent intermittent failures when a word is split into subwords
|
||||
assert abs(len(hyp_.alignments) - enc_len[0]) <= 2 # 1
|
||||
|
||||
for t in range(len(hyp_.alignments)):
|
||||
t_u = []
|
||||
for u in range(len(hyp_.alignments[t])):
|
||||
logp, label = hyp_.alignments[t][u]
|
||||
assert torch.is_tensor(logp)
|
||||
assert torch.is_tensor(label)
|
||||
|
||||
t_u.append(int(label))
|
||||
|
||||
# Blank token must be the last token in the current
|
||||
if len(t_u) > 1:
|
||||
assert t_u[-1] == blank_id
|
||||
|
||||
# No blank token should be present in the current timestamp other than at the end
|
||||
for token in t_u[:-1]:
|
||||
assert token != blank_id
|
||||
|
||||
print(f"Tokens at timestamp {t} = {t_u}")
|
||||
print()
|
||||
|
||||
assert len(hyp_.timestamp) > 0
|
||||
print("Timesteps", hyp_.timestamp)
|
||||
print()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"model_name, decoding_strategy",
|
||||
[
|
||||
("stt_en_conformer_transducer_small", "greedy"),
|
||||
("stt_en_conformer_transducer_small", "greedy_batch"),
|
||||
("stt_en_conformer_transducer_small", "beam"),
|
||||
# ("stt_en_conformer_transducer_small", "tsd"),
|
||||
("stt_en_conformer_transducer_small", "alsd"),
|
||||
("nvidia/parakeet-tdt_ctc-110m", "greedy"),
|
||||
("nvidia/parakeet-tdt_ctc-110m", "greedy_batch"),
|
||||
],
|
||||
)
|
||||
def test_subword_decoding_compute_timestamps(self, test_data_dir, decoding_strategy, model_name):
|
||||
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, model_name)
|
||||
|
||||
cfg = DictConfig(model.cfg.decoding)
|
||||
cfg['strategy'] = decoding_strategy
|
||||
cfg['preserve_alignments'] = True
|
||||
cfg['compute_timestamps'] = True
|
||||
|
||||
decoding = RNNTBPEDecoding(
|
||||
decoding_cfg=cfg, decoder=model.decoder, joint=model.joint, tokenizer=model.tokenizer
|
||||
)
|
||||
|
||||
hyps = decoding.rnnt_decoder_predictions_tensor(encoded, encoded_len, return_hypotheses=True)
|
||||
if isinstance(hyps[0], list):
|
||||
BaseTimestampsTest.check_subword_timestamps(hyps[0][0], decoding)
|
||||
else:
|
||||
BaseTimestampsTest.check_subword_timestamps(hyps[0], decoding)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"model_name, decoding_strategy",
|
||||
[
|
||||
("stt_en_conformer_transducer_small", "greedy"),
|
||||
("stt_en_conformer_transducer_small", "greedy_batch"),
|
||||
("stt_en_conformer_transducer_small", "beam"),
|
||||
# ("stt_en_conformer_transducer_small", "tsd"),
|
||||
("stt_en_conformer_transducer_small", "alsd"),
|
||||
("nvidia/parakeet-tdt_ctc-110m", "greedy"),
|
||||
("nvidia/parakeet-tdt_ctc-110m", "greedy_batch"),
|
||||
],
|
||||
)
|
||||
def test_char_decoding_compute_timestamps(self, test_data_dir, decoding_strategy, model_name):
|
||||
|
||||
model, encoded, encoded_len = get_model_encoder_output(test_data_dir, model_name)
|
||||
|
||||
cfg = DictConfig(model.cfg.decoding)
|
||||
cfg['strategy'] = decoding_strategy
|
||||
cfg['preserve_alignments'] = True
|
||||
cfg['compute_timestamps'] = True
|
||||
|
||||
vocab = [t[0] for t in model.tokenizer.vocab]
|
||||
|
||||
decoding = RNNTDecoding(decoding_cfg=cfg, decoder=model.decoder, joint=model.joint, vocabulary=vocab)
|
||||
|
||||
hyps = decoding.rnnt_decoder_predictions_tensor(encoded, encoded_len, return_hypotheses=True)
|
||||
|
||||
if isinstance(hyps[0], list):
|
||||
BaseTimestampsTest.check_char_timestamps(hyps[0][0], decoding)
|
||||
else:
|
||||
BaseTimestampsTest.check_char_timestamps(hyps[0], decoding)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("use_cuda_graph_decoder", [True, False])
|
||||
@pytest.mark.parametrize("use_lm", [True, False])
|
||||
@pytest.mark.parametrize("use_boosting_tree", [True, False])
|
||||
@pytest.mark.parametrize("enable_per_stream_biasing", [True, False])
|
||||
def test_tdt_greedy_decoding(
|
||||
self,
|
||||
test_data_dir,
|
||||
use_cuda_graph_decoder: bool,
|
||||
use_lm: bool,
|
||||
use_boosting_tree: bool,
|
||||
enable_per_stream_biasing: bool,
|
||||
):
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
boosting_tree = BoostingTreeModelConfig(key_phrases_list=["hello", "nvidia"]) if use_boosting_tree else None
|
||||
check_tdt_greedy_decoding(
|
||||
test_data_dir,
|
||||
use_cuda_graph_decoder=use_cuda_graph_decoder,
|
||||
lm_path=kenlm_model_path if use_lm else None,
|
||||
boosting_tree=boosting_tree,
|
||||
enable_per_stream_biasing=enable_per_stream_biasing,
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{
|
||||
"search_type": "default",
|
||||
"beam_size": 2,
|
||||
},
|
||||
{"search_type": "maes", "maes_num_steps": 2, "maes_expansion_beta": 2, "beam_size": 2},
|
||||
{"search_type": "maes", "maes_num_steps": 2, "maes_expansion_beta": 1, "beam_size": 4},
|
||||
],
|
||||
)
|
||||
def test_tdt_beam_decoding(self, test_data_dir, beam_config):
|
||||
check_beam_decoding(test_data_dir, beam_config)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{
|
||||
"search_type": "maes",
|
||||
"maes_num_steps": 2,
|
||||
"maes_expansion_beta": 1,
|
||||
"beam_size": 4,
|
||||
"ngram_lm_alpha": 0.3,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_tdt_beam_decoding_with_kenlm(self, test_data_dir, beam_config):
|
||||
# skipping if kenlm is not installed
|
||||
pytest.importorskip("kenlm", reason="Skipping test because 'kenlm' is not installed.")
|
||||
|
||||
kenlm_model_path = os.path.join(
|
||||
test_data_dir, "asr", "kenlm_ngram_lm", "parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
)
|
||||
beam_config["ngram_lm_model"] = kenlm_model_path
|
||||
check_beam_decoding(test_data_dir, beam_config)
|
||||
|
||||
|
||||
class TestRNNTTimestamps(BaseTimestampsTest):
|
||||
"""RNNT-specific timestamp tests that inherit from BaseTimestampsTest"""
|
||||
|
||||
def _convert_offsets(self, offsets):
|
||||
result = copy.deepcopy(offsets)
|
||||
for offset in result:
|
||||
offset['char'] = [offset['char']]
|
||||
return result
|
||||
|
||||
@property
|
||||
def char_offsets_chars(self):
|
||||
return self._convert_offsets(super().char_offsets_chars)
|
||||
|
||||
@property
|
||||
def char_offsets_wpe(self):
|
||||
return self._convert_offsets(super().char_offsets_wpe)
|
||||
|
||||
@property
|
||||
def char_offsets_bpe(self):
|
||||
return self._convert_offsets(super().char_offsets_bpe)
|
||||
|
||||
@property
|
||||
def encoded_char_offsets_bpe(self):
|
||||
return self._convert_offsets(super().encoded_char_offsets_bpe)
|
||||
|
||||
@cached_property
|
||||
def decoding_char(self):
|
||||
cfg = RNNTDecodingConfig()
|
||||
vocab = char_vocabulary()
|
||||
decoder = get_rnnt_decoder(vocab_size=len(vocab))
|
||||
joint = get_rnnt_joint(vocab_size=len(vocab))
|
||||
decoding = RNNTDecoding(decoding_cfg=cfg, decoder=decoder, joint=joint, vocabulary=vocab)
|
||||
return decoding
|
||||
|
||||
@cached_property
|
||||
def decoding_subword_wpe(self):
|
||||
cfg = RNNTDecodingConfig()
|
||||
vocab = self.tmp_tokenizer.vocab
|
||||
decoder = get_rnnt_decoder(vocab_size=len(vocab))
|
||||
joint = get_rnnt_joint(vocab_size=len(vocab))
|
||||
decoding = RNNTBPEDecoding(decoding_cfg=cfg, decoder=decoder, joint=joint, tokenizer=self.tmp_tokenizer)
|
||||
return decoding
|
||||
|
||||
@cached_property
|
||||
def decoding_subword_bpe(self):
|
||||
vocab = self.bpe_tokenizer.vocab
|
||||
cfg = RNNTDecodingConfig()
|
||||
decoder = get_rnnt_decoder(vocab_size=len(vocab))
|
||||
joint = get_rnnt_joint(vocab_size=len(vocab))
|
||||
decoding = RNNTBPEDecoding(decoding_cfg=cfg, decoder=decoder, joint=joint, tokenizer=self.bpe_tokenizer)
|
||||
return decoding
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_offsets_subword_wpe(self, tmp_tokenizer):
|
||||
self.tmp_tokenizer = tmp_tokenizer
|
||||
super().test_word_offsets_subword_wpe()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_offsets_subword_wpe_other_delimiter(self, tmp_tokenizer):
|
||||
self.tmp_tokenizer = tmp_tokenizer
|
||||
super().test_word_offsets_subword_wpe_other_delimiter()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
def test_transcribe_timestamps_no_decoder_reinstantiation(stt_en_fastconformer_transducer_large, test_data_dir):
|
||||
"""
|
||||
Test that calling transcribe with timestamps=True multiple times
|
||||
does not reinstantiate the decoder.
|
||||
|
||||
Regression test for the fix that avoids calling change_decoding_strategy()
|
||||
when compute_timestamps is already set to the desired value.
|
||||
"""
|
||||
model = stt_en_fastconformer_transducer_large
|
||||
audio_file = os.path.join(test_data_dir, "asr/test/an4/wav/cen3-mjwl-b.wav")
|
||||
|
||||
# First call - may change decoding strategy
|
||||
_ = model.transcribe(audio_file, timestamps=True)
|
||||
|
||||
# Get reference to decoding algorithm after first call
|
||||
decoding_after_first_call = model.decoding.decoding
|
||||
|
||||
# Second call - should NOT reinstantiate decoder
|
||||
_ = model.transcribe(audio_file, timestamps=True)
|
||||
|
||||
# Verify decoder is the same object (not reinstantiated)
|
||||
assert model.decoding.decoding is decoding_after_first_call, (
|
||||
"Decoder was reinstantiated on second transcribe call with timestamps=True. "
|
||||
"This indicates change_decoding_strategy() was called unnecessarily."
|
||||
)
|
||||
@@ -0,0 +1,600 @@
|
||||
# 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.
|
||||
|
||||
"""Streaming beam-search (MALSD + MAES) decoding tests.
|
||||
|
||||
Beam-search analogue of ``test_streaming_decoding.py``. Exercises the streaming
|
||||
path of the batched beam-search computers by manually feeding the encoder
|
||||
output to ``model.decoding.decoding.decoding_computer`` in chunks and threading
|
||||
``prev_batched_state`` (a ``BatchedBeamState``):
|
||||
|
||||
- :func:`test_malsd_streaming_batched_state` -- covers RNNT and TDT MALSD
|
||||
(:class:`ModifiedALSDBatchedRNNTComputer`, :class:`ModifiedALSDBatchedTDTComputer`)
|
||||
across the eager path and both captured-graph variants (``full_graph`` and
|
||||
``no_while_loops``).
|
||||
- :func:`test_malsd_streaming_batched_state_with_word_boosting` -- same MALSD matrix
|
||||
but with a ``GPUBoostingTreeModel`` fusion model plugged in (``boosting_tree.
|
||||
key_phrases_list``); exercises cross-chunk restoration of per-beam fusion states
|
||||
in :meth:`_init_decoding_state`.
|
||||
- :func:`test_maes_streaming_batched_state` -- covers RNNT MAES
|
||||
(:class:`ModifiedAESBatchedRNNTComputer`); MAES is RNNT-only and pure-PyTorch,
|
||||
so there is no ``is_tdt`` / ``cuda_graphs_mode`` axis.
|
||||
|
||||
Per-chunk results are merged into a single ``BatchedBeamHyps`` via
|
||||
``flatten_()`` + ``merge_(..., is_chunk_continuation=True,
|
||||
boundary_prev_ptr=...)`` -- the same accumulation pattern used by the
|
||||
cache-aware / chunked streaming inference scripts.
|
||||
|
||||
Streamed transcripts are asserted to be identical to the non-streaming
|
||||
reference produced by ``model.transcribe`` with the same beam settings:
|
||||
beam search with ``prev_batched_state`` is chunk-invariant because all
|
||||
cross-chunk per-beam state (scores, ``last_label``, decoded lengths,
|
||||
decoder + fusion states, ``last_timestamp_lasts``, ...) is preserved across
|
||||
boundaries.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import open_dict
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.parts.context_biasing.biasing_multi_model import BiasingRequestItemConfig
|
||||
from nemo.collections.asr.parts.context_biasing.boosting_graph_batched import BoostingTreeModelConfig
|
||||
from nemo.collections.asr.parts.submodules.transducer_decoding.label_looping_base import BatchedBeamState
|
||||
from nemo.collections.asr.parts.utils.batched_beam_decoding_utils import BatchedBeamHyps
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from tests.collections.asr.decoding.utils import load_audio, make_preprocessor_deterministic
|
||||
|
||||
|
||||
def get_devices_for_testing(use_cpu_always: bool = False) -> list[torch.device]:
|
||||
devices = [torch.device("cpu")] if use_cpu_always else []
|
||||
if torch.cuda.is_available():
|
||||
devices.append(torch.device("cuda:0"))
|
||||
|
||||
if torch.mps.is_available():
|
||||
devices.append(torch.device("mps"))
|
||||
|
||||
if len(devices) == 0:
|
||||
# no fast device for testing, add CPU
|
||||
devices.append(torch.device("cpu"))
|
||||
return devices
|
||||
|
||||
|
||||
DEVICES = get_devices_for_testing(use_cpu_always=True)
|
||||
|
||||
|
||||
def _make_device_param_matrix() -> list:
|
||||
"""
|
||||
Build the ``(device, cuda_graphs_mode)`` parametrize entries with explicit, readable
|
||||
pytest IDs (``cpu-no-graphs``, ``cuda-full-graph``, ``cuda-no-while-loops``, ...) so the
|
||||
test matrix shows which device + graph-mode pair is exercised instead of opaque
|
||||
``device0`` / ``device1`` ids.
|
||||
|
||||
``cuda_graphs_mode`` is ``None`` for the eager path or one of the
|
||||
:class:`ModifiedALSDBatched{RNNT,TDT}Computer.CudaGraphsMode` string values
|
||||
(``"full_graph"`` / ``"no_while_loops"``) for the two captured-graph variants. The test
|
||||
uses ``force_cuda_graphs_mode`` to pin the variant explicitly so each captured path is
|
||||
actually exercised (otherwise ``maybe_enable_cuda_graphs`` would always pick
|
||||
``full_graph`` whenever conditional nodes are supported).
|
||||
|
||||
Coverage:
|
||||
- every device in ``DEVICES`` with ``cuda_graphs_mode=None`` (eager path).
|
||||
- every CUDA device additionally with both ``"full_graph"`` and ``"no_while_loops"``.
|
||||
"""
|
||||
entries: list = []
|
||||
for device in DEVICES:
|
||||
entries.append(pytest.param(device, None, id=f"{device.type}-no-graphs"))
|
||||
for device in DEVICES:
|
||||
if device.type == "cuda":
|
||||
entries.append(pytest.param(device, "full_graph", id=f"{device.type}-full-graph"))
|
||||
entries.append(pytest.param(device, "no_while_loops", id=f"{device.type}-no-while-loops"))
|
||||
return entries
|
||||
|
||||
|
||||
DEVICE_PARAM_MATRIX = _make_device_param_matrix()
|
||||
|
||||
|
||||
def _make_maes_device_param_matrix() -> list:
|
||||
"""Build readable ``device`` parametrize entries for MAES tests.
|
||||
|
||||
MAES has no CUDA-graphs path (it's pure-PyTorch), so the matrix is just one entry per
|
||||
available device with explicit IDs (``cpu``, ``cuda``, ...) instead of opaque
|
||||
``device0`` / ``device1``.
|
||||
"""
|
||||
return [pytest.param(device, id=device.type) for device in DEVICES]
|
||||
|
||||
|
||||
MAES_DEVICE_PARAM_MATRIX = _make_maes_device_param_matrix()
|
||||
|
||||
|
||||
def get_model_encoder_output(
|
||||
test_audio_filenames,
|
||||
num_samples: int,
|
||||
model: ASRModel,
|
||||
device: torch.device = torch.device("cpu"),
|
||||
dtype: torch.dtype = torch.float32,
|
||||
):
|
||||
audio_filepaths = test_audio_filenames[:num_samples]
|
||||
|
||||
with torch.no_grad():
|
||||
make_preprocessor_deterministic(model)
|
||||
model.eval()
|
||||
|
||||
all_inputs, all_lengths = [], []
|
||||
for audio_file in tqdm(audio_filepaths, desc="Loading audio files"):
|
||||
audio_tensor, _ = load_audio(audio_file)
|
||||
all_inputs.append(audio_tensor)
|
||||
all_lengths.append(torch.tensor(audio_tensor.shape[0], dtype=torch.int64))
|
||||
|
||||
input_batch = torch.nn.utils.rnn.pad_sequence(all_inputs, batch_first=True).to(device=device, dtype=dtype)
|
||||
length_batch = torch.tensor(all_lengths, dtype=torch.int64).to(device)
|
||||
|
||||
encoded_outputs, encoded_length = model(input_signal=input_batch, input_signal_length=length_batch)
|
||||
|
||||
return encoded_outputs, encoded_length
|
||||
|
||||
|
||||
def get_batch_encoder_outputs_from_records(records, model, device):
|
||||
"""Helper function to get encoder outputs for a batch of manifest records"""
|
||||
filenames = [record["audio_filepath"] for record in records]
|
||||
local_batch_size = len(filenames)
|
||||
encoder_output, encoder_output_len = get_model_encoder_output(
|
||||
test_audio_filenames=filenames, model=model, num_samples=local_batch_size, device=device
|
||||
)
|
||||
return encoder_output, encoder_output_len
|
||||
|
||||
|
||||
def _configure_malsd_decoding(
|
||||
model: ASRModel,
|
||||
cuda_graphs_mode: Optional[str],
|
||||
beam_size: int,
|
||||
max_symbols: int,
|
||||
key_phrases_list: Optional[list[str]] = None,
|
||||
boosting_tree_alpha: float = 1.0,
|
||||
enable_per_stream_biasing: bool = False,
|
||||
) -> None:
|
||||
"""Switch ``model`` to the ``malsd_batch`` beam-search strategy used by the streaming tests.
|
||||
|
||||
``cuda_graphs_mode`` is the CudaGraphsMode string (``"full_graph"`` or
|
||||
``"no_while_loops"``) when CUDA graphs should be used, or ``None`` for the eager path.
|
||||
When non-None we also call ``force_cuda_graphs_mode`` to pin the variant after the
|
||||
decoding strategy is swapped in -- ``maybe_enable_cuda_graphs`` would otherwise auto-pick
|
||||
``full_graph`` whenever conditional nodes are supported, leaving the ``no_while_loops``
|
||||
branch effectively untested.
|
||||
|
||||
When ``key_phrases_list`` is given, a ``boosting_tree`` fusion model is plugged in
|
||||
(``BoostingTreeModelConfig.key_phrases_list``) so the streaming path exercises
|
||||
cross-chunk fusion-state restoration in :meth:`_init_decoding_state`.
|
||||
"""
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "malsd_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.beam.beam_size = beam_size
|
||||
decoding_cfg.beam.max_symbols = max_symbols
|
||||
decoding_cfg.beam.allow_cuda_graphs = cuda_graphs_mode is not None
|
||||
decoding_cfg.beam.return_best_hypothesis = True
|
||||
decoding_cfg.beam.score_norm = True
|
||||
if key_phrases_list is not None:
|
||||
decoding_cfg.beam.boosting_tree = {"key_phrases_list": list(key_phrases_list)}
|
||||
decoding_cfg.beam.boosting_tree_alpha = boosting_tree_alpha
|
||||
if enable_per_stream_biasing:
|
||||
decoding_cfg.beam.enable_per_stream_biasing = True
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
if cuda_graphs_mode is not None:
|
||||
model.decoding.decoding.decoding_computer.force_cuda_graphs_mode(cuda_graphs_mode)
|
||||
|
||||
|
||||
def _configure_maes_decoding(
|
||||
model: ASRModel,
|
||||
beam_size: int,
|
||||
maes_num_steps: int,
|
||||
maes_expansion_beta: int,
|
||||
maes_expansion_gamma: float,
|
||||
) -> None:
|
||||
"""Switch ``model`` to the ``maes_batch`` beam-search strategy used by the streaming tests.
|
||||
|
||||
MAES is RNNT-only and currently pure-PyTorch (CUDA graphs are not implemented;
|
||||
``allow_cuda_graphs`` is accepted only for API parity with MALSD and is ignored by
|
||||
:class:`ModifiedAESBatchedRNNTComputer`).
|
||||
"""
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "maes_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.beam.beam_size = beam_size
|
||||
decoding_cfg.beam.maes_num_steps = maes_num_steps
|
||||
decoding_cfg.beam.maes_expansion_beta = maes_expansion_beta
|
||||
decoding_cfg.beam.maes_expansion_gamma = maes_expansion_gamma
|
||||
decoding_cfg.beam.allow_cuda_graphs = False
|
||||
decoding_cfg.beam.return_best_hypothesis = True
|
||||
decoding_cfg.beam.score_norm = True
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
|
||||
def _reset_decoding_computer_state(model: ASRModel) -> None:
|
||||
decoding_computer = model.decoding.decoding.decoding_computer
|
||||
if hasattr(decoding_computer, "reset_cuda_graphs_state"):
|
||||
decoding_computer.reset_cuda_graphs_state()
|
||||
|
||||
|
||||
def _decode_malsd_encoder_in_chunks(
|
||||
decoding_computer,
|
||||
encoder_output: torch.Tensor,
|
||||
encoder_output_len: torch.Tensor,
|
||||
chunk_size: int,
|
||||
multi_biasing_ids: Optional[torch.Tensor] = None,
|
||||
) -> BatchedBeamHyps:
|
||||
encoder_output = encoder_output.transpose(1, 2)
|
||||
state: Optional[BatchedBeamState] = None
|
||||
current_batched_hyps: BatchedBeamHyps | None = None
|
||||
decode_kwargs = {}
|
||||
if multi_biasing_ids is not None:
|
||||
decode_kwargs["multi_biasing_ids"] = multi_biasing_ids
|
||||
|
||||
for t in range(0, encoder_output.shape[1], chunk_size):
|
||||
rest_len = encoder_output_len - t
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
current_len = torch.maximum(current_len, torch.zeros_like(current_len))
|
||||
chunk_batched_hyps, state = decoding_computer(
|
||||
x=encoder_output[:, t : t + chunk_size],
|
||||
out_len=current_len,
|
||||
prev_batched_state=state,
|
||||
**decode_kwargs,
|
||||
)
|
||||
chunk_root_ptrs = chunk_batched_hyps.flatten_()
|
||||
if current_batched_hyps is None:
|
||||
current_batched_hyps = chunk_batched_hyps
|
||||
else:
|
||||
current_batched_hyps.merge_(
|
||||
chunk_batched_hyps,
|
||||
is_chunk_continuation=True,
|
||||
boundary_prev_ptr=chunk_root_ptrs,
|
||||
)
|
||||
|
||||
assert current_batched_hyps is not None
|
||||
return current_batched_hyps
|
||||
|
||||
|
||||
def _register_per_stream_biasing(
|
||||
decoding_computer,
|
||||
tokenizer,
|
||||
boost_texts: list[str],
|
||||
device: torch.device,
|
||||
boosting_model_alpha: float = 10.0,
|
||||
) -> tuple[torch.Tensor, list[BiasingRequestItemConfig | None]]:
|
||||
batch_size = len(boost_texts)
|
||||
multi_biasing_ids = torch.full([batch_size], fill_value=-1, dtype=torch.long, device=device)
|
||||
biasing_requests: list[BiasingRequestItemConfig | None] = []
|
||||
|
||||
for batch_idx, boost_text in enumerate(boost_texts):
|
||||
if not boost_text:
|
||||
biasing_requests.append(None)
|
||||
continue
|
||||
request = BiasingRequestItemConfig(
|
||||
boosting_model_cfg=BoostingTreeModelConfig(key_phrases_list=[boost_text], unk_score=-100),
|
||||
boosting_model_alpha=boosting_model_alpha,
|
||||
)
|
||||
request.add_to_multi_model(
|
||||
tokenizer=tokenizer,
|
||||
biasing_multi_model=decoding_computer.biasing_multi_model,
|
||||
)
|
||||
if request.multi_model_id is not None:
|
||||
multi_biasing_ids[batch_idx] = request.multi_model_id
|
||||
biasing_requests.append(request)
|
||||
|
||||
return multi_biasing_ids, biasing_requests
|
||||
|
||||
|
||||
def _unregister_per_stream_biasing(decoding_computer, biasing_requests: list[BiasingRequestItemConfig | None]) -> None:
|
||||
for request in biasing_requests:
|
||||
if request is not None and request.multi_model_id is not None:
|
||||
decoding_computer.biasing_multi_model.remove_model(request.multi_model_id)
|
||||
request.multi_model_id = None
|
||||
|
||||
|
||||
def _run_malsd_streaming_manifest(
|
||||
model: ASRModel,
|
||||
manifest_path,
|
||||
device: torch.device,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
boost_texts: Optional[list[str]] = None,
|
||||
boosting_model_alpha: float = 10.0,
|
||||
) -> list[str]:
|
||||
manifest = read_manifest(manifest_path)
|
||||
decoding_computer = model.decoding.decoding.decoding_computer
|
||||
all_transcripts: list[str] = []
|
||||
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
batch_records = manifest[i : i + batch_size]
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
batch_records, model=model, device=device
|
||||
)
|
||||
|
||||
multi_biasing_ids = None
|
||||
biasing_requests: list[BiasingRequestItemConfig | None] = []
|
||||
if boost_texts is not None:
|
||||
assert decoding_computer.biasing_multi_model is not None
|
||||
batch_boost_texts = boost_texts[i : i + batch_size]
|
||||
multi_biasing_ids, biasing_requests = _register_per_stream_biasing(
|
||||
decoding_computer,
|
||||
model.tokenizer,
|
||||
batch_boost_texts,
|
||||
device,
|
||||
boosting_model_alpha=boosting_model_alpha,
|
||||
)
|
||||
|
||||
batched_hyps = _decode_malsd_encoder_in_chunks(
|
||||
decoding_computer,
|
||||
encoder_output,
|
||||
encoder_output_len,
|
||||
chunk_size,
|
||||
multi_biasing_ids=multi_biasing_ids,
|
||||
)
|
||||
if boost_texts is not None:
|
||||
_unregister_per_stream_biasing(decoding_computer, biasing_requests)
|
||||
|
||||
all_transcripts.extend(
|
||||
model.tokenizer.ids_to_text(hyp.y_sequence.tolist())
|
||||
for hyp in batched_hyps.to_hyps_list(score_norm=True)
|
||||
)
|
||||
|
||||
return all_transcripts
|
||||
|
||||
|
||||
def _run_streaming_batched_state(
|
||||
model: ASRModel,
|
||||
manifest_path,
|
||||
device: torch.device,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Drive the model's beam-search ``decoding_computer`` chunk-by-chunk and return
|
||||
``(ref_transcripts, streaming_transcripts)``.
|
||||
|
||||
Shared between the MALSD and MAES streaming tests: both decoders return a
|
||||
``(BatchedBeamHyps, None, BatchedBeamState)`` triple and accept
|
||||
``prev_batched_state`` for cross-chunk state threading. The per-chunk results are
|
||||
flattened and merged into a single accumulator via ``flatten_()`` +
|
||||
``merge_(..., is_chunk_continuation=True, boundary_prev_ptr=...)`` -- the same
|
||||
accumulation pattern used by the cache-aware / chunked streaming inference scripts.
|
||||
"""
|
||||
manifest = read_manifest(manifest_path)
|
||||
transcriptions = model.transcribe(audio=str(manifest_path.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
all_hyps = []
|
||||
decoding_computer = model.decoding.decoding.decoding_computer
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[i : i + batch_size], model=model, device=device
|
||||
)
|
||||
state: Optional[BatchedBeamState] = None
|
||||
current_batched_hyps: BatchedBeamHyps | None = None
|
||||
encoder_output = encoder_output.transpose(1, 2) # (B, T, D)
|
||||
for t in range(0, encoder_output.shape[1], chunk_size):
|
||||
rest_len = encoder_output_len - t
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
current_len = torch.maximum(current_len, torch.zeros_like(current_len))
|
||||
chunk_batched_hyps, state = decoding_computer(
|
||||
x=encoder_output[:, t : t + chunk_size],
|
||||
out_len=current_len,
|
||||
prev_batched_state=state,
|
||||
)
|
||||
# Flatten this chunk's prefix tree and thread the cross-chunk beam
|
||||
# permutation (``root_ptrs``) into the accumulator so the final
|
||||
# ``flatten_sort_`` walks back through the right beam history.
|
||||
chunk_root_ptrs = chunk_batched_hyps.flatten_()
|
||||
if current_batched_hyps is None:
|
||||
current_batched_hyps = chunk_batched_hyps
|
||||
else:
|
||||
current_batched_hyps.merge_(
|
||||
chunk_batched_hyps,
|
||||
is_chunk_continuation=True,
|
||||
boundary_prev_ptr=chunk_root_ptrs,
|
||||
)
|
||||
assert current_batched_hyps is not None
|
||||
# ``to_hyps_list`` mutates the prefix tree via ``flatten_sort_``, but we're done
|
||||
# with ``current_batched_hyps`` here so an in-place call is fine.
|
||||
all_hyps.extend(current_batched_hyps.to_hyps_list(score_norm=True))
|
||||
|
||||
streaming_transcripts = [model.tokenizer.ids_to_text(hyp.y_sequence.tolist()) for hyp in all_hyps]
|
||||
return ref_transcripts, streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device,cuda_graphs_mode", DEVICE_PARAM_MATRIX)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_malsd_streaming_batched_state(
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
cuda_graphs_mode: Optional[str],
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
beam_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Streaming MALSD decoding with batched beam state passed across chunks."""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
_configure_malsd_decoding(model, cuda_graphs_mode, beam_size=beam_size, max_symbols=max_symbols)
|
||||
|
||||
ref_transcripts, streaming_transcripts = _run_streaming_batched_state(
|
||||
model=model,
|
||||
manifest_path=an4_val_manifest_corrected,
|
||||
device=device,
|
||||
chunk_size=chunk_size,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device", MAES_DEVICE_PARAM_MATRIX)
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("maes_num_steps", [2])
|
||||
@pytest.mark.parametrize("maes_expansion_beta", [2])
|
||||
@pytest.mark.parametrize("maes_expansion_gamma", [2.3])
|
||||
def test_maes_streaming_batched_state(
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
device: torch.device,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
beam_size: int,
|
||||
maes_num_steps: int,
|
||||
maes_expansion_beta: int,
|
||||
maes_expansion_gamma: float,
|
||||
):
|
||||
"""Streaming MAES decoding with batched beam state passed across chunks.
|
||||
|
||||
MAES is RNNT-only and pure-PyTorch (no CUDA graphs path), so the device matrix is
|
||||
just the set of available devices and there is no ``cuda_graphs_mode`` / ``is_tdt``
|
||||
parameter.
|
||||
"""
|
||||
model = stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
_configure_maes_decoding(
|
||||
model,
|
||||
beam_size=beam_size,
|
||||
maes_num_steps=maes_num_steps,
|
||||
maes_expansion_beta=maes_expansion_beta,
|
||||
maes_expansion_gamma=maes_expansion_gamma,
|
||||
)
|
||||
|
||||
ref_transcripts, streaming_transcripts = _run_streaming_batched_state(
|
||||
model=model,
|
||||
manifest_path=an4_val_manifest_corrected,
|
||||
device=device,
|
||||
chunk_size=chunk_size,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
# Phrases chosen from the AN4 val transcripts so the boosting tree is actually exercised
|
||||
# (an empty / unseen list collapses to the no-boosting path and would not test fusion-state
|
||||
# restoration across chunks).
|
||||
_WB_KEY_PHRASES: list[str] = ["nineteen", "forty", "fifty", "repeat", "stop", "yes"]
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device,cuda_graphs_mode", DEVICE_PARAM_MATRIX)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_malsd_streaming_batched_state_with_word_boosting(
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
cuda_graphs_mode: Optional[str],
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
beam_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Streaming MALSD with word-boosting (``boosting_tree``) is chunk-invariant.
|
||||
|
||||
Adds a ``GPUBoostingTreeModel`` fusion model on top of the standard streaming MALSD
|
||||
test. The reference (``model.transcribe``) and the streaming path are configured
|
||||
identically, so the boosting tree's per-beam fusion states must be restored across
|
||||
chunks via ``_init_decoding_state`` for the two transcripts to match.
|
||||
"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
_configure_malsd_decoding(
|
||||
model,
|
||||
cuda_graphs_mode,
|
||||
beam_size=beam_size,
|
||||
max_symbols=max_symbols,
|
||||
key_phrases_list=_WB_KEY_PHRASES,
|
||||
)
|
||||
|
||||
ref_transcripts, streaming_transcripts = _run_streaming_batched_state(
|
||||
model=model,
|
||||
manifest_path=an4_val_manifest_corrected,
|
||||
device=device,
|
||||
chunk_size=chunk_size,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize("device,cuda_graphs_mode", DEVICE_PARAM_MATRIX)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("beam_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_malsd_streaming_boosting_with_ref_transcripts(
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
cuda_graphs_mode: Optional[str],
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
beam_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Metamorphic test analogous to ``test_label_looping_streaming_boosting_with_ref_transcripts``."""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
_configure_malsd_decoding(model, cuda_graphs_mode, beam_size, max_symbols)
|
||||
ref_transcripts = [
|
||||
hyp.text for hyp in model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
]
|
||||
|
||||
_configure_malsd_decoding(model, cuda_graphs_mode, beam_size, max_symbols, enable_per_stream_biasing=True)
|
||||
_reset_decoding_computer_state(model)
|
||||
|
||||
streaming_transcripts = _run_malsd_streaming_manifest(
|
||||
model,
|
||||
an4_val_manifest_corrected,
|
||||
device,
|
||||
chunk_size,
|
||||
batch_size,
|
||||
boost_texts=ref_transcripts,
|
||||
)
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
@@ -0,0 +1,469 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for `StreamingBatchedAudioBuffer` and accompanying helper
|
||||
classes defined in
|
||||
`nemo.collections.asr.parts.utils.streaming_utils`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.utils.streaming_utils import (
|
||||
ContextSize,
|
||||
ContextSizeBatch,
|
||||
DynamicLengthTensor,
|
||||
StreamingBatchedAudioBuffer,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper constants / fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
DEVICES: list[torch.device] = [torch.device("cpu")]
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device("cuda:0"))
|
||||
|
||||
|
||||
def _create_audio_batch(batch_size: int, length: int, device: torch.device, dtype: torch.dtype = torch.float32):
|
||||
"""Create a dummy audio batch of shape (batch_size, length)."""
|
||||
# Use a simple ramp signal to ease debugging.
|
||||
vals = torch.arange(batch_size * length, device=device, dtype=dtype)
|
||||
return vals.view(batch_size, length)
|
||||
|
||||
|
||||
def _make_chunk(batch_size: int, length: int, channels: int, start: float, device: torch.device) -> torch.Tensor:
|
||||
"""Create a deterministic chunk of shape (batch_size, length, channels)."""
|
||||
n = batch_size * length * channels
|
||||
return (start + torch.arange(n, device=device, dtype=torch.float32)).view(batch_size, length, channels)
|
||||
|
||||
|
||||
def _make_ndim_chunk(
|
||||
batch_size: int, length: int, dim_shape: list[int], start: float, device: torch.device
|
||||
) -> torch.Tensor:
|
||||
"""Create a deterministic chunk of shape (batch_size, length, *dim_shape)."""
|
||||
n = batch_size * length * math.prod(dim_shape)
|
||||
return (start + torch.arange(n, device=device, dtype=torch.float32)).view(batch_size, length, *dim_shape)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests for ContextSize and ContextSizeBatch
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextSize:
|
||||
@pytest.mark.unit
|
||||
def test_context_size_total_and_subsample(self):
|
||||
ctx = ContextSize(left=4, chunk=2, right=1)
|
||||
assert ctx.total() == 7
|
||||
|
||||
half_ctx = ctx.subsample(factor=2)
|
||||
assert isinstance(half_ctx, ContextSize)
|
||||
assert half_ctx.left == 2 and half_ctx.chunk == 1 and half_ctx.right == 0
|
||||
assert half_ctx.total() == math.floor(7 / 2)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_context_size_batch_total_and_subsample(self, device: torch.device):
|
||||
left = torch.tensor([4, 4], dtype=torch.long, device=device)
|
||||
chunk = torch.tensor([2, 2], dtype=torch.long, device=device)
|
||||
right = torch.tensor([2, 2], dtype=torch.long, device=device)
|
||||
batch_ctx = ContextSizeBatch(left=left, chunk=chunk, right=right)
|
||||
|
||||
# total() should equal element-wise sum
|
||||
expected_total = left + chunk + right
|
||||
assert torch.equal(batch_ctx.total(), expected_total)
|
||||
|
||||
# After subsampling by 2 each component should be halved (floor division)
|
||||
half_ctx = batch_ctx.subsample(2)
|
||||
assert torch.equal(half_ctx.left, left // 2)
|
||||
assert torch.equal(half_ctx.chunk, chunk // 2)
|
||||
assert torch.equal(half_ctx.right, right // 2)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests for StreamingBatchedAudioBuffer
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingBatchedAudioBuffer:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_streaming_batched_audio_buffer(self, device: torch.device):
|
||||
batch_size = 2
|
||||
expected_ctx = ContextSize(left=4, chunk=2, right=1) # total = 7
|
||||
buffer = StreamingBatchedAudioBuffer(
|
||||
batch_size=batch_size,
|
||||
context_samples=expected_ctx,
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# First add : chunk + right (filling initial buffer)
|
||||
# ------------------------------------------------------------------
|
||||
first_len = expected_ctx.chunk + expected_ctx.right # 3
|
||||
audio_batch = _create_audio_batch(batch_size, first_len, device)
|
||||
audio_lens = torch.full(
|
||||
[
|
||||
batch_size,
|
||||
],
|
||||
first_len,
|
||||
dtype=torch.long,
|
||||
device=device,
|
||||
)
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio_batch,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=False,
|
||||
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
# Validate context sizes
|
||||
assert buffer.context_size.left == 0
|
||||
assert buffer.context_size.chunk == expected_ctx.chunk
|
||||
assert buffer.context_size.right == expected_ctx.right
|
||||
assert buffer.samples.shape[1] == first_len # No truncation yet
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Second add : only chunk length
|
||||
# ------------------------------------------------------------------
|
||||
chunk_len = expected_ctx.chunk # 2
|
||||
audio_batch = _create_audio_batch(batch_size, chunk_len, device)
|
||||
audio_lens.fill_(chunk_len)
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio_batch,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=False,
|
||||
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
# After second add, left should have grown by previous chunk (2)
|
||||
assert buffer.context_size.left == 2
|
||||
assert buffer.context_size.chunk == expected_ctx.chunk
|
||||
assert buffer.context_size.right == expected_ctx.right
|
||||
assert buffer.samples.shape[1] == 5 # 2 (left) + 2 (chunk) + 1 (right)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Third add : another chunk, buffer should now reach full capacity (7)
|
||||
# ------------------------------------------------------------------
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio_batch,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=False,
|
||||
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
assert buffer.samples.shape[1] == expected_ctx.total()
|
||||
assert buffer.context_size.total() == expected_ctx.total()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fourth add : buffer overflows by 2 samples; implementation should
|
||||
# drop the excess from the left context.
|
||||
# ------------------------------------------------------------------
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio_batch,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=False,
|
||||
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
# Buffer length remains constant (total context size)
|
||||
assert buffer.samples.shape[1] == expected_ctx.total()
|
||||
assert buffer.context_size.total() == expected_ctx.total()
|
||||
|
||||
# Left context should have been clipped by 2 samples (from 6 to 4)
|
||||
assert buffer.context_size.left == expected_ctx.left # 4
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Final add : mark last chunk with shorter length; right context
|
||||
# should go to 0 afterwards.
|
||||
# ------------------------------------------------------------------
|
||||
last_len = 1
|
||||
audio_batch = _create_audio_batch(batch_size, last_len, device)
|
||||
audio_lens.fill_(last_len)
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio_batch,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=True,
|
||||
is_last_chunk_batch=torch.ones(batch_size, dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
# After last chunk, right context must be zero and total size preserved
|
||||
assert buffer.context_size.right == 0
|
||||
assert buffer.context_size.total() == expected_ctx.total()
|
||||
assert buffer.samples.shape[1] == expected_ctx.total()
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_streaming_batched_audio_buffer_raises_on_too_long_chunk(self, device: torch.device):
|
||||
"""`add_audio_batch_` should raise if provided chunk is larger than chunk + right."""
|
||||
|
||||
expected_ctx = ContextSize(left=0, chunk=2, right=1)
|
||||
buffer = StreamingBatchedAudioBuffer(
|
||||
batch_size=1,
|
||||
context_samples=expected_ctx,
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Attempt to add a chunk that is too long (4 > 3)
|
||||
too_long_chunk_size = expected_ctx.chunk + expected_ctx.right + 1
|
||||
audio = _create_audio_batch(1, too_long_chunk_size, device)
|
||||
audio_lens = torch.tensor([too_long_chunk_size], dtype=torch.long, device=device)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
buffer.add_audio_batch_(
|
||||
audio_batch=audio,
|
||||
audio_lengths=audio_lens,
|
||||
is_last_chunk=False,
|
||||
is_last_chunk_batch=torch.tensor([False], dtype=torch.bool, device=device),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests for DynamicLengthTensor
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDynamicLengthTensor:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize(
|
||||
"dim_shape, expected_dim_shape",
|
||||
[
|
||||
(None, []),
|
||||
(3, [3]),
|
||||
([4, 5], [4, 5]),
|
||||
],
|
||||
)
|
||||
def test_init(self, device, dim_shape, expected_dim_shape):
|
||||
batch_size, init_length = 2, 5
|
||||
t = DynamicLengthTensor(
|
||||
batch_size=batch_size,
|
||||
init_length=init_length,
|
||||
dim_shape=dim_shape,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
assert t.dim_shape == expected_dim_shape
|
||||
assert list(t.data.shape) == [batch_size, init_length, *expected_dim_shape]
|
||||
assert list(t.lengths.shape) == [batch_size]
|
||||
assert t.lengths.dtype == torch.long
|
||||
assert t.data.dtype == torch.float32
|
||||
# Freshly created storage is zeroed and reports no content.
|
||||
assert torch.count_nonzero(t.lengths) == 0
|
||||
assert torch.count_nonzero(t.data) == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_init_minimum_length(self):
|
||||
"""`init_length` is clamped to at least 1 so doubling-based growth works."""
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=0, dim_shape=1)
|
||||
assert t._max_length == 1
|
||||
assert t.data.shape[1] == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_append_with_lengths(self, device):
|
||||
"""Per-batch `lengths` control how many frames become valid for each item."""
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=1, device=device, dtype=torch.float32)
|
||||
|
||||
# First chunk: batch item 0 keeps 2 frames, item 1 keeps 1 frame.
|
||||
chunk1 = _make_chunk(batch_size=2, length=2, channels=1, start=10.0, device=device)
|
||||
# chunk1 == [[[10], [11]], [[12], [13]]]
|
||||
t.append_(data=chunk1, lengths=torch.tensor([2, 1], device=device))
|
||||
assert t.lengths.tolist() == [2, 1]
|
||||
|
||||
# Second chunk: item 0 keeps 1 frame, item 1 keeps 2 frames. Item 1 should
|
||||
# overwrite the previously written "garbage" frame at position 1.
|
||||
chunk2 = _make_chunk(batch_size=2, length=2, channels=1, start=30.0, device=device)
|
||||
# chunk2 == [[[30], [31]], [[32], [33]]]
|
||||
t.append_(data=chunk2, lengths=torch.tensor([1, 2], device=device))
|
||||
assert t.lengths.tolist() == [3, 3]
|
||||
|
||||
# Valid frames are everything up to the per-item length.
|
||||
item0 = t.data[0, : t.lengths[0], 0].tolist()
|
||||
item1 = t.data[1, : t.lengths[1], 0].tolist()
|
||||
assert item0 == [10.0, 11.0, 30.0]
|
||||
assert item1 == [12.0, 32.0, 33.0]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_append_without_lengths(self, device):
|
||||
"""Without `lengths`, every frame in the chunk is appended for all items."""
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
|
||||
|
||||
chunk = _make_chunk(batch_size=2, length=3, channels=1, start=0.0, device=device)
|
||||
t.append_(data=chunk)
|
||||
|
||||
assert t.lengths.tolist() == [3, 3]
|
||||
assert torch.equal(t.data[:, :3], chunk)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("dim_shape", [[], [3], [2, 3], [2, 3, 4]])
|
||||
def test_append_without_lengths_multidim(self, device, dim_shape):
|
||||
"""Append must scatter the whole feature vector for arbitrary trailing `dim_shape`."""
|
||||
batch_size = 2
|
||||
# init_length < appended length so this also exercises the growth path with multi-dim shapes.
|
||||
t = DynamicLengthTensor(
|
||||
batch_size=batch_size, init_length=2, dim_shape=dim_shape, device=device, dtype=torch.float32
|
||||
)
|
||||
|
||||
chunk = _make_ndim_chunk(batch_size, length=3, dim_shape=dim_shape, start=0.0, device=device)
|
||||
t.append_(data=chunk)
|
||||
|
||||
assert t.lengths.tolist() == [3, 3]
|
||||
assert torch.equal(t.data[:, :3], chunk)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_append_with_lengths_multidim(self, device):
|
||||
"""Per-batch `lengths` must place the full multi-dim feature vectors at the right offsets."""
|
||||
dim_shape = [2, 3]
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=dim_shape, device=device, dtype=torch.float32)
|
||||
|
||||
# First chunk: item 0 keeps 2 frames, item 1 keeps 1 frame.
|
||||
chunk1 = _make_ndim_chunk(2, length=2, dim_shape=dim_shape, start=10.0, device=device)
|
||||
t.append_(data=chunk1, lengths=torch.tensor([2, 1], device=device))
|
||||
assert t.lengths.tolist() == [2, 1]
|
||||
|
||||
# Second chunk: item 0 keeps 1 frame, item 1 keeps 2 frames. Item 1's second frame
|
||||
# must overwrite the previously written "garbage" frame at position 1.
|
||||
chunk2 = _make_ndim_chunk(2, length=2, dim_shape=dim_shape, start=100.0, device=device)
|
||||
t.append_(data=chunk2, lengths=torch.tensor([1, 2], device=device))
|
||||
assert t.lengths.tolist() == [3, 3]
|
||||
|
||||
# Item 0: chunk1[0, 0], chunk1[0, 1], chunk2[0, 0]; each is a full (2, 3) feature vector.
|
||||
assert torch.equal(t.data[0, 0], chunk1[0, 0])
|
||||
assert torch.equal(t.data[0, 1], chunk1[0, 1])
|
||||
assert torch.equal(t.data[0, 2], chunk2[0, 0])
|
||||
# Item 1: chunk1[1, 0], chunk2[1, 0] (overwrites garbage at pos 1), chunk2[1, 1].
|
||||
assert torch.equal(t.data[1, 0], chunk1[1, 0])
|
||||
assert torch.equal(t.data[1, 1], chunk2[1, 0])
|
||||
assert torch.equal(t.data[1, 2], chunk2[1, 1])
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_growth_preserves_data(self, device):
|
||||
"""Appending more than the initial capacity reallocates and keeps content."""
|
||||
t = DynamicLengthTensor(batch_size=1, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
|
||||
initial_capacity = t._max_length
|
||||
|
||||
big_len = 10
|
||||
chunk = _make_chunk(batch_size=1, length=big_len, channels=1, start=0.0, device=device)
|
||||
t.append_(data=chunk, lengths=torch.tensor([big_len], device=device))
|
||||
|
||||
assert t._max_length > initial_capacity
|
||||
assert t._max_length >= big_len
|
||||
assert t.lengths.tolist() == [big_len]
|
||||
assert t.data[0, :big_len, 0].tolist() == list(range(big_len))
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_incremental_appends_double_capacity(self, device):
|
||||
"""Repeated single-frame appends grow capacity geometrically (amortized O(1))."""
|
||||
n_appends = 9
|
||||
t = DynamicLengthTensor(batch_size=1, init_length=1, dim_shape=1, device=device, dtype=torch.float32)
|
||||
|
||||
capacities = []
|
||||
for i in range(n_appends):
|
||||
frame = torch.full((1, 1, 1), float(i), device=device)
|
||||
t.append_(data=frame)
|
||||
capacities.append(t._max_length)
|
||||
|
||||
# Everything that was appended is retained, in order.
|
||||
assert t.lengths.tolist() == [n_appends]
|
||||
assert t.data[0, :n_appends, 0].tolist() == [float(i) for i in range(n_appends)]
|
||||
# Capacity is always at least what is stored, and grew far less than linearly.
|
||||
assert t._max_length >= n_appends
|
||||
assert len(set(capacities)) < n_appends # capacity reused across appends, not bumped every time
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_clear(self, device):
|
||||
"""`clear_` resets both lengths and storage to zero while keeping capacity."""
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=1, device=device, dtype=torch.float32)
|
||||
t.append_(data=_make_chunk(2, 3, 1, start=1.0, device=device), lengths=torch.tensor([3, 3], device=device))
|
||||
|
||||
capacity_before = t._max_length
|
||||
t.clear_()
|
||||
|
||||
assert t.lengths.tolist() == [0, 0]
|
||||
assert torch.count_nonzero(t.data) == 0
|
||||
assert t._max_length == capacity_before
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_merge(self, device):
|
||||
"""`merge_` concatenates another tensor's content along the length dim."""
|
||||
a = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
|
||||
a.append_(data=_make_chunk(2, 2, 1, start=1.0, device=device), lengths=torch.tensor([2, 2], device=device))
|
||||
|
||||
b = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
|
||||
b.append_(data=_make_chunk(2, 2, 1, start=100.0, device=device), lengths=torch.tensor([1, 2], device=device))
|
||||
|
||||
a_item0_before = a.data[0, : a.lengths[0], 0].tolist()
|
||||
a_item1_before = a.data[1, : a.lengths[1], 0].tolist()
|
||||
b_item0 = b.data[0, : b.lengths[0], 0].tolist()
|
||||
b_item1 = b.data[1, : b.lengths[1], 0].tolist()
|
||||
|
||||
ret = a.merge_(b)
|
||||
assert ret is a # in-place, returns self
|
||||
assert a.lengths.tolist() == [3, 4]
|
||||
assert a.data[0, : a.lengths[0], 0].tolist() == a_item0_before + b_item0
|
||||
assert a.data[1, : a.lengths[1], 0].tolist() == a_item1_before + b_item1
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_clone_is_independent(self, device):
|
||||
"""`clone` returns a deep copy: same data/lengths, but independent storage."""
|
||||
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=3, device=device, dtype=torch.float32)
|
||||
t.append_(data=_make_chunk(2, 2, 3, start=1.0, device=device), lengths=torch.tensor([2, 1], device=device))
|
||||
|
||||
clone = t.clone()
|
||||
assert clone is not t
|
||||
assert clone.dim_shape == t.dim_shape
|
||||
assert clone.data.shape == t.data.shape
|
||||
assert torch.equal(clone.lengths, t.lengths)
|
||||
assert torch.equal(clone.data, t.data)
|
||||
|
||||
# Mutating the clone must not affect the original.
|
||||
clone.append_(
|
||||
data=_make_chunk(2, 1, 3, start=50.0, device=device), lengths=torch.tensor([1, 1], device=device)
|
||||
)
|
||||
assert clone.lengths.tolist() == [3, 2]
|
||||
assert t.lengths.tolist() == [2, 1]
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA to verify cross-device move")
|
||||
def test_to_device(self):
|
||||
"""`to_device` moves the underlying storage (not just the bookkeeping attr)."""
|
||||
t = DynamicLengthTensor(
|
||||
batch_size=2, init_length=4, dim_shape=1, device=torch.device("cpu"), dtype=torch.float32
|
||||
)
|
||||
t.append_(data=_make_chunk(2, 2, 1, start=1.0, device=torch.device("cpu")), lengths=torch.tensor([2, 2]))
|
||||
|
||||
ret = t.to_device("cuda:0")
|
||||
assert ret is t
|
||||
assert t.device == "cuda:0"
|
||||
assert t.data.device.type == "cuda"
|
||||
assert t.lengths.device.type == "cuda"
|
||||
# Content survives the move.
|
||||
assert t.data[0, :2, 0].tolist() == [1.0, 2.0]
|
||||
@@ -0,0 +1,608 @@
|
||||
# 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 copy
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from omegaconf import open_dict
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.parts.context_biasing.biasing_multi_model import BiasingRequestItemConfig
|
||||
from nemo.collections.asr.parts.context_biasing.boosting_graph_batched import BoostingTreeModelConfig
|
||||
from nemo.collections.asr.parts.submodules.transducer_decoding.label_looping_base import (
|
||||
BatchedLabelLoopingState,
|
||||
GreedyBatchedLabelLoopingComputerBase,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import BatchedHyps, Hypothesis, batched_hyps_to_hypotheses
|
||||
from tests.collections.asr.decoding.utils import load_audio, make_preprocessor_deterministic
|
||||
|
||||
|
||||
def get_devices_for_testing(use_cpu_always: bool = False) -> list[torch.device]:
|
||||
devices = [torch.device("cpu")] if use_cpu_always else []
|
||||
if torch.cuda.is_available():
|
||||
devices.append(torch.device("cuda:0"))
|
||||
|
||||
if torch.mps.is_available():
|
||||
devices.append(torch.device("mps"))
|
||||
|
||||
if len(devices) == 0:
|
||||
# no fast device for testing, add CPU
|
||||
devices.append(torch.device("cpu"))
|
||||
return devices
|
||||
|
||||
|
||||
DEVICES = get_devices_for_testing(use_cpu_always=False)
|
||||
|
||||
|
||||
def get_model_encoder_output(
|
||||
test_audio_filenames,
|
||||
num_samples: int,
|
||||
model: ASRModel,
|
||||
device: torch.device = torch.device("cpu"),
|
||||
dtype: torch.dtype = torch.float32,
|
||||
):
|
||||
audio_filepaths = test_audio_filenames[:num_samples]
|
||||
|
||||
with torch.no_grad():
|
||||
make_preprocessor_deterministic(model)
|
||||
model.eval()
|
||||
|
||||
all_inputs, all_lengths = [], []
|
||||
for audio_file in tqdm(audio_filepaths, desc="Loading audio files"):
|
||||
audio_tensor, _ = load_audio(audio_file)
|
||||
all_inputs.append(audio_tensor)
|
||||
all_lengths.append(torch.tensor(audio_tensor.shape[0], dtype=torch.int64))
|
||||
|
||||
input_batch = torch.nn.utils.rnn.pad_sequence(all_inputs, batch_first=True).to(device=device, dtype=dtype)
|
||||
length_batch = torch.tensor(all_lengths, dtype=torch.int64).to(device)
|
||||
|
||||
encoded_outputs, encoded_length = model(input_signal=input_batch, input_signal_length=length_batch)
|
||||
|
||||
return encoded_outputs, encoded_length
|
||||
|
||||
|
||||
def get_batch_encoder_outputs_from_records(records, model, device):
|
||||
"""Helper function to get encoder outputs for a batch of manifest records"""
|
||||
filenames = [record["audio_filepath"] for record in records]
|
||||
local_batch_size = len(filenames)
|
||||
encoder_output, encoder_output_len = get_model_encoder_output(
|
||||
test_audio_filenames=filenames, model=model, num_samples=local_batch_size, device=device
|
||||
)
|
||||
return encoder_output, encoder_output_len
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_label_looping_streaming_batched_state(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Test streaming decoding with batched state"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg.greedy.max_symbols = max_symbols
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
transcriptions = model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
all_hyps = []
|
||||
decoding_computer: GreedyBatchedLabelLoopingComputerBase = model.decoding.decoding.decoding_computer
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[i : i + batch_size], model=model, device=device
|
||||
)
|
||||
local_batch_size = encoder_output_len.shape[0]
|
||||
# decode encoder output by chunks, passing state between decoder invocations
|
||||
state: Optional[BatchedLabelLoopingState] = None
|
||||
batched_hyps: BatchedHyps | None = None
|
||||
encoder_output = encoder_output.transpose(1, 2)
|
||||
for t in range(0, encoder_output.shape[1], chunk_size):
|
||||
rest_len = encoder_output_len - t
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
current_len = torch.maximum(current_len, torch.zeros_like(current_len))
|
||||
batched_hyps_chunk, state = decoding_computer(
|
||||
x=encoder_output[:, t : t + chunk_size],
|
||||
out_len=current_len,
|
||||
prev_batched_state=state,
|
||||
)
|
||||
if batched_hyps is None:
|
||||
batched_hyps = batched_hyps_chunk
|
||||
else:
|
||||
batched_hyps.merge_(batched_hyps_chunk)
|
||||
assert batched_hyps is not None
|
||||
all_hyps.extend(batched_hyps_to_hypotheses(batched_hyps, batch_size=local_batch_size))
|
||||
|
||||
streaming_transcripts = []
|
||||
for hyp in all_hyps:
|
||||
streaming_transcripts.append(model.tokenizer.ids_to_text(hyp.y_sequence.tolist()))
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_label_looping_streaming_partial_hypotheses(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Test streaming decoding with partial hypotheses"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg.greedy.max_symbols = max_symbols
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
transcriptions = model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
all_hyps = []
|
||||
rnnt_infer = model.decoding.decoding
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[i : i + batch_size], model=model, device=device
|
||||
)
|
||||
# decode encoder output by chunks, passing state between decoder invocations
|
||||
hyps: list[Hypothesis] | None = None
|
||||
for t in range(0, encoder_output.shape[2], chunk_size):
|
||||
rest_len = encoder_output_len - t
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
current_len = torch.maximum(current_len, torch.zeros_like(current_len))
|
||||
(hyps,) = rnnt_infer(
|
||||
encoder_output=encoder_output[:, :, t : t + chunk_size],
|
||||
encoded_lengths=current_len,
|
||||
partial_hypotheses=hyps,
|
||||
)
|
||||
# free up memory by resetting decoding state
|
||||
for hyp in hyps:
|
||||
hyp.clean_decoding_state_()
|
||||
all_hyps.extend(hyps)
|
||||
streaming_transcripts = []
|
||||
for hyp in all_hyps:
|
||||
streaming_transcripts.append(model.tokenizer.ids_to_text(hyp.y_sequence.tolist()))
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_label_looping_continuous_streaming_batched_state(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Test streaming continuos decoding with partial hypotheses"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg.greedy.max_symbols = max_symbols
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
transcriptions = model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
all_hyps = [None for _ in range(len(manifest))]
|
||||
decoding_computer: GreedyBatchedLabelLoopingComputerBase = model.decoding.decoding.decoding_computer
|
||||
assert batch_size < len(
|
||||
manifest
|
||||
), "Batch size should be less than the number of records in the manifest for continuous streaming test."
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
# get first 2 batches
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[:batch_size], model=model, device=device
|
||||
)
|
||||
encoder_output_next, encoder_output_len_next = get_batch_encoder_outputs_from_records(
|
||||
manifest[batch_size : batch_size + batch_size], model=model, device=device
|
||||
)
|
||||
# we always work with encoder_output, getting next utterances from encoder_output_next
|
||||
# so we need to pad encoder_output if it is shorter than encoder_output_next
|
||||
if encoder_output.shape[2] < encoder_output_next.shape[2]:
|
||||
encoder_output = F.pad(encoder_output, (0, encoder_output_next.shape[2] - encoder_output.shape[2]))
|
||||
expanded_batch_indices = torch.arange(batch_size, device=device).unsqueeze(1).expand(-1, chunk_size)
|
||||
next_batch_i = 0
|
||||
next_batch_global_i = batch_size
|
||||
next_query_utterance_i = batch_size + batch_size
|
||||
has_next = True # if we have anything in next batch to decode
|
||||
hyps: list[Hypothesis | None] = [None for _ in range(batch_size)]
|
||||
hyps_global_indices = list(range(batch_size))
|
||||
encoder_output_t = torch.zeros_like(encoder_output_len)
|
||||
state = None # decoding state
|
||||
# while there is something to decode
|
||||
while ((rest_len := encoder_output_len - encoder_output_t) > 0).any():
|
||||
frame_indices = encoder_output_t[:, None] + torch.arange(chunk_size, device=device)[None, :]
|
||||
frame_indices = torch.minimum(frame_indices, encoder_output_len[:, None] - 1)
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
encoder_frames = encoder_output[expanded_batch_indices, :, frame_indices]
|
||||
batched_hyps, state = decoding_computer(
|
||||
x=encoder_frames,
|
||||
out_len=current_len,
|
||||
prev_batched_state=state,
|
||||
)
|
||||
hyps_continuations = batched_hyps_to_hypotheses(batched_hyps, batch_size=batch_size)
|
||||
for i, (hyp, hyp_continuation) in enumerate(zip(hyps, hyps_continuations)):
|
||||
if hyp is None:
|
||||
hyps[i] = hyp_continuation
|
||||
else:
|
||||
hyp.merge_(hyp_continuation)
|
||||
encoder_output_t += current_len
|
||||
rest_len -= current_len
|
||||
|
||||
decoding_computer.reset_state_by_mask(state, rest_len == 0)
|
||||
finished_decoding_indices = torch.nonzero(rest_len == 0, as_tuple=True)[0].cpu().tolist()
|
||||
for idx in finished_decoding_indices:
|
||||
hyp = hyps[idx]
|
||||
if all_hyps[hyps_global_indices[idx]] is None:
|
||||
all_hyps[hyps_global_indices[idx]] = hyp
|
||||
hyps[idx] = None # reset to None
|
||||
if has_next:
|
||||
# get next utterance to decode for finished hypothesis
|
||||
encoder_output[idx] = encoder_output_next[next_batch_i]
|
||||
encoder_output_len[idx] = encoder_output_len_next[next_batch_i]
|
||||
hyps_global_indices[idx] = next_batch_global_i
|
||||
encoder_output_t[idx] = 0
|
||||
next_batch_i += 1
|
||||
next_batch_global_i += 1
|
||||
# if next_batch_i is out of bounds, get next batch of encoder outputs
|
||||
if next_batch_i >= encoder_output_len_next.shape[0]:
|
||||
if next_query_utterance_i < len(manifest):
|
||||
encoder_output_next, encoder_output_len_next = get_batch_encoder_outputs_from_records(
|
||||
manifest[next_query_utterance_i : next_query_utterance_i + batch_size],
|
||||
model=model,
|
||||
device=device,
|
||||
)
|
||||
# pad if needed to allow futher assignment of encoder_output_next to encoder_output
|
||||
if encoder_output.shape[2] < encoder_output_next.shape[2]:
|
||||
encoder_output = F.pad(
|
||||
encoder_output, (0, encoder_output_next.shape[2] - encoder_output.shape[2])
|
||||
)
|
||||
next_batch_i = 0
|
||||
next_query_utterance_i += batch_size
|
||||
else:
|
||||
has_next = False
|
||||
|
||||
streaming_transcripts = []
|
||||
for hyp in all_hyps:
|
||||
streaming_transcripts.append(model.tokenizer.ids_to_text(hyp.y_sequence.tolist()))
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 3])
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_label_looping_continuous_streaming_partial_hypotheses(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""Test streaming continuos decoding with partial hypotheses"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.to(device=device)
|
||||
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg.greedy.max_symbols = max_symbols
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
transcriptions = model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
all_hyps = [None for _ in range(len(manifest))]
|
||||
rnnt_infer = model.decoding.decoding
|
||||
assert batch_size < len(
|
||||
manifest
|
||||
), "Batch size should be less than the number of records in the manifest for continuous streaming test."
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
# get first 2 batches
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
manifest[:batch_size], model=model, device=device
|
||||
)
|
||||
encoder_output_next, encoder_output_len_next = get_batch_encoder_outputs_from_records(
|
||||
manifest[batch_size : batch_size + batch_size], model=model, device=device
|
||||
)
|
||||
# we always work with encoder_output, getting next utterances from encoder_output_next
|
||||
# so we need to pad encoder_output if it is shorter than encoder_output_next
|
||||
if encoder_output.shape[2] < encoder_output_next.shape[2]:
|
||||
encoder_output = F.pad(encoder_output, (0, encoder_output_next.shape[2] - encoder_output.shape[2]))
|
||||
expanded_batch_indices = torch.arange(batch_size, device=device).unsqueeze(1).expand(-1, chunk_size)
|
||||
# NB: we assume that encoder_output_len and encoder_output_len_next
|
||||
# have no zero elements (no empty utterances), and we do not check this condition further
|
||||
next_batch_i = 0
|
||||
next_batch_global_i = batch_size
|
||||
next_query_utterance_i = batch_size + batch_size
|
||||
has_next = True # if we have anything in next batch to decode
|
||||
hyps: list[Hypothesis | None] = [None for _ in range(batch_size)]
|
||||
hyps_global_indices = list(range(batch_size))
|
||||
encoder_output_t = torch.zeros_like(encoder_output_len)
|
||||
# while there is something to decode
|
||||
while ((rest_len := encoder_output_len - encoder_output_t) > 0).any():
|
||||
frame_indices = encoder_output_t[:, None] + torch.arange(chunk_size, device=device)[None, :]
|
||||
frame_indices = torch.minimum(frame_indices, encoder_output_len[:, None] - 1)
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
encoder_frames = encoder_output[expanded_batch_indices, :, frame_indices].transpose(1, 2)
|
||||
(hyps,) = rnnt_infer(
|
||||
encoder_output=encoder_frames,
|
||||
encoded_lengths=current_len,
|
||||
partial_hypotheses=hyps,
|
||||
)
|
||||
encoder_output_t += current_len
|
||||
rest_len -= current_len
|
||||
finished_decoding_indices = torch.nonzero(rest_len == 0, as_tuple=True)[0].cpu().tolist()
|
||||
for idx in finished_decoding_indices:
|
||||
hyp = hyps[idx]
|
||||
all_hyps[hyps_global_indices[idx]] = hyp
|
||||
# NB: we clean decoding state and set hyp to None only if we have next utterances to decode
|
||||
# otherwise for each decoder invocation with 0 length it will recreate the hypothesis object,
|
||||
# which is computationally expensive
|
||||
# decoding current hyp with 0 length will not change the hypothesis
|
||||
if has_next:
|
||||
hyp.clean_decoding_state_()
|
||||
hyps[idx] = None # reset to None
|
||||
# get next utterance to decode for finished hypothesis
|
||||
encoder_output[idx] = encoder_output_next[next_batch_i]
|
||||
encoder_output_len[idx] = encoder_output_len_next[next_batch_i]
|
||||
hyps_global_indices[idx] = next_batch_global_i
|
||||
encoder_output_t[idx] = 0
|
||||
next_batch_i += 1
|
||||
next_batch_global_i += 1
|
||||
# if next_batch_i is out of bounds, get next batch of encoder outputs
|
||||
if next_batch_i >= encoder_output_len_next.shape[0]:
|
||||
if next_query_utterance_i < len(manifest):
|
||||
encoder_output_next, encoder_output_len_next = get_batch_encoder_outputs_from_records(
|
||||
manifest[next_query_utterance_i : next_query_utterance_i + batch_size],
|
||||
model=model,
|
||||
device=device,
|
||||
)
|
||||
# pad if needed to allow futher assignment of encoder_output_next to encoder_output
|
||||
if encoder_output.shape[2] < encoder_output_next.shape[2]:
|
||||
encoder_output = F.pad(
|
||||
encoder_output, (0, encoder_output_next.shape[2] - encoder_output.shape[2])
|
||||
)
|
||||
next_batch_i = 0
|
||||
next_query_utterance_i += batch_size
|
||||
else:
|
||||
has_next = False
|
||||
for hyp in hyps:
|
||||
if hyp is not None:
|
||||
hyp.clean_decoding_state_()
|
||||
|
||||
streaming_transcripts = []
|
||||
for hyp in all_hyps:
|
||||
streaming_transcripts.append(model.tokenizer.ids_to_text(hyp.y_sequence.tolist()))
|
||||
assert ref_transcripts == streaming_transcripts
|
||||
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize(
|
||||
"device,use_cuda_graph_decoder",
|
||||
[(device, False) for device in DEVICES] + [(device, True) for device in DEVICES if device.type == "cuda"],
|
||||
)
|
||||
@pytest.mark.parametrize("is_tdt", [False, True])
|
||||
@pytest.mark.parametrize("chunk_size", [1]) # Small chunk size to trigger more state updates
|
||||
@pytest.mark.parametrize("batch_size", [4])
|
||||
@pytest.mark.parametrize("max_symbols", [10])
|
||||
def test_label_looping_streaming_boosting_with_ref_transcripts(
|
||||
tmp_path_factory,
|
||||
an4_val_manifest_corrected,
|
||||
stt_en_fastconformer_transducer_large,
|
||||
stt_en_fastconformer_tdt_large,
|
||||
device: torch.device,
|
||||
use_cuda_graph_decoder: bool,
|
||||
is_tdt: bool,
|
||||
chunk_size: int,
|
||||
batch_size: int,
|
||||
max_symbols: int,
|
||||
):
|
||||
"""
|
||||
Metamorphic test: boosting with reference transcripts should yield identical results.
|
||||
|
||||
This test validates that when we boost with the exact transcripts that the model
|
||||
would produce without boosting, the results remain the same. This is a metamorphic
|
||||
property that should hold for correct implementations.
|
||||
|
||||
This test specifically validates the fix for TDT streaming boosting where
|
||||
fusion states were incorrectly updated using `active_mask` instead of `found_labels_mask`.
|
||||
"""
|
||||
model = stt_en_fastconformer_tdt_large if is_tdt else stt_en_fastconformer_transducer_large
|
||||
model.eval()
|
||||
model.to(device=device)
|
||||
|
||||
# First, get reference transcripts without boosting
|
||||
decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg.greedy.max_symbols = max_symbols
|
||||
model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
manifest = read_manifest(an4_val_manifest_corrected)
|
||||
transcriptions = model.transcribe(audio=str(an4_val_manifest_corrected.absolute()), batch_size=batch_size)
|
||||
ref_transcripts = [hyp.text for hyp in transcriptions]
|
||||
|
||||
# Now set up per-stream boosting with reference transcripts
|
||||
decoding_cfg_boosted = copy.deepcopy(model.cfg.decoding)
|
||||
decoding_cfg_boosted.strategy = "greedy_batch"
|
||||
with open_dict(decoding_cfg_boosted):
|
||||
decoding_cfg_boosted.greedy.use_cuda_graph_decoder = use_cuda_graph_decoder
|
||||
decoding_cfg_boosted.greedy.max_symbols = max_symbols
|
||||
decoding_cfg_boosted.greedy.enable_per_stream_biasing = True
|
||||
model.change_decoding_strategy(decoding_cfg_boosted)
|
||||
|
||||
all_hyps = []
|
||||
decoding_computer: GreedyBatchedLabelLoopingComputerBase = model.decoding.decoding.decoding_computer
|
||||
|
||||
with torch.no_grad(), torch.inference_mode():
|
||||
for i in range(0, len(manifest), batch_size):
|
||||
batch_records = manifest[i : i + batch_size]
|
||||
batch_ref_transcripts = ref_transcripts[i : i + batch_size]
|
||||
|
||||
encoder_output, encoder_output_len = get_batch_encoder_outputs_from_records(
|
||||
batch_records, model=model, device=device
|
||||
)
|
||||
local_batch_size = encoder_output_len.shape[0]
|
||||
|
||||
# Create biasing requests for each sample in the batch
|
||||
biasing_requests = []
|
||||
multi_biasing_ids = torch.full([local_batch_size], fill_value=-1, dtype=torch.long, device=device)
|
||||
|
||||
for batch_idx, ref_text in enumerate(batch_ref_transcripts):
|
||||
if ref_text: # Only boost non-empty transcripts
|
||||
request = BiasingRequestItemConfig(
|
||||
boosting_model_cfg=BoostingTreeModelConfig(key_phrases_list=[ref_text], unk_score=-100),
|
||||
boosting_model_alpha=10.0,
|
||||
)
|
||||
request.add_to_multi_model(
|
||||
tokenizer=model.tokenizer,
|
||||
biasing_multi_model=decoding_computer.biasing_multi_model,
|
||||
)
|
||||
if request.multi_model_id is not None:
|
||||
multi_biasing_ids[batch_idx] = request.multi_model_id
|
||||
biasing_requests.append(request)
|
||||
else:
|
||||
biasing_requests.append(None)
|
||||
|
||||
# Decode encoder output by chunks, passing state between decoder invocations
|
||||
state: Optional[BatchedLabelLoopingState] = None
|
||||
batched_hyps: BatchedHyps | None = None
|
||||
encoder_output = encoder_output.transpose(1, 2)
|
||||
|
||||
for t in range(0, encoder_output.shape[1], chunk_size):
|
||||
rest_len = encoder_output_len - t
|
||||
current_len = torch.full_like(encoder_output_len, fill_value=chunk_size)
|
||||
current_len = torch.minimum(current_len, rest_len)
|
||||
current_len = torch.maximum(current_len, torch.zeros_like(current_len))
|
||||
|
||||
batched_hyps_chunk, state = decoding_computer(
|
||||
x=encoder_output[:, t : t + chunk_size],
|
||||
out_len=current_len,
|
||||
prev_batched_state=state,
|
||||
multi_biasing_ids=multi_biasing_ids,
|
||||
)
|
||||
|
||||
if batched_hyps is None:
|
||||
batched_hyps = batched_hyps_chunk
|
||||
else:
|
||||
batched_hyps.merge_(batched_hyps_chunk)
|
||||
|
||||
# Clean up biasing models
|
||||
for request in biasing_requests:
|
||||
if request is not None and request.multi_model_id is not None:
|
||||
decoding_computer.biasing_multi_model.remove_model(request.multi_model_id)
|
||||
request.multi_model_id = None
|
||||
|
||||
assert batched_hyps is not None
|
||||
all_hyps.extend(batched_hyps_to_hypotheses(batched_hyps, batch_size=local_batch_size))
|
||||
|
||||
streaming_transcripts = []
|
||||
for hyp in all_hyps:
|
||||
streaming_transcripts.append(model.tokenizer.ids_to_text(hyp.y_sequence.tolist()))
|
||||
|
||||
# The key assertion: boosting with ref transcripts should yield same results
|
||||
assert ref_transcripts == streaming_transcripts, (
|
||||
f"Boosting with reference transcripts should yield identical results.\n"
|
||||
f"This failure indicates a bug in fusion state handling during streaming decoding.\n"
|
||||
f"Differences found:\n"
|
||||
+ "\n".join(
|
||||
f" [{i}] ref: {ref!r} != boosted: {boosted!r}"
|
||||
for i, (ref, boosted) in enumerate(zip(ref_transcripts, streaming_transcripts))
|
||||
if ref != boosted
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
# 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 os.path
|
||||
import re
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.asr.parts.utils.timestamp_utils import get_segment_offsets, get_words_offsets
|
||||
|
||||
|
||||
class BaseTimestampsTest:
|
||||
"""
|
||||
Base class for testing timestamps in decoders (CTC and RNNT).
|
||||
This class defines common test methods that can be inherited by both
|
||||
test_ctc_decoding.py and test_rnnt_decoding.py.
|
||||
"""
|
||||
|
||||
@cached_property
|
||||
def bpe_tokenizer(self):
|
||||
model_path = "/home/TestData/asr/stt_en_conformer_transducer_small.nemo"
|
||||
if os.path.exists(model_path):
|
||||
model = ASRModel.restore_from(model_path, map_location="cpu")
|
||||
else:
|
||||
model = ASRModel.from_pretrained("stt_en_conformer_transducer_small", map_location="cpu")
|
||||
return model.tokenizer
|
||||
|
||||
@property
|
||||
def char_offsets_chars(self):
|
||||
char_offsets = [
|
||||
{"char": "e", "start_offset": 0, "end_offset": 1},
|
||||
{"char": " ", "start_offset": 2, "end_offset": 2},
|
||||
{"char": "e", "start_offset": 3, "end_offset": 4},
|
||||
{"char": " ", "start_offset": 5, "end_offset": 5},
|
||||
{"char": ".", "start_offset": 6, "end_offset": 7},
|
||||
{"char": " ", "start_offset": 8, "end_offset": 9},
|
||||
{"char": "e", "start_offset": 10, "end_offset": 11},
|
||||
{"char": " ", "start_offset": 12, "end_offset": 13},
|
||||
{"char": "?", "start_offset": 14, "end_offset": 15},
|
||||
{"char": " ", "start_offset": 16, "end_offset": 17},
|
||||
]
|
||||
return char_offsets
|
||||
|
||||
@property
|
||||
def word_offsets_chars_expected_output(self):
|
||||
return [
|
||||
{'word': 'e', 'start_offset': 0, 'end_offset': 1},
|
||||
{'word': 'e.', 'start_offset': 3, 'end_offset': 7},
|
||||
{'word': 'e?', 'start_offset': 10, 'end_offset': 15},
|
||||
]
|
||||
|
||||
@property
|
||||
def word_offsets_chars_expected_output_other_delimiter(self):
|
||||
return [
|
||||
{'word': 'e e ', 'start_offset': 0, 'end_offset': 5},
|
||||
{'word': ' e? ', 'start_offset': 8, 'end_offset': 17},
|
||||
]
|
||||
|
||||
@property
|
||||
def segment_offsets_expected_output(self):
|
||||
return [
|
||||
{'segment': 'e e.', 'start_offset': 0, 'end_offset': 7},
|
||||
{'segment': 'e?', 'start_offset': 10, 'end_offset': 15},
|
||||
]
|
||||
|
||||
@property
|
||||
def segment_offsets_expected_output_gap(self):
|
||||
return [
|
||||
{'segment': 'e e. e?', 'start_offset': 0, 'end_offset': 15},
|
||||
]
|
||||
|
||||
@property
|
||||
def char_offsets_wpe(self):
|
||||
char_offsets = [
|
||||
{"char": "nineteen", "start_offset": 0, "end_offset": 1},
|
||||
{"char": "##th", "start_offset": 2, "end_offset": 2},
|
||||
{"char": "re", "start_offset": 3, "end_offset": 4},
|
||||
{"char": "seven", "start_offset": 5, "end_offset": 6},
|
||||
{"char": "##ty", "start_offset": 6, "end_offset": 7},
|
||||
{"char": "eighty", "start_offset": 8, "end_offset": 9},
|
||||
]
|
||||
|
||||
return char_offsets
|
||||
|
||||
@property
|
||||
def word_offsets_wpe_expected_output(self):
|
||||
return [
|
||||
{'word': 'nineteenth', 'start_offset': 0, 'end_offset': 2},
|
||||
{'word': 're', 'start_offset': 3, 'end_offset': 4},
|
||||
{'word': 'seventy', 'start_offset': 5, 'end_offset': 7},
|
||||
{'word': 'eighty', 'start_offset': 8, 'end_offset': 9},
|
||||
]
|
||||
|
||||
@property
|
||||
def word_offsets_wpe_expected_output_other_delimiter(self):
|
||||
return [
|
||||
{'word': 'nineteenth', 'start_offset': 0, 'end_offset': 2},
|
||||
{'word': 'seventy eighty', 'start_offset': 5, 'end_offset': 9},
|
||||
]
|
||||
|
||||
@property
|
||||
def char_offsets_bpe(self):
|
||||
char_offsets = [
|
||||
{"char": "discuss", "start_offset": 0, "end_offset": 2},
|
||||
{"char": "absolute", "start_offset": 2, "end_offset": 4},
|
||||
{"char": "'", "start_offset": 5, "end_offset": 5},
|
||||
{"char": "really", "start_offset": 5, "end_offset": 6},
|
||||
{"char": "friend", "start_offset": 6, "end_offset": 7},
|
||||
{"char": "ship", "start_offset": 8, "end_offset": 9},
|
||||
]
|
||||
|
||||
return char_offsets
|
||||
|
||||
@property
|
||||
def encoded_char_offsets_bpe(self):
|
||||
char_offsets = [
|
||||
{"char": "▁discuss", "start_offset": 0, "end_offset": 2},
|
||||
{"char": "▁absolute", "start_offset": 2, "end_offset": 4},
|
||||
{"char": "'", "start_offset": 5, "end_offset": 5},
|
||||
{"char": "▁really", "start_offset": 5, "end_offset": 6},
|
||||
{"char": "▁friend", "start_offset": 6, "end_offset": 7},
|
||||
{"char": "ship", "start_offset": 8, "end_offset": 9},
|
||||
]
|
||||
|
||||
return char_offsets
|
||||
|
||||
@property
|
||||
def word_offsets_bpe_expected_output(self):
|
||||
return [
|
||||
{'word': "discuss", 'start_offset': 0, 'end_offset': 2},
|
||||
{'word': "absolute'", 'start_offset': 2, 'end_offset': 5},
|
||||
{'word': "really", 'start_offset': 5, 'end_offset': 6},
|
||||
{'word': "friendship", 'start_offset': 6, 'end_offset': 9},
|
||||
]
|
||||
|
||||
@property
|
||||
def word_offsets_bpe_expected_output_other_delimiter(self):
|
||||
return [
|
||||
{'word': "discuss absolute'", 'start_offset': 0, 'end_offset': 5},
|
||||
{'word': "friendship", 'start_offset': 6, 'end_offset': 9},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def check_char_timestamps(hyp: Hypothesis, decoding: Any):
|
||||
"""Test character-level timestamps for both CTC and RNNT"""
|
||||
assert hyp.timestamp is not None
|
||||
assert isinstance(hyp.timestamp, dict)
|
||||
assert 'timestep' in hyp.timestamp
|
||||
assert 'char' in hyp.timestamp
|
||||
assert 'word' in hyp.timestamp
|
||||
assert 'segment' in hyp.timestamp
|
||||
|
||||
hypothesis_text = re.sub(r'\s+', ' ', hyp.text.strip())
|
||||
|
||||
words = hyp.text.split(decoding.word_seperator)
|
||||
words = list(filter(lambda x: x != '', words))
|
||||
assert len(hyp.timestamp['word']) == len(words)
|
||||
|
||||
words_from_timestamps = [ts['word'] for ts in hyp.timestamp['word']]
|
||||
assert hypothesis_text == decoding.word_seperator.join(words_from_timestamps)
|
||||
|
||||
segments = []
|
||||
segment = []
|
||||
|
||||
for word in words:
|
||||
segment.append(word)
|
||||
if word[-1] in decoding.segment_seperators:
|
||||
segments.append(' '.join(segment))
|
||||
segment = []
|
||||
|
||||
if segment:
|
||||
segments.append(' '.join(segment))
|
||||
|
||||
assert len(hyp.timestamp['segment']) == len(segments)
|
||||
|
||||
segments_from_timestamps = [ts['segment'] for ts in hyp.timestamp['segment']]
|
||||
assert hypothesis_text == decoding.word_seperator.join(segments_from_timestamps)
|
||||
|
||||
@staticmethod
|
||||
def check_subword_timestamps(hyp: Hypothesis, decoding: Any):
|
||||
"""Test subword-level timestamps for both CTC and RNNT"""
|
||||
assert hyp.timestamp is not None
|
||||
assert isinstance(hyp.timestamp, dict)
|
||||
assert 'timestep' in hyp.timestamp
|
||||
assert 'char' in hyp.timestamp
|
||||
assert 'word' in hyp.timestamp
|
||||
assert 'segment' in hyp.timestamp
|
||||
|
||||
chars = list(hyp.text)
|
||||
chars = list(filter(lambda x: x not in ['', ' ', '#'], chars))
|
||||
all_chars = [list(decoding.tokenizer.tokens_to_text(data['char'])) for data in hyp.timestamp['char']]
|
||||
all_chars = [char for subword in all_chars for char in subword]
|
||||
all_chars = list(filter(lambda x: x not in ['', ' ', '#'], all_chars))
|
||||
assert len(chars) == len(all_chars)
|
||||
|
||||
hypothesis_text = re.sub(r'\s+', ' ', hyp.text.strip())
|
||||
|
||||
words_from_timestamps = [ts['word'] for ts in hyp.timestamp['word']]
|
||||
assert hypothesis_text == decoding.word_seperator.join(words_from_timestamps)
|
||||
|
||||
segments_count = sum([hyp.text.count(seperator) for seperator in decoding.segment_seperators])
|
||||
if hyp.text[-1] not in decoding.segment_seperators:
|
||||
segments_count += 1
|
||||
|
||||
if hyp.text in decoding.segment_seperators:
|
||||
segments_count = 0
|
||||
|
||||
assert len(hyp.timestamp['segment']) == segments_count
|
||||
|
||||
segments_from_timestamps = [ts['segment'] for ts in hyp.timestamp['segment']]
|
||||
assert hypothesis_text == decoding.word_seperator.join(segments_from_timestamps)
|
||||
|
||||
def test_word_offsets_chars(self):
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_chars,
|
||||
encoded_char_offsets=None,
|
||||
word_delimiter_char=" ",
|
||||
tokenizer_type="char",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_char.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_chars_expected_output
|
||||
|
||||
def test_word_offsets_char_other_delimiter(self):
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_chars,
|
||||
encoded_char_offsets=None,
|
||||
tokenizer_type="char",
|
||||
word_delimiter_char=".",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_char.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_chars_expected_output_other_delimiter
|
||||
|
||||
def test_word_offsets_subword_wpe(self):
|
||||
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_wpe,
|
||||
encoded_char_offsets=None,
|
||||
word_delimiter_char=" ",
|
||||
tokenizer_type="wpe",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_subword_wpe.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_wpe_expected_output
|
||||
|
||||
def test_word_offsets_subword_wpe_other_delimiter(self):
|
||||
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_wpe,
|
||||
encoded_char_offsets=None,
|
||||
word_delimiter_char="re",
|
||||
tokenizer_type="wpe",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_subword_wpe.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_wpe_expected_output_other_delimiter
|
||||
|
||||
def test_word_offsets_subword_bpe(self):
|
||||
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_bpe,
|
||||
encoded_char_offsets=self.encoded_char_offsets_bpe,
|
||||
word_delimiter_char=" ",
|
||||
tokenizer_type="bpe",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_subword_bpe.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_bpe_expected_output
|
||||
|
||||
def test_word_offsets_subword_bpe_other_delimiter(self):
|
||||
word_offsets = get_words_offsets(
|
||||
char_offsets=self.char_offsets_bpe,
|
||||
encoded_char_offsets=self.encoded_char_offsets_bpe,
|
||||
word_delimiter_char="really",
|
||||
tokenizer_type="bpe",
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
decode_tokens_to_str=self.decoding_subword_bpe.decode_tokens_to_str,
|
||||
)
|
||||
|
||||
assert word_offsets == self.word_offsets_bpe_expected_output_other_delimiter
|
||||
|
||||
def test_segment_offsets_delimiter(self):
|
||||
segment_offsets = get_segment_offsets(
|
||||
word_offsets=self.word_offsets_chars_expected_output,
|
||||
segment_delimiter_tokens=['.', '!', '?'],
|
||||
supported_punctuation={'.', '!', '?'},
|
||||
)
|
||||
|
||||
assert segment_offsets == self.segment_offsets_expected_output
|
||||
|
||||
def test_segment_offsets_gap(self):
|
||||
segment_offsets = get_segment_offsets(
|
||||
word_offsets=self.word_offsets_chars_expected_output,
|
||||
segment_delimiter_tokens=[],
|
||||
supported_punctuation={},
|
||||
segment_gap_threshold=10,
|
||||
)
|
||||
|
||||
assert segment_offsets == self.segment_offsets_expected_output_gap
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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 copy
|
||||
from contextlib import contextmanager
|
||||
|
||||
import librosa
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
|
||||
@contextmanager
|
||||
def preserve_decoding_cfg_and_cpu_device(model: ASRModel):
|
||||
"""
|
||||
Context manager to preserve the decoding strategy and device of the model.
|
||||
This is useful for tests that modify the model's decoding strategy or device
|
||||
to avoid side effects or costly model reloading.
|
||||
"""
|
||||
backup_decoding_cfg = copy.deepcopy(model.cfg.decoding)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
model.to(device="cpu")
|
||||
if model.cfg.decoding != backup_decoding_cfg:
|
||||
model.change_decoding_strategy(backup_decoding_cfg)
|
||||
|
||||
|
||||
def load_audio(file_path, target_sr=16000) -> tuple[torch.Tensor, int]:
|
||||
audio, sr = librosa.load(file_path, sr=target_sr)
|
||||
return torch.tensor(audio, dtype=torch.float32), sr
|
||||
|
||||
|
||||
@contextmanager
|
||||
def avoid_sync_operations(device: torch.device):
|
||||
try:
|
||||
if device.type == "cuda":
|
||||
torch.cuda.set_sync_debug_mode(2) # fail if a blocking operation
|
||||
yield
|
||||
finally:
|
||||
if device.type == "cuda":
|
||||
torch.cuda.set_sync_debug_mode(0) # default, blocking operations are allowed
|
||||
|
||||
|
||||
def make_preprocessor_deterministic(model: ASRModel):
|
||||
model.preprocessor.featurizer.dither = 0.0
|
||||
model.preprocessor.featurizer.pad_to = 0
|
||||
Reference in New Issue
Block a user