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,13 @@
|
||||
# Copyright (c) 2021, 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.
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION & AFFILIATES. 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.
|
||||
|
||||
"""A script to check that copyright headers exists"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
EXCLUSIONS = ["scripts/get_commonvoice_data.py"]
|
||||
|
||||
|
||||
def get_top_comments(_data):
|
||||
# Get all lines where comments should exist
|
||||
lines_to_extract = []
|
||||
for i, line in enumerate(_data):
|
||||
# If empty line, skip
|
||||
if line in ["", "\n", "", "\r", "\r\n"]:
|
||||
continue
|
||||
# If it is a comment line, we should get it
|
||||
if line.startswith("#"):
|
||||
lines_to_extract.append(i)
|
||||
# Assume all copyright headers occur before any import statements not enclosed in a comment block
|
||||
elif "import" in line:
|
||||
break
|
||||
|
||||
comments = []
|
||||
for line in lines_to_extract:
|
||||
comments.append(_data[line])
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Usage for copyright header insertion script")
|
||||
parser.add_argument(
|
||||
'--dir',
|
||||
help='Path to source files to add copyright header to. Will recurse through subdirectories',
|
||||
required=True,
|
||||
type=str,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
current_year = int(datetime.today().year)
|
||||
starting_year = 2020
|
||||
python_header_path = "tests/py_cprheader.txt"
|
||||
with open(python_header_path, 'r', encoding='utf-8') as original:
|
||||
pyheader = original.read().split("\n")
|
||||
pyheader_lines = len(pyheader)
|
||||
|
||||
problematic_files = []
|
||||
|
||||
for filename in Path(args.dir).rglob('*.py'):
|
||||
if str(filename) in EXCLUSIONS:
|
||||
continue
|
||||
with open(str(filename), 'r', encoding='utf-8') as original:
|
||||
data = original.readlines()
|
||||
|
||||
data = get_top_comments(data)
|
||||
if len(data) < pyheader_lines:
|
||||
print(f"{filename} has less header lines than the copyright template")
|
||||
problematic_files.append(filename)
|
||||
continue
|
||||
|
||||
found = False
|
||||
for i, line in enumerate(data):
|
||||
if re.search(re.compile("Copyright.*NVIDIA.*", re.IGNORECASE), line):
|
||||
# if re.search(re.compile("Copyright.*", re.IGNORECASE), line):
|
||||
found = True
|
||||
# Check 1st line manually
|
||||
year_good = False
|
||||
for year in range(starting_year, current_year + 1):
|
||||
year_line = pyheader[0].format(CURRENT_YEAR=year)
|
||||
if year_line in data[i]:
|
||||
year_good = True
|
||||
break
|
||||
year_line_aff = year_line.split(".")
|
||||
year_line_aff = year_line_aff[0] + " & AFFILIATES." + year_line_aff[1]
|
||||
if year_line_aff in data[i]:
|
||||
year_good = True
|
||||
break
|
||||
if not year_good:
|
||||
problematic_files.append(filename)
|
||||
print(f"{filename} had an error with the year")
|
||||
break
|
||||
while "opyright" in data[i]:
|
||||
i += 1
|
||||
for j in range(1, pyheader_lines):
|
||||
if pyheader[j] not in data[i + j - 1]:
|
||||
problematic_files.append(filename)
|
||||
print(f"{filename} missed the line: {pyheader[j]}")
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
print(f"{filename} did not match the regex: `Copyright.*NVIDIA.*`")
|
||||
problematic_files.append(filename)
|
||||
|
||||
if len(problematic_files) > 0:
|
||||
print("check_copyright_headers.py found the following files that might not have a copyright header:")
|
||||
for _file in problematic_files:
|
||||
print(_file)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
# 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 json
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.models import ASRModel, EncDecMultiTaskModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.ctc_greedy_decoding import GreedyCTCInferConfig
|
||||
from nemo.collections.asr.parts.submodules.multitask_decoding import MultiTaskDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.multitask_greedy_decoding import AEDGreedyInferConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_greedy_decoding import GreedyBatchedRNNTInferConfig
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_benchmarking_utils import run_confidence_benchmark
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceConfig
|
||||
|
||||
# both models recognize the test data without errors, thus every metric except ece return default values
|
||||
# ECE values for fast conformer models (stt_en_fastconformer_ctc_large and stt_en_fastconformer_transducer_large)
|
||||
ECE_VALUES = {("token", "ctc"): 0.86, ("token", "rnnt"): 0.75, ("word", "ctc"): 0.89, ("word", "rnnt"): 0.80}
|
||||
|
||||
TOL_DEGREE = 2
|
||||
TOL = 2 / math.pow(10, TOL_DEGREE)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def audio_and_texts(test_data_dir):
|
||||
# get filenames and reference texts from manifest
|
||||
filepaths = []
|
||||
reference_texts = []
|
||||
manifest = Path(test_data_dir) / Path("asr/an4_val.json")
|
||||
with open(manifest, 'r') as f:
|
||||
for line in f:
|
||||
item = json.loads(line)
|
||||
# alaptev: maybe fix those paths in the manifest?
|
||||
audio_file = Path(item['audio_filepath'].replace("/data/", "/.data/"))
|
||||
filepaths.append(str(audio_file.absolute()))
|
||||
reference_texts.append(item['text'])
|
||||
return filepaths, reference_texts
|
||||
|
||||
|
||||
class TestASRConfidenceBenchmark:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize('model_name', ("ctc", "rnnt"))
|
||||
@pytest.mark.parametrize('target_level', ("token", "word"))
|
||||
def test_run_confidence_benchmark(
|
||||
self, model_name, target_level, audio_and_texts, fast_conformer_ctc_model, fast_conformer_transducer_model
|
||||
):
|
||||
model = fast_conformer_ctc_model if model_name == "ctc" else fast_conformer_transducer_model
|
||||
assert isinstance(model, ASRModel)
|
||||
filepaths, reference_texts = audio_and_texts
|
||||
confidence_cfg = (
|
||||
ConfidenceConfig(preserve_frame_confidence=True, preserve_token_confidence=True)
|
||||
if target_level == "token"
|
||||
else ConfidenceConfig(preserve_frame_confidence=True, preserve_word_confidence=True)
|
||||
)
|
||||
model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(
|
||||
fused_batch_size=-1,
|
||||
strategy="greedy_batch",
|
||||
confidence_cfg=confidence_cfg,
|
||||
greedy=GreedyBatchedRNNTInferConfig(loop_labels=False),
|
||||
)
|
||||
if model_name == "rnnt"
|
||||
else CTCDecodingConfig(confidence_cfg=confidence_cfg)
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
assert np.allclose(
|
||||
np.array(
|
||||
run_confidence_benchmark(model, target_level, filepaths, reference_texts, plot_dir=tmpdir)[
|
||||
target_level
|
||||
]
|
||||
),
|
||||
np.array([0.5, 1.0, 0.0, -math.inf, ECE_VALUES[(target_level, model_name)], 0.0, 0.0, 0.0]),
|
||||
atol=TOL,
|
||||
)
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.parametrize('model_name', ("ctc", "rnnt"))
|
||||
def test_deprecated_config_args(self, model_name, fast_conformer_ctc_model, fast_conformer_transducer_model):
|
||||
assert ConfidenceConfig().method_cfg.alpha == 0.33, "default `alpha` is supposed to be 0.33"
|
||||
model = fast_conformer_ctc_model if model_name == "ctc" else fast_conformer_transducer_model
|
||||
assert isinstance(model, ASRModel)
|
||||
|
||||
conf = OmegaConf.create({"temperature": 0.5})
|
||||
test_args_main = {"method_cfg": conf}
|
||||
test_args_greedy = {"confidence_method_cfg": conf}
|
||||
confidence_cfg = ConfidenceConfig(preserve_word_confidence=True, **test_args_main)
|
||||
model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(fused_batch_size=-1, strategy="greedy", confidence_cfg=confidence_cfg)
|
||||
if model_name == "rnnt"
|
||||
else CTCDecodingConfig(confidence_cfg=confidence_cfg)
|
||||
)
|
||||
assert model.cfg.decoding.confidence_cfg.method_cfg.alpha == 0.5
|
||||
model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(
|
||||
fused_batch_size=-1,
|
||||
strategy="greedy",
|
||||
greedy=GreedyBatchedRNNTInferConfig(preserve_frame_confidence=True, **test_args_greedy),
|
||||
)
|
||||
if model_name == "rnnt"
|
||||
else CTCDecodingConfig(greedy=GreedyCTCInferConfig(preserve_frame_confidence=True, **test_args_greedy))
|
||||
)
|
||||
assert model.cfg.decoding.greedy.confidence_method_cfg.alpha == 0.5
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.with_downloads
|
||||
def test_aed_multitask_model_confidence(self, canary_1b_v2, test_data_dir):
|
||||
"""Test token and word confidence for AED multitask models (Canary)."""
|
||||
model = canary_1b_v2
|
||||
assert isinstance(model, EncDecMultiTaskModel)
|
||||
|
||||
audio_file = Path(test_data_dir) / "asr" / "train" / "an4" / "wav" / "an46-mmap-b.wav"
|
||||
|
||||
# Configure decoding with confidence
|
||||
decode_cfg = MultiTaskDecodingConfig(
|
||||
strategy='greedy',
|
||||
greedy=AEDGreedyInferConfig(preserve_token_confidence=True),
|
||||
confidence_cfg=ConfidenceConfig(preserve_token_confidence=True, preserve_word_confidence=True),
|
||||
)
|
||||
model.change_decoding_strategy(decode_cfg)
|
||||
|
||||
hypotheses = model.transcribe(
|
||||
audio=str(audio_file),
|
||||
batch_size=1,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
|
||||
assert len(hypotheses) == 1
|
||||
hyp = hypotheses[0]
|
||||
|
||||
# Verify text is present
|
||||
assert isinstance(hyp.text, str)
|
||||
assert len(hyp.text) > 0
|
||||
|
||||
# Verify y_sequence is present
|
||||
assert hyp.y_sequence is not None
|
||||
assert len(hyp.y_sequence) > 0
|
||||
|
||||
# Verify token confidence is present and has correct length
|
||||
assert hyp.token_confidence is not None
|
||||
assert len(hyp.token_confidence) == len(hyp.y_sequence)
|
||||
|
||||
# Verify word confidence is present
|
||||
assert hyp.word_confidence is not None
|
||||
assert len(hyp.word_confidence) > 0
|
||||
|
||||
# Verify confidence values are in valid range [0, 1]
|
||||
for conf in hyp.token_confidence:
|
||||
assert 0.0 <= conf <= 1.0, f"Token confidence {conf} not in valid range [0, 1]"
|
||||
for conf in hyp.word_confidence:
|
||||
assert 0.0 <= conf <= 1.0, f"Word confidence {conf} not in valid range [0, 1]"
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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 math
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.stats import uniform
|
||||
|
||||
from nemo.collections.asr.parts.utils.confidence_metrics import (
|
||||
auc_nt,
|
||||
auc_pr,
|
||||
auc_roc,
|
||||
auc_yc,
|
||||
ece,
|
||||
nce,
|
||||
save_confidence_hist,
|
||||
save_custom_confidence_curve,
|
||||
save_nt_curve,
|
||||
save_pr_curve,
|
||||
save_roc_curve,
|
||||
)
|
||||
|
||||
# set convenient name2metric mapping
|
||||
name2metric = {
|
||||
f.__name__: (f, ans)
|
||||
for f, ans in zip((auc_roc, auc_pr, auc_nt, auc_yc, ece, nce), (0.833, 0.917, 0.833, 0.421, 0.232, 0.403))
|
||||
}
|
||||
# ece does not have a default value
|
||||
name2metric_all_correct = {
|
||||
f.__name__: (f, ans) for f, ans in zip((auc_roc, auc_pr, auc_nt, auc_yc, nce), (0.5, 1.0, 0.0, 0.0, -math.inf))
|
||||
}
|
||||
name2metric_all_incorrect = {
|
||||
f.__name__: (f, ans) for f, ans in zip((auc_roc, auc_pr, auc_nt, auc_yc, nce), (0.5, 0.0, 1.0, 0.0, -math.inf))
|
||||
}
|
||||
|
||||
# Initialize data
|
||||
Y_TRUE = [1, 0, 0, 1, 1]
|
||||
Y_TRUE_ALL_CORRECT = [1, 1, 1, 1, 1]
|
||||
Y_TRUE_ALL_INCORRECT = [0, 0, 0, 0, 0]
|
||||
Y_SCORE = [0.6, 0.7, 0.02, 0.95, 0.8]
|
||||
Y_TRUE_RANDOM = np.random.choice(2, 1000, p=[0.2, 0.8])
|
||||
# probability distribution with mean ~= 0.65 and std ~= 0.25
|
||||
Y_SCORE_RANDOM = uniform.rvs(size=1000, loc=0.5, scale=0.5) - 0.5 * np.random.choice(2, 1000, p=[0.8, 0.2])
|
||||
|
||||
TOL_DEGREE = 3
|
||||
TOL = 1 / math.pow(10, TOL_DEGREE)
|
||||
|
||||
|
||||
class TestConfidenceMetrics:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('metric_name', name2metric.keys())
|
||||
def test_metric_main(self, metric_name):
|
||||
metric, ans = name2metric[metric_name]
|
||||
|
||||
assert round(metric(Y_TRUE, Y_SCORE), TOL_DEGREE) == ans
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('metric_name', name2metric_all_correct.keys())
|
||||
def test_metric_all_correct(self, metric_name):
|
||||
metric, ans = name2metric_all_correct[metric_name]
|
||||
|
||||
assert round(metric(Y_TRUE_ALL_CORRECT, Y_SCORE), TOL_DEGREE) == ans
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('metric_name', name2metric_all_incorrect.keys())
|
||||
def test_metric_all_incorrect(self, metric_name):
|
||||
metric, ans = name2metric_all_incorrect[metric_name]
|
||||
|
||||
assert round(metric(Y_TRUE_ALL_INCORRECT, Y_SCORE), TOL_DEGREE) == ans
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_metric_auc_yc_aux(self):
|
||||
n_bins = 10
|
||||
result, result_std, result_max, (thresholds, yc_curve) = auc_yc(
|
||||
Y_TRUE, Y_SCORE, n_bins=n_bins, return_std_maximum=True, return_curve=True
|
||||
)
|
||||
|
||||
assert round(result_std, TOL_DEGREE) == 0.228
|
||||
assert round(result_max, TOL_DEGREE) == 0.667
|
||||
assert np.allclose(np.array(thresholds), np.array([i / n_bins for i in range(0, n_bins + 1)]), atol=TOL)
|
||||
assert np.allclose(
|
||||
np.array(yc_curve), np.array([0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.167, 0.667, 0.667, 0.333, 0.0]), atol=TOL
|
||||
)
|
||||
|
||||
|
||||
class TestSaveConfidencePlot:
|
||||
@pytest.mark.unit
|
||||
def test_save_confidence_hist(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
save_confidence_hist(Y_SCORE_RANDOM, tmpdir)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('plot_func', (save_roc_curve, save_pr_curve, save_nt_curve))
|
||||
def test_save_simple_confidence_curve(self, plot_func):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
plot_func(Y_TRUE_RANDOM, Y_SCORE_RANDOM, tmpdir)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_custom_confidence_curve(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ranges = np.arange(0, 1, 0.01)
|
||||
save_custom_confidence_curve(ranges, ranges, tmpdir)
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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 math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import (
|
||||
get_confidence_aggregation_bank,
|
||||
get_confidence_measure_bank,
|
||||
)
|
||||
|
||||
# Initialize probability vectors
|
||||
VOCAB_SIZES = (100, 1000, 10000)
|
||||
ONE_VEC_SET, ZERO_VEC_SET, RAND_VEC_SET, OVERFIT_RAND_VEC_SET = {}, {}, {}, {}
|
||||
for vocab_size in VOCAB_SIZES:
|
||||
# batch size 2 to test different positions of probability one
|
||||
ONE_VEC_SET[vocab_size] = torch.nan_to_num(
|
||||
torch.cat(
|
||||
[
|
||||
torch.tensor([[0] + [float('-inf')] * (vocab_size - 1)]),
|
||||
torch.tensor([[float('-inf')] * (vocab_size - 3) + [0] + [float('-inf')] * 2]),
|
||||
]
|
||||
)
|
||||
)
|
||||
ZERO_VEC_SET[vocab_size] = torch.nan_to_num(torch.tensor([[math.log(1 / vocab_size)] * vocab_size] * 2))
|
||||
# batch size 1
|
||||
rand_logit = torch.rand((1, vocab_size))
|
||||
rand_logit_overfit = rand_logit.clone()
|
||||
rand_logit_overfit[0, 0] += vocab_size
|
||||
RAND_VEC_SET[vocab_size] = torch.nan_to_num(torch.nn.functional.log_softmax(rand_logit, -1))
|
||||
OVERFIT_RAND_VEC_SET[vocab_size] = torch.nan_to_num(torch.nn.functional.log_softmax(rand_logit_overfit, -1))
|
||||
AGGREGATION_VEC_SIMPLE = [0.0, 0.5, 1]
|
||||
|
||||
TOL_DEGREE = 6
|
||||
TOL = 1 / math.pow(10, TOL_DEGREE)
|
||||
|
||||
|
||||
def get_measure_parametrize_ranges():
|
||||
confidence_measure_bank = {}
|
||||
alpha_range = (0.25, 0.5, 1.0)
|
||||
bank_exception = None
|
||||
try:
|
||||
confidence_measure_bank = get_confidence_measure_bank()
|
||||
except Exception as e:
|
||||
alpha_range = ()
|
||||
bank_exception = e
|
||||
return confidence_measure_bank, alpha_range, bank_exception
|
||||
|
||||
|
||||
def get_aggregation_parametrize_ranges():
|
||||
confidence_aggregation_bank = {}
|
||||
bank_exception = None
|
||||
try:
|
||||
confidence_aggregation_bank = get_confidence_aggregation_bank()
|
||||
except Exception as e:
|
||||
bank_exception = e
|
||||
return confidence_aggregation_bank, bank_exception
|
||||
|
||||
|
||||
class TestConfidenceMeasureBank:
|
||||
measure_bank, alphas, bank_build_exception = get_measure_parametrize_ranges()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_measure_bank(self):
|
||||
if self.bank_build_exception is not None:
|
||||
raise self.bank_build_exception
|
||||
|
||||
assert isinstance(self.measure_bank, dict)
|
||||
assert len(self.measure_bank) > 0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('measure_name', measure_bank.keys())
|
||||
@pytest.mark.parametrize('alpha', alphas)
|
||||
@pytest.mark.parametrize('vocab_size', VOCAB_SIZES)
|
||||
def test_confidence_measures_one(self, measure_name, alpha, vocab_size):
|
||||
measure = self.measure_bank[measure_name]
|
||||
|
||||
assert torch.allclose(measure(ONE_VEC_SET[vocab_size], vocab_size, alpha), torch.tensor([1.0, 1.0]), atol=TOL)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('measure_name', measure_bank.keys())
|
||||
@pytest.mark.parametrize('alpha', alphas)
|
||||
@pytest.mark.parametrize('vocab_size', VOCAB_SIZES)
|
||||
def test_confidence_measures_zero(self, measure_name, alpha, vocab_size):
|
||||
measure = self.measure_bank[measure_name]
|
||||
|
||||
assert torch.allclose(measure(ZERO_VEC_SET[vocab_size], vocab_size, alpha), torch.tensor([0.0, 0.0]), atol=TOL)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('measure_name', measure_bank.keys())
|
||||
@pytest.mark.parametrize('alpha', alphas)
|
||||
@pytest.mark.parametrize('vocab_size', VOCAB_SIZES)
|
||||
def test_confidence_measures_partial_order(self, measure_name, alpha, vocab_size):
|
||||
measure = self.measure_bank[measure_name]
|
||||
value_normal = round(float(measure(RAND_VEC_SET[vocab_size], vocab_size, alpha)[0]), TOL_DEGREE)
|
||||
value_overfit = round(float(measure(OVERFIT_RAND_VEC_SET[vocab_size], vocab_size, alpha)[0]), TOL_DEGREE)
|
||||
|
||||
assert 0 <= value_normal < value_overfit <= 1, (
|
||||
measure(RAND_VEC_SET[vocab_size], vocab_size, alpha),
|
||||
measure(OVERFIT_RAND_VEC_SET[vocab_size], vocab_size, alpha),
|
||||
)
|
||||
|
||||
|
||||
class TestConfidenceAggregationBank:
|
||||
aggregation_bank, bank_build_exception = get_aggregation_parametrize_ranges()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_aggregation_bank(self):
|
||||
if self.bank_build_exception is not None:
|
||||
raise self.bank_build_exception
|
||||
|
||||
assert isinstance(self.aggregation_bank, dict)
|
||||
assert len(self.aggregation_bank) > 0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('aggregation_name', aggregation_bank.keys())
|
||||
def test_confidence_agregation_simple(self, aggregation_name):
|
||||
# alaptev: would skipif work with parametrize arguments?
|
||||
if aggregation_name not in ("mean", "min", "max", "prod"):
|
||||
pytest.skip(f"{aggregation_name} is not a simple aggregation")
|
||||
aggregation = self.aggregation_bank[aggregation_name]
|
||||
if aggregation_name == "mean":
|
||||
assert aggregation(AGGREGATION_VEC_SIMPLE) == 0.5
|
||||
elif aggregation_name == "min":
|
||||
assert aggregation(AGGREGATION_VEC_SIMPLE) == 0.0
|
||||
if aggregation_name == "max":
|
||||
assert aggregation(AGGREGATION_VEC_SIMPLE) == 1.0
|
||||
if aggregation_name == "prod":
|
||||
assert aggregation(AGGREGATION_VEC_SIMPLE) == 0.0
|
||||
@@ -0,0 +1,387 @@
|
||||
# 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 dataclasses import dataclass
|
||||
from typing import Optional, Type
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
|
||||
class RNNTTestHelper:
|
||||
@staticmethod
|
||||
def wrap_and_call(fn, acts, labels, device, input_lengths=None, target_lengths=None):
|
||||
if not torch.is_tensor(acts):
|
||||
acts = torch.FloatTensor(acts)
|
||||
|
||||
if 'cuda' in device:
|
||||
acts = acts.cuda()
|
||||
|
||||
if not acts.requires_grad:
|
||||
acts.requires_grad = True
|
||||
|
||||
labels = torch.LongTensor(labels)
|
||||
|
||||
if input_lengths is None:
|
||||
lengths = [acts.shape[1]] * acts.shape[0]
|
||||
lengths = torch.LongTensor(lengths)
|
||||
else:
|
||||
lengths = input_lengths
|
||||
|
||||
if target_lengths is None:
|
||||
label_lengths = [len(l) for l in labels]
|
||||
label_lengths = torch.LongTensor(label_lengths)
|
||||
else:
|
||||
label_lengths = target_lengths
|
||||
|
||||
if 'cuda' in device:
|
||||
labels = labels.cuda()
|
||||
lengths = lengths.cuda()
|
||||
label_lengths = label_lengths.cuda()
|
||||
|
||||
costs = fn(acts, labels, lengths, label_lengths)
|
||||
cost = torch.sum(costs)
|
||||
cost.backward()
|
||||
|
||||
if 'cuda' in device:
|
||||
torch.cuda.synchronize()
|
||||
|
||||
if acts.grad is not None:
|
||||
grad = acts.grad.data.cpu().numpy()
|
||||
else:
|
||||
grad = None
|
||||
|
||||
return costs.data.cpu().numpy(), grad
|
||||
|
||||
|
||||
@dataclass
|
||||
class RnntLossSampleData:
|
||||
vocab_size: int
|
||||
blank_id: int
|
||||
|
||||
logits: torch.Tensor
|
||||
targets: torch.Tensor
|
||||
input_lengths: torch.Tensor
|
||||
target_lengths: torch.Tensor
|
||||
|
||||
expected_cost: Optional[torch.Tensor] = None
|
||||
expected_grads: Optional[torch.Tensor] = None
|
||||
|
||||
@classmethod
|
||||
def get_sample_small(cls) -> "RnntLossSampleData":
|
||||
activations = np.array(
|
||||
[
|
||||
[
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.6, 0.1, 0.1], [0.1, 0.1, 0.2, 0.8, 0.1]],
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.2, 0.1, 0.1], [0.7, 0.1, 0.2, 0.1, 0.1]],
|
||||
]
|
||||
]
|
||||
)
|
||||
labels = np.asarray([[1, 2]])
|
||||
|
||||
expected_cost = [4.495666]
|
||||
expected_grads = np.array(
|
||||
[
|
||||
[
|
||||
[
|
||||
[-0.13116688, -0.3999269, 0.17703125, 0.17703125, 0.17703125],
|
||||
[-0.18572757, 0.12247056, -0.18168412, 0.12247056, 0.12247056],
|
||||
[-0.32091254, 0.06269141, 0.06928472, 0.12624499, 0.06269141],
|
||||
],
|
||||
[
|
||||
[0.05456069, -0.21824276, 0.05456069, 0.05456069, 0.05456069],
|
||||
[0.12073959, 0.12073959, -0.48295835, 0.12073959, 0.12073959],
|
||||
[-0.6925882, 0.16871116, 0.18645467, 0.16871116, 0.16871116],
|
||||
],
|
||||
]
|
||||
]
|
||||
)
|
||||
return RnntLossSampleData(
|
||||
vocab_size=3,
|
||||
blank_id=0,
|
||||
logits=torch.from_numpy(activations).to(torch.float32),
|
||||
targets=torch.from_numpy(labels),
|
||||
input_lengths=torch.tensor([2]),
|
||||
target_lengths=torch.tensor([2]),
|
||||
expected_cost=torch.tensor(expected_cost).to(torch.float32),
|
||||
expected_grads=torch.from_numpy(expected_grads),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_sample_small_blank_last(cls) -> "RnntLossSampleData":
|
||||
activations = np.array(
|
||||
[
|
||||
[
|
||||
[[0.0, 1.0, 3.0], [0.0, 2.0, 3.0], [1.0, 1.0, 3.0], [2.0, 3.0, 2.0]],
|
||||
[[0.0, 0.0, 1.0], [0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [2.0, 2.0, 0.0]],
|
||||
[[0.0, 2.0, 5.0], [0.0, 3.0, 5.0], [1.0, 2.0, 5.0], [2.0, 4.0, 4.0]],
|
||||
[[0.0, 3.0, 4.0], [0.0, 4.0, 4.0], [1.0, 3.0, 4.0], [2.0, 5.0, 3.0]],
|
||||
[[2.0, 2.0, 1.0], [2.0, 3.0, 1.0], [3.0, 2.0, 1.0], [4.0, 4.0, 0.0]],
|
||||
]
|
||||
]
|
||||
)
|
||||
labels = np.array([[0, 1, 0]])
|
||||
|
||||
expected_cost = [6.789285182952881]
|
||||
expected_grads = np.array(
|
||||
[
|
||||
[
|
||||
[
|
||||
[-0.03551076725125313, 0.11419519782066345, -0.07868456840515137],
|
||||
[0.0027224558871239424, 0.00704305712133646, -0.009765520691871643],
|
||||
[0.0013856772566214204, 0.0013924005907028913, -0.0027780719101428986],
|
||||
[1.4249643527364242e-06, 3.873454716085689e-06, -5.298420546751004e-06],
|
||||
],
|
||||
[
|
||||
[-0.1934257447719574, 0.19551163911819458, -0.0020859241485595703],
|
||||
[0.07043898105621338, 0.05738453567028046, -0.12782356142997742],
|
||||
[0.061031512916088104, 0.02286236733198166, -0.08389391005039215],
|
||||
[0.0005252412520349026, 0.0005252412520349026, -0.0010504829697310925],
|
||||
],
|
||||
[
|
||||
[-0.007841046899557114, 0.025142310187220573, -0.017301201820373535],
|
||||
[0.0019501042552292347, 0.0005148053169250488, -0.0024650096893310547],
|
||||
[0.0027856370434165, 0.008609085343778133, -0.01139475405216217],
|
||||
[9.526080975774676e-05, 0.0007038871408440173, -0.000799147819634527],
|
||||
],
|
||||
[
|
||||
[-0.01533521432429552, 0.1386115401983261, -0.12327653169631958],
|
||||
[0.002850571647286415, -0.006693005561828613, 0.003842458128929138],
|
||||
[0.009236274287104607, 0.08995233476161957, -0.0991886705160141],
|
||||
[0.0001865450612967834, 0.0037468576338142157, -0.003933403175324202],
|
||||
],
|
||||
[
|
||||
[-0.2888762652873993, 0.211185485124588, 0.07769080251455307],
|
||||
[0.15952755510807037, -0.2182144820690155, 0.05868690833449364],
|
||||
[-0.3332723379135132, 0.2436419129371643, 0.0896308496594429],
|
||||
[0.4954628646373749, 0.4954628646373749, -0.9909257292747498],
|
||||
],
|
||||
]
|
||||
]
|
||||
)
|
||||
return RnntLossSampleData(
|
||||
vocab_size=3,
|
||||
blank_id=2,
|
||||
logits=torch.from_numpy(activations).to(torch.float32),
|
||||
targets=torch.from_numpy(labels),
|
||||
input_lengths=torch.tensor([5]),
|
||||
target_lengths=torch.tensor([3]),
|
||||
expected_cost=torch.tensor(expected_cost).to(torch.float32),
|
||||
expected_grads=torch.from_numpy(expected_grads),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_sample_medium(cls) -> "RnntLossSampleData":
|
||||
# minibatch x T x U x alphabet_size
|
||||
activations = [
|
||||
[
|
||||
[
|
||||
[0.06535690384862791, 0.7875301411923206, 0.08159176605666074],
|
||||
[0.5297155426466327, 0.7506749639230854, 0.7541348379087998],
|
||||
[0.6097641124736383, 0.8681404965673826, 0.6225318186056529],
|
||||
],
|
||||
[
|
||||
[0.6685222872103057, 0.8580392805336061, 0.16453892311765583],
|
||||
[0.989779515236694, 0.944298460961015, 0.6031678586829663],
|
||||
[0.9467833543605416, 0.666202507295747, 0.28688179752461884],
|
||||
],
|
||||
[
|
||||
[0.09418426230195986, 0.3666735970751962, 0.736168049462793],
|
||||
[0.1666804425271342, 0.7141542198635192, 0.3993997272216727],
|
||||
[0.5359823524146038, 0.29182076440286386, 0.6126422611507932],
|
||||
],
|
||||
[
|
||||
[0.3242405528768486, 0.8007644367291621, 0.5241057606558068],
|
||||
[0.779194617063042, 0.18331417220174862, 0.113745182072432],
|
||||
[0.24022162381327106, 0.3394695622533106, 0.1341595066017014],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[0.5055615569388828, 0.051597282072282646, 0.6402903936686337],
|
||||
[0.43073311517251, 0.8294731834714112, 0.1774668847323424],
|
||||
[0.3207001991262245, 0.04288308912457006, 0.30280282975568984],
|
||||
],
|
||||
[
|
||||
[0.6751777088333762, 0.569537369330242, 0.5584738347504452],
|
||||
[0.08313242153985256, 0.06016544344162322, 0.10795752845152584],
|
||||
[0.7486153608562472, 0.943918041459349, 0.4863558118797222],
|
||||
],
|
||||
[
|
||||
[0.4181986264486809, 0.6524078485043804, 0.024242983423721887],
|
||||
[0.13458171554507403, 0.3663418070512402, 0.2958297395361563],
|
||||
[0.9236695822497084, 0.6899291482654177, 0.7418981733448822],
|
||||
],
|
||||
[
|
||||
[0.25000547599982104, 0.6034295486281007, 0.9872887878887768],
|
||||
[0.5926057265215715, 0.8846724004467684, 0.5434495396894328],
|
||||
[0.6607698886038497, 0.3771277082495921, 0.3580209022231813],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
expected_cost = [4.2806528590890736, 3.9384369822503591]
|
||||
expected_grads = [
|
||||
[
|
||||
[
|
||||
[-1.86843902e-01, -6.25548810e-02, 2.49398798e-01],
|
||||
[-2.03376666e-01, 2.02399328e-01, 9.77333169e-04],
|
||||
[-1.41016081e-01, 7.91234672e-02, 6.18926100e-02],
|
||||
],
|
||||
[
|
||||
[-1.15517676e-02, -8.12802389e-02, 9.28319991e-02],
|
||||
[-1.54257029e-01, 2.29432687e-01, -7.51756504e-02],
|
||||
[-2.46593088e-01, 1.46404594e-01, 1.00188486e-01],
|
||||
],
|
||||
[
|
||||
[-1.29182907e-02, -6.15932420e-02, 7.45115355e-02],
|
||||
[-5.59857301e-02, 2.19830811e-01, -1.63845062e-01],
|
||||
[-4.97626871e-01, 2.09239945e-01, 2.88386941e-01],
|
||||
],
|
||||
[
|
||||
[1.36048580e-02, -3.02196294e-02, 1.66147724e-02],
|
||||
[1.13924511e-01, 6.27811998e-02, -1.76705718e-01],
|
||||
[-6.67078257e-01, 3.67658824e-01, 2.99419403e-01],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[-3.56343776e-01, -5.53474613e-02, 4.11691219e-01],
|
||||
[-9.69219357e-02, 2.94591039e-02, 6.74628317e-02],
|
||||
[-6.35175705e-02, 2.76544970e-02, 3.58630717e-02],
|
||||
],
|
||||
[
|
||||
[-1.54499024e-01, -7.39420280e-02, 2.28441030e-01],
|
||||
[-1.66789949e-01, -8.78955179e-05, 1.66877866e-01],
|
||||
[-1.72369644e-01, 1.05565332e-01, 6.68043196e-02],
|
||||
],
|
||||
[
|
||||
[2.38748826e-02, -1.18255816e-01, 9.43809375e-02],
|
||||
[-1.04707085e-01, -1.08934477e-01, 2.13641584e-01],
|
||||
[-3.69844258e-01, 1.80118099e-01, 1.89726159e-01],
|
||||
],
|
||||
[
|
||||
[2.57137045e-02, -7.94617534e-02, 5.37480488e-02],
|
||||
[1.22328237e-01, -2.38788679e-01, 1.16460443e-01],
|
||||
[-5.98686993e-01, 3.02203178e-01, 2.96483815e-01],
|
||||
],
|
||||
],
|
||||
]
|
||||
activations = np.array(activations)
|
||||
labels = np.array([[1, 2], [1, 1]])
|
||||
expected_grads = np.array(expected_grads)
|
||||
|
||||
return RnntLossSampleData(
|
||||
vocab_size=3,
|
||||
blank_id=0,
|
||||
logits=torch.from_numpy(activations).to(torch.float32),
|
||||
targets=torch.from_numpy(labels),
|
||||
input_lengths=torch.tensor([4, 4]),
|
||||
target_lengths=torch.tensor([2, 2]),
|
||||
expected_cost=torch.tensor(expected_cost).to(torch.float32),
|
||||
expected_grads=torch.from_numpy(expected_grads),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_sample_small_random(cls, blank_first: bool, device=torch.device("cpu")) -> "RnntLossSampleData":
|
||||
vocab_size = 4
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
num_frames = 4
|
||||
text_len = 2
|
||||
if blank_first:
|
||||
text = np.asarray([1, 3])
|
||||
else:
|
||||
text = np.asarray([0, 2])
|
||||
|
||||
targets = torch.from_numpy(text).unsqueeze(0).to(device)
|
||||
logits = torch.rand([1, num_frames, text_len + 1, vocab_size], requires_grad=True, device=device)
|
||||
input_lengths = torch.tensor([num_frames], device=device)
|
||||
target_lengths = torch.tensor([text_len], device=device)
|
||||
return RnntLossSampleData(
|
||||
vocab_size=vocab_size,
|
||||
blank_id=blank_id,
|
||||
logits=logits,
|
||||
targets=targets,
|
||||
input_lengths=input_lengths,
|
||||
target_lengths=target_lengths,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_sample_medium_random_var_size(cls, blank_first: bool, device=torch.device("cpu")) -> "RnntLossSampleData":
|
||||
vocab_size = 32
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
num_frames = 32
|
||||
text_len = 27
|
||||
min_symbol = 1 if blank_first else 0
|
||||
max_symbol = vocab_size if blank_first else vocab_size - 1
|
||||
batch_size = 4
|
||||
|
||||
rs = np.random.RandomState(2021)
|
||||
text = rs.randint(min_symbol, max_symbol, size=(batch_size, text_len))
|
||||
targets = torch.from_numpy(text).to(device)
|
||||
|
||||
logits = torch.rand([batch_size, num_frames, text_len + 1, vocab_size], requires_grad=True, device=device)
|
||||
input_lengths = torch.tensor([num_frames, num_frames // 2, text_len, text_len // 2], device=device).long()
|
||||
target_lengths = torch.tensor([text_len, text_len - 1, text_len - 3, text_len - 10], device=device)
|
||||
return RnntLossSampleData(
|
||||
vocab_size=vocab_size,
|
||||
blank_id=blank_id,
|
||||
logits=logits,
|
||||
targets=targets,
|
||||
input_lengths=input_lengths,
|
||||
target_lengths=target_lengths,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rnnt_test_helper() -> Type[RNNTTestHelper]:
|
||||
return RNNTTestHelper
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rnn_loss_sample_data() -> Type[RnntLossSampleData]:
|
||||
return RnntLossSampleData
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def fast_conformer_transducer_model():
|
||||
return ASRModel.from_pretrained("stt_en_fastconformer_transducer_large")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def fast_conformer_ctc_model():
|
||||
return ASRModel.from_pretrained("stt_en_fastconformer_ctc_large")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def fast_conformer_hybrid_model():
|
||||
return ASRModel.from_pretrained("parakeet-tdt_ctc-110m")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def canary_1b_flash():
|
||||
return ASRModel.restore_from("/home/TestData/asr/canary/models/canary-1b-flash_HF_20250318.nemo")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def canary_1b_v2():
|
||||
return ASRModel.restore_from("/home/TestData/asr/canary/models/canary-1b-v2_20250809.nemo")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def hybrid_rnnt_ctc_bpe_model_with_prompt():
|
||||
return ASRModel.restore_from("/home/TestData/asr/hybrid_rnnt_ctc_bpe_model_with_prompt.nemo")
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.inference.streaming.buffering.audio_bufferer import AudioBufferer, BatchedAudioBufferer
|
||||
from nemo.collections.asr.inference.streaming.framing.mono_stream import MonoStream
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import MultiStream
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_audios():
|
||||
return torch.ones(83200), torch.ones(118960)
|
||||
|
||||
|
||||
class TestAudioBufferer:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_audio_bufferer(self, test_audios):
|
||||
for audio in test_audios:
|
||||
stream = MonoStream(16000, frame_size_in_secs=2.5, stream_id=0, pad_last_frame=False)
|
||||
stream.load_audio(audio, options=None)
|
||||
|
||||
frame_bufferer = AudioBufferer(16000, buffer_size_in_secs=5.0)
|
||||
|
||||
for frame in iter(stream):
|
||||
frame = frame[0]
|
||||
frame_bufferer.update(frame)
|
||||
buffer = frame_bufferer.get_buffer()
|
||||
|
||||
assert len(buffer) == frame_bufferer.buffer_size
|
||||
assert torch.allclose(buffer[-frame.size :], frame.samples, atol=1e-5)
|
||||
|
||||
|
||||
class TestBatchedAudioBufferer:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_batched_audio_bufferer(self, test_audios):
|
||||
|
||||
multi_stream = MultiStream(n_frames_per_stream=1)
|
||||
for stream_id, audio in enumerate(test_audios):
|
||||
stream = MonoStream(16000, 2.5, stream_id=stream_id, pad_last_frame=False)
|
||||
stream.load_audio(audio, options=None)
|
||||
multi_stream.add_stream(stream, stream_id=stream_id)
|
||||
|
||||
batched_audio_bufferer = BatchedAudioBufferer(16000, buffer_size_in_secs=5.0)
|
||||
|
||||
for frames in iter(multi_stream):
|
||||
buffered_frames, left_paddings = batched_audio_bufferer.update(frames)
|
||||
for idx, frame in enumerate(frames):
|
||||
frame_buffer = buffered_frames[idx]
|
||||
assert torch.allclose(frame_buffer[-frame.size :], frame.samples, atol=1e-5)
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.inference.model_wrappers.ctc_inference_wrapper import CTCInferenceWrapper
|
||||
from nemo.collections.asr.inference.utils.bpe_decoder import BPEDecoder
|
||||
from nemo.collections.asr.inference.utils.text_segment import TextSegment, Word
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bpe_decoder():
|
||||
asr_model = CTCInferenceWrapper(
|
||||
model_name="stt_en_conformer_ctc_small",
|
||||
decoding_cfg=CTCDecodingConfig(),
|
||||
device="cuda" if torch.cuda.is_available() else "cpu",
|
||||
)
|
||||
return BPEDecoder(
|
||||
vocabulary=asr_model.get_vocabulary(),
|
||||
tokenizer=asr_model.tokenizer,
|
||||
confidence_aggregator=min,
|
||||
asr_supported_puncts=asr_model.supported_punctuation(),
|
||||
word_boundary_tolerance=0.0, # Set to 0.0 for easy testing
|
||||
token_duration_in_secs=asr_model.get_model_stride(in_secs=True),
|
||||
)
|
||||
|
||||
|
||||
class TestBPEDecoder:
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
"lorem ipsum dolor sit amet",
|
||||
"this a test sentence",
|
||||
],
|
||||
)
|
||||
def test_group_tokens_into_words(self, bpe_decoder, text):
|
||||
ground_truth_words = text.split()
|
||||
tokens = bpe_decoder.tokenizer.text_to_ids(text)
|
||||
n_tokens = len(tokens)
|
||||
timestamps = [float(i) for i in range(n_tokens)]
|
||||
confidences = [0.1] * n_tokens
|
||||
|
||||
words, need_merge = bpe_decoder.group_tokens_into_words(tokens, timestamps, confidences)
|
||||
assert len(words) == len(ground_truth_words)
|
||||
prev_word_end = -1
|
||||
for word, ground_truth_word in zip(words, ground_truth_words):
|
||||
assert isinstance(word, Word)
|
||||
assert word.text == ground_truth_word
|
||||
assert word.conf == 0.1
|
||||
assert word.end > word.start and word.start >= prev_word_end
|
||||
prev_word_end = word.end
|
||||
assert need_merge == False
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
"lorem ipsum dolor sit amet",
|
||||
"this a test sentence",
|
||||
],
|
||||
)
|
||||
def test_group_tokens_into_segment(self, bpe_decoder, text):
|
||||
tokens = bpe_decoder.tokenizer.text_to_ids(text)
|
||||
n_tokens = len(tokens)
|
||||
timestamps = [float(i) for i in range(n_tokens)]
|
||||
confidences = [0.1] * n_tokens
|
||||
|
||||
segment, need_merge = bpe_decoder.group_tokens_into_segment(tokens, timestamps, confidences)
|
||||
assert isinstance(segment, TextSegment)
|
||||
assert need_merge == False
|
||||
assert segment.text == text
|
||||
assert segment.start == 0.0
|
||||
assert segment.end == (n_tokens - 1) * bpe_decoder.token_duration_in_secs
|
||||
assert segment.conf == 0.1
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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
|
||||
|
||||
from nemo.collections.asr.inference.utils.enums import (
|
||||
ASRDecodingType,
|
||||
ASROutputGranularity,
|
||||
FeatureBufferPaddingMode,
|
||||
PipelineType,
|
||||
RequestType,
|
||||
)
|
||||
|
||||
|
||||
class TestEnums:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASRDecodingType(self):
|
||||
assert ASRDecodingType.from_str("ctc") == ASRDecodingType.CTC
|
||||
assert ASRDecodingType.from_str("RNNT") == ASRDecodingType.RNNT
|
||||
with pytest.raises(ValueError):
|
||||
ASRDecodingType.from_str("invalid")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASROutputGranularity(self):
|
||||
assert ASROutputGranularity.from_str("word") == ASROutputGranularity.WORD
|
||||
assert ASROutputGranularity.from_str("segment") == ASROutputGranularity.SEGMENT
|
||||
with pytest.raises(ValueError):
|
||||
ASROutputGranularity.from_str("invalid")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_PipelineType(self):
|
||||
assert PipelineType.from_str("buffered") == PipelineType.BUFFERED
|
||||
assert PipelineType.from_str("cache_aware") == PipelineType.CACHE_AWARE
|
||||
with pytest.raises(ValueError):
|
||||
PipelineType.from_str("invalid")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_RequestType(self):
|
||||
assert RequestType.from_str("frame") == RequestType.FRAME
|
||||
assert RequestType.from_str("feature_buffer") == RequestType.FEATURE_BUFFER
|
||||
with pytest.raises(ValueError):
|
||||
RequestType.from_str("invalid")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_FeatureBufferPaddingMode(self):
|
||||
assert FeatureBufferPaddingMode.from_str("left") == FeatureBufferPaddingMode.LEFT
|
||||
assert FeatureBufferPaddingMode.from_str("right") == FeatureBufferPaddingMode.RIGHT
|
||||
with pytest.raises(ValueError):
|
||||
FeatureBufferPaddingMode.from_str("invalid")
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.inference.streaming.framing.mono_stream import MonoStream
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import MultiStream
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_audios():
|
||||
return torch.ones(83200), torch.ones(118960)
|
||||
|
||||
|
||||
class TestMonoWavStream:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mono_wav_stream_no_pad(self, test_audios):
|
||||
for audio in test_audios:
|
||||
stream = MonoStream(16000, 2.5, stream_id=0, pad_last_frame=False)
|
||||
stream.load_audio(audio, options=None)
|
||||
audio_len_in_samples = stream.samples.shape[0]
|
||||
i = 0
|
||||
total_samples = 0
|
||||
for frame in iter(stream):
|
||||
total_samples += len(frame[0].samples)
|
||||
i += 1
|
||||
assert total_samples == audio_len_in_samples
|
||||
assert frame[0].is_last == True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mono_wav_stream_with_pad(self, test_audios):
|
||||
for audio in test_audios:
|
||||
stream = MonoStream(16000, 2.5, stream_id=0, pad_last_frame=True)
|
||||
stream.load_audio(audio, options=None)
|
||||
for frame in iter(stream):
|
||||
last_frame_size = frame[0].size
|
||||
assert last_frame_size == stream.frame_size
|
||||
|
||||
|
||||
class TestMultiStream:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_multi_stream(self, test_audios):
|
||||
multi_stream = MultiStream(n_frames_per_stream=1)
|
||||
audio_len_in_samples = {}
|
||||
for stream_id, audio in enumerate(test_audios):
|
||||
stream = MonoStream(16000, 2.5, stream_id=stream_id, pad_last_frame=False)
|
||||
stream.load_audio(audio, options=None)
|
||||
multi_stream.add_stream(stream, stream_id=stream_id)
|
||||
audio_len_in_samples[stream_id] = stream.samples.shape[0]
|
||||
|
||||
total_samples = {}
|
||||
for frames in iter(multi_stream):
|
||||
for frame in frames:
|
||||
total_samples[frame.stream_id] = total_samples.get(frame.stream_id, 0) + frame.size
|
||||
|
||||
assert total_samples == audio_len_in_samples
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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.inference.streaming.decoders.greedy.greedy_ctc_decoder import CTCGreedyDecoder
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_rnnt_decoder import (
|
||||
ClippedRNNTGreedyDecoder,
|
||||
RNNTGreedyDecoder,
|
||||
)
|
||||
|
||||
|
||||
class TestCTCGreedyDecoder:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ctc_greedy_decoder(self):
|
||||
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = CTCGreedyDecoder(vocabulary=vocab)
|
||||
|
||||
assert decoder.blank_id == len(vocab)
|
||||
assert decoder.is_token_silent(len(vocab)) == True
|
||||
|
||||
for i in range(len(vocab)):
|
||||
assert decoder.is_token_silent(i) == False
|
||||
|
||||
for i in range(len(vocab)):
|
||||
assert decoder.is_token_start_of_word(i) == False
|
||||
|
||||
assert decoder.count_silent_tokens([0, 1, 2, 3, 4], 0, 5) == 1
|
||||
assert decoder.count_silent_tokens([0, 1, 2, 3, 4], 0, 3) == 0
|
||||
assert decoder.first_non_silent_token([1, 2, 3, 4], 0, 5) == 0
|
||||
|
||||
log_probs = torch.tensor([[0.1, 0.2, 0.3, 0.4, 0.05], [0.4, 0.3, 0.2, 0.1, 0.05]])
|
||||
assert decoder.get_labels(log_probs) == log_probs.argmax(dim=-1).tolist()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ctc_greedy_decoder_with_previous_token(self):
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = CTCGreedyDecoder(vocabulary=vocab)
|
||||
|
||||
log_probs = torch.tensor([[0.1, 0.2, 0.3, 0.4, 0.05], [0.1, 0.2, 0.3, 0.4, 0.05], [0.4, 0.3, 0.2, 0.1, 0.05]])
|
||||
last_token_id = 3
|
||||
output = decoder(log_probs, compute_confidence=False, previous=last_token_id)
|
||||
assert output["tokens"] == [0]
|
||||
assert output["timesteps"] == [2]
|
||||
|
||||
output = decoder(log_probs, compute_confidence=False, previous=None)
|
||||
assert output["tokens"] == [3, 0]
|
||||
assert output["timesteps"] == [0, 2]
|
||||
|
||||
|
||||
class TestRNNTGreedyDecoder:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rnnt_greedy_decoder(self):
|
||||
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = RNNTGreedyDecoder(vocab)
|
||||
|
||||
blank_id = len(vocab)
|
||||
assert decoder.blank_id == blank_id
|
||||
assert decoder.is_token_silent(blank_id) == True
|
||||
|
||||
for i in range(len(vocab)):
|
||||
assert decoder.is_token_silent(i) == False
|
||||
|
||||
for i in range(len(vocab)):
|
||||
assert decoder.is_token_start_of_word(i) == False
|
||||
|
||||
assert decoder.count_silent_tokens([0, 1, 2, 3, 4], 0, 5) == 1
|
||||
assert decoder.count_silent_tokens([0, 1, 2, 3, 4], 0, 3) == 0
|
||||
assert decoder.first_non_silent_token([1, 2, 3, 4], 0, 5) == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_call_confidence_passthrough(self):
|
||||
"""Per-token confidences are propagated to the output, aligned with the decoded tokens."""
|
||||
decoder = RNNTGreedyDecoder(["a", "b", "c", "d"])
|
||||
|
||||
output, _, new_offset = decoder(
|
||||
global_timestamps=torch.tensor([0, 1, 2, 3]),
|
||||
tokens=torch.tensor([0, 1, 2, 3]),
|
||||
length=5,
|
||||
offset=0,
|
||||
confidences=torch.tensor([0.9, 0.8, 0.7, 0.6]),
|
||||
)
|
||||
|
||||
assert output["tokens"] == [0, 1, 2, 3]
|
||||
assert output["confidences"] == pytest.approx([0.9, 0.8, 0.7, 0.6])
|
||||
assert new_offset == 4
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_call_confidence_trimmed_by_offset(self):
|
||||
"""Confidences are trimmed by `offset` together with tokens/timestamps."""
|
||||
decoder = RNNTGreedyDecoder(["a", "b", "c", "d"])
|
||||
|
||||
output, _, _ = decoder(
|
||||
global_timestamps=torch.tensor([0, 1, 2, 3]),
|
||||
tokens=torch.tensor([0, 1, 2, 3]),
|
||||
length=5,
|
||||
offset=2,
|
||||
confidences=[0.9, 0.8, 0.7, 0.6],
|
||||
)
|
||||
|
||||
assert output["tokens"] == [2, 3]
|
||||
assert output["confidences"] == pytest.approx([0.7, 0.6])
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_call_confidence_defaults_to_zero(self):
|
||||
"""Without confidences, zeros are returned for each decoded token (backward compatible)."""
|
||||
decoder = RNNTGreedyDecoder(["a", "b", "c", "d"])
|
||||
|
||||
output, _, _ = decoder(
|
||||
global_timestamps=torch.tensor([0, 1, 2, 3]),
|
||||
tokens=torch.tensor([0, 1, 2, 3]),
|
||||
length=5,
|
||||
offset=0,
|
||||
confidences=None,
|
||||
)
|
||||
|
||||
assert output["tokens"] == [0, 1, 2, 3]
|
||||
assert output["confidences"] == [0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
class TestClippedRNNTGreedyDecoder:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_confidence_passthrough(self):
|
||||
"""Per-token confidences are clipped with the same mask as tokens/timesteps."""
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = ClippedRNNTGreedyDecoder(vocabulary=vocab, tokens_per_frame=4, endpointer=None)
|
||||
|
||||
tokens = torch.tensor([0, 1, 2, 3])
|
||||
timesteps = torch.tensor([0, 1, 2, 3])
|
||||
confidences = torch.tensor([0.9, 0.8, 0.7, 0.6])
|
||||
|
||||
clipped_output, _, is_eou, _, _ = decoder(
|
||||
global_timesteps=timesteps,
|
||||
tokens=tokens,
|
||||
clip_start=0,
|
||||
clip_end=4,
|
||||
alignment_length=4,
|
||||
is_last=True,
|
||||
is_start=True,
|
||||
confidences=confidences,
|
||||
)
|
||||
|
||||
assert is_eou is True
|
||||
assert clipped_output["tokens"] == [0, 1, 2, 3]
|
||||
assert clipped_output["confidences"] == pytest.approx([0.9, 0.8, 0.7, 0.6])
|
||||
assert len(clipped_output["confidences"]) == len(clipped_output["tokens"])
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_confidence_clipping_follows_token_mask(self):
|
||||
"""Confidences outside the clip range are dropped together with their tokens."""
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = ClippedRNNTGreedyDecoder(vocabulary=vocab, tokens_per_frame=4, endpointer=None)
|
||||
|
||||
tokens = torch.tensor([0, 1, 2, 3])
|
||||
timesteps = torch.tensor([0, 1, 2, 3])
|
||||
confidences = torch.tensor([0.9, 0.8, 0.7, 0.6])
|
||||
|
||||
clipped_output, _, _, _, _ = decoder(
|
||||
global_timesteps=timesteps,
|
||||
tokens=tokens,
|
||||
clip_start=1,
|
||||
clip_end=4,
|
||||
alignment_length=4,
|
||||
is_last=True,
|
||||
is_start=True,
|
||||
confidences=confidences,
|
||||
)
|
||||
|
||||
assert clipped_output["tokens"] == [1, 2, 3]
|
||||
assert clipped_output["confidences"] == pytest.approx([0.8, 0.7, 0.6])
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_confidence_defaults_to_zero(self):
|
||||
"""Without confidences, zeros are returned for each clipped token (backward compatible)."""
|
||||
vocab = ["a", "b", "c", "d"]
|
||||
decoder = ClippedRNNTGreedyDecoder(vocabulary=vocab, tokens_per_frame=4, endpointer=None)
|
||||
|
||||
tokens = torch.tensor([0, 1, 2, 3])
|
||||
timesteps = torch.tensor([0, 1, 2, 3])
|
||||
|
||||
clipped_output, _, _, _, _ = decoder(
|
||||
global_timesteps=timesteps,
|
||||
tokens=tokens,
|
||||
clip_start=0,
|
||||
clip_end=4,
|
||||
alignment_length=4,
|
||||
is_last=True,
|
||||
is_start=True,
|
||||
confidences=None,
|
||||
)
|
||||
|
||||
assert clipped_output["tokens"] == [0, 1, 2, 3]
|
||||
assert clipped_output["confidences"] == [0.0, 0.0, 0.0, 0.0]
|
||||
@@ -0,0 +1,231 @@
|
||||
# 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.inference.streaming.endpointing.greedy.greedy_ctc_endpointing import CTCGreedyEndpointing
|
||||
from nemo.collections.asr.inference.streaming.endpointing.greedy.greedy_rnnt_endpointing import RNNTGreedyEndpointing
|
||||
from nemo.collections.asr.inference.utils.endpointing_utils import millisecond_to_frames
|
||||
|
||||
|
||||
class TestGreedyEndpointing:
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"inputs, expected",
|
||||
[
|
||||
((100, 80), 2),
|
||||
((100, 100), 1),
|
||||
((100, 40), 3),
|
||||
],
|
||||
)
|
||||
def test_millisecond_to_frames(self, inputs, expected):
|
||||
assert millisecond_to_frames(*inputs) == expected
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_endpointing_with_negative_stop_history_eou(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(vocabulary=["a", "b", "c"], ms_per_timestep=100, stop_history_eou=-1)
|
||||
if isinstance(greedy_endpointing, CTCGreedyEndpointing):
|
||||
b = len(greedy_endpointing.greedy_ctc_decoder.vocabulary)
|
||||
else:
|
||||
b = len(greedy_endpointing.greedy_rnnt_decoder.vocabulary)
|
||||
emissions = [0, 1, 2, b, b, b, b, b, b, b, b, b]
|
||||
|
||||
# False case, because stop_history_eou = -1
|
||||
assert greedy_endpointing.detect_eou_given_emissions(emissions, 3) == (False, -1)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_endpointing_with_positive_stop_history_eou(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"], ms_per_timestep=20, stop_history_eou=100, residue_tokens_at_end=0
|
||||
)
|
||||
if isinstance(greedy_endpointing, CTCGreedyEndpointing):
|
||||
b = len(greedy_endpointing.greedy_ctc_decoder.vocabulary)
|
||||
else:
|
||||
b = len(greedy_endpointing.greedy_rnnt_decoder.vocabulary)
|
||||
emissions = [0, 1, 2, b, b, b, b, b, b, b, b, b]
|
||||
|
||||
for pivot_point in range(len(emissions)):
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_emissions(emissions, pivot_point)
|
||||
assert eou_detected == True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detect_eou_given_timestamps_empty_inputs(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"], ms_per_timestep=80, stop_history_eou=100, residue_tokens_at_end=0
|
||||
)
|
||||
|
||||
# Test with empty timesteps and tokens
|
||||
timesteps = torch.tensor([])
|
||||
tokens = torch.tensor([])
|
||||
alignment_length = 10
|
||||
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_timestamps(
|
||||
timesteps, tokens, alignment_length
|
||||
)
|
||||
assert eou_detected == False
|
||||
assert eou_detected_at == -1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detect_eou_given_timestamps_disabled_stop_history(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"],
|
||||
ms_per_timestep=80,
|
||||
stop_history_eou=-1, # Disabled
|
||||
residue_tokens_at_end=0,
|
||||
)
|
||||
|
||||
timesteps = torch.tensor([0, 2, 4, 6])
|
||||
tokens = torch.tensor([0, 1, 2, 3])
|
||||
alignment_length = 10
|
||||
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_timestamps(
|
||||
timesteps, tokens, alignment_length
|
||||
)
|
||||
assert eou_detected == False
|
||||
assert eou_detected_at == -1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detect_eou_given_timestamps_trailing_silence(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"], ms_per_timestep=20, stop_history_eou=80, residue_tokens_at_end=0
|
||||
)
|
||||
|
||||
# Last token at position 5, alignment_length is 10
|
||||
# Trailing silence = 10 - 4 - 1 = 5 frames > stop_history_eou (4)
|
||||
timesteps = torch.tensor([0, 1, 2, 3, 4])
|
||||
tokens = torch.tensor([0, 1, 2, 3, 4])
|
||||
alignment_length = 10
|
||||
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_timestamps(
|
||||
timesteps, tokens, alignment_length
|
||||
)
|
||||
assert eou_detected == True
|
||||
# eou_detected_at = 4 + 1 + 4//2 = 7
|
||||
assert eou_detected_at == 7
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detect_eou_given_timestamps_no_trailing_silence(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"], ms_per_timestep=20, stop_history_eou=80, residue_tokens_at_end=0
|
||||
)
|
||||
|
||||
# Last token at position 8, alignment_length is 10
|
||||
# Trailing silence = 10 - 8 - 1 = 1 frame < stop_history_eou (4)
|
||||
timesteps = torch.tensor([0, 1, 2, 3, 8])
|
||||
tokens = torch.tensor([0, 1, 2, 3, 4])
|
||||
alignment_length = 10
|
||||
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_timestamps(
|
||||
timesteps, tokens, alignment_length
|
||||
)
|
||||
assert eou_detected == False
|
||||
assert eou_detected_at == -1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detect_eou_given_timestamps_gap_detection(self):
|
||||
for endpointing_cls in [CTCGreedyEndpointing, RNNTGreedyEndpointing]:
|
||||
greedy_endpointing = endpointing_cls(
|
||||
vocabulary=["a", "b", "c"], ms_per_timestep=20, stop_history_eou=80, residue_tokens_at_end=0
|
||||
)
|
||||
|
||||
# Large gap between tokens: 8 - 2 - 1 = 5 frames > stop_history_eou (4)
|
||||
timesteps = torch.tensor([0, 2, 8, 9])
|
||||
tokens = torch.tensor([0, 1, 2, 3])
|
||||
alignment_length = 10
|
||||
|
||||
eou_detected, eou_detected_at = greedy_endpointing.detect_eou_given_timestamps(
|
||||
timesteps, tokens, alignment_length
|
||||
)
|
||||
assert eou_detected == True
|
||||
# eou_detected_at = 2 + 1 + 4//2 = 5
|
||||
assert eou_detected_at == 5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rnnt_vad_endpointing_disabled(self):
|
||||
rnnt_endpointing = RNNTGreedyEndpointing(
|
||||
vocabulary=["a", "b", "c"],
|
||||
ms_per_timestep=100,
|
||||
effective_buffer_size_in_secs=None, # VAD disabled
|
||||
stop_history_eou=100,
|
||||
)
|
||||
|
||||
# Test with VAD segments - should raise ValueError since VAD is disabled
|
||||
vad_segments = torch.tensor([[0.0, 1.0], [1.5, 2.5]])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Effective buffer size in seconds is required for VAD-based EOU detection"
|
||||
):
|
||||
rnnt_endpointing.detect_eou_vad(vad_segments)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rnnt_vad_endpointing_enabled_no_eou(self):
|
||||
rnnt_endpointing = RNNTGreedyEndpointing(
|
||||
vocabulary=["a", "b", "c"],
|
||||
ms_per_timestep=100,
|
||||
effective_buffer_size_in_secs=2.0, # VAD enabled
|
||||
stop_history_eou=100,
|
||||
)
|
||||
|
||||
# Test with VAD segments that don't trigger EOU
|
||||
vad_segments = torch.tensor([[0.0, 1.45], [1.5, 2.0]])
|
||||
eou_detected, eou_detected_at = rnnt_endpointing.detect_eou_vad(vad_segments, stop_history_eou=100)
|
||||
|
||||
assert eou_detected == False
|
||||
assert eou_detected_at == -1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rnnt_vad_endpointing_enabled_with_eou(self):
|
||||
rnnt_endpointing = RNNTGreedyEndpointing(
|
||||
vocabulary=["a", "b", "c"],
|
||||
ms_per_timestep=100,
|
||||
effective_buffer_size_in_secs=2.0, # VAD enabled
|
||||
stop_history_eou=100,
|
||||
)
|
||||
|
||||
# Test with VAD segments that should trigger EOU
|
||||
# Create segments with enough silence to trigger EOU
|
||||
vad_segments = torch.tensor([[0.0, 0.5], [1.0, 2.0]]) # Gap of 0.5s between segments
|
||||
eou_detected, eou_detected_at = rnnt_endpointing.detect_eou_vad(vad_segments, stop_history_eou=100)
|
||||
|
||||
# This should detect EOU if the silence gap is sufficient
|
||||
# The exact behavior depends on the VAD logic implementation
|
||||
assert eou_detected == True
|
||||
assert eou_detected_at == 5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rnnt_vad_endpointing_enabled_with_eou_at_end(self):
|
||||
rnnt_endpointing = RNNTGreedyEndpointing(
|
||||
vocabulary=["a", "b", "c"],
|
||||
ms_per_timestep=100,
|
||||
effective_buffer_size_in_secs=2.0, # VAD enabled
|
||||
stop_history_eou=100,
|
||||
)
|
||||
|
||||
# Test with VAD segments that should trigger EOU
|
||||
# Create segments with enough silence to trigger EOU
|
||||
vad_segments = torch.tensor([[0.0, 0.5], [1.0, 1.8]]) # Gap of 0.5s between segments
|
||||
eou_detected, eou_detected_at = rnnt_endpointing.detect_eou_vad(vad_segments, stop_history_eou=100)
|
||||
|
||||
# This should detect EOU if the silence gap is sufficient
|
||||
# The exact behavior depends on the VAD logic implementation
|
||||
assert eou_detected == True
|
||||
assert eou_detected_at == 18
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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.inference.streaming.buffering.incremental_audio_bufferer import IncrementalAudioBufferer
|
||||
from nemo.collections.asr.inference.streaming.framing.mono_stream import MonoStream
|
||||
from nemo.collections.asr.inference.streaming.framing.request import Frame
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_audios():
|
||||
return torch.ones(83200), torch.ones(118960)
|
||||
|
||||
|
||||
def _make_frame(samples: torch.Tensor, stream_id: int = 0, is_first: bool = False, is_last: bool = False) -> Frame:
|
||||
return Frame(
|
||||
samples=samples,
|
||||
stream_id=stream_id,
|
||||
is_first=is_first,
|
||||
is_last=is_last,
|
||||
)
|
||||
|
||||
|
||||
class TestIncrementalAudioBufferer:
|
||||
"""Tests for IncrementalAudioBufferer."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_valid_params(self):
|
||||
"""Constructor with valid params initializes buffer and capacity."""
|
||||
sample_rate = 16000
|
||||
buffer_size_in_secs = 5.0
|
||||
chunk_size_in_secs = 2.5
|
||||
overlap_size_in_secs = 2.5
|
||||
buf = IncrementalAudioBufferer(
|
||||
sample_rate=sample_rate,
|
||||
buffer_size_in_secs=buffer_size_in_secs,
|
||||
chunk_size_in_secs=chunk_size_in_secs,
|
||||
overlap_size_in_secs=overlap_size_in_secs,
|
||||
)
|
||||
assert buf.sample_rate == sample_rate
|
||||
assert buf.buffer_size == int(buffer_size_in_secs * sample_rate)
|
||||
assert buf.chunk_size == int(chunk_size_in_secs * sample_rate)
|
||||
assert buf.overlap_size == int(overlap_size_in_secs * sample_rate)
|
||||
assert buf.sample_buffer.shape[0] == buf.buffer_size
|
||||
assert buf.remaining_capacity == buf.buffer_size
|
||||
assert buf.head == 0
|
||||
assert not buf.is_full()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_overlap_negative_raises(self):
|
||||
"""Overlap < 0 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Overlap size.*must satisfy"):
|
||||
IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=-0.1,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_overlap_exceeds_buffer_raises(self):
|
||||
"""Overlap > buffer_size raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Overlap size.*must satisfy"):
|
||||
IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=6.0,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_buffer_not_divisible_by_chunk_raises(self):
|
||||
"""Buffer size not divisible by chunk size raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Buffer size.*must be divisible by chunk size"):
|
||||
IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=1.7,
|
||||
overlap_size_in_secs=1.7,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constructor_overlap_not_divisible_by_chunk_raises(self):
|
||||
"""Overlap not divisible by chunk size raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Overlap size.*must be divisible by chunk size"):
|
||||
IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=2.0,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_single_frame(self):
|
||||
"""Single frame update fills start of buffer and decreases remaining_capacity."""
|
||||
buf = IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=2.5,
|
||||
)
|
||||
chunk_size = 40000 # 2.5 * 16000
|
||||
samples = torch.arange(chunk_size, dtype=torch.float32)
|
||||
frame = _make_frame(samples)
|
||||
buf.update(frame)
|
||||
assert buf.head == chunk_size
|
||||
assert buf.remaining_capacity == buf.buffer_size - chunk_size
|
||||
assert torch.allclose(buf.sample_buffer[:chunk_size], samples, atol=1e-5)
|
||||
assert not buf.is_full()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_multiple_frames_until_full(self):
|
||||
"""Multiple updates fill buffer; is_full() becomes True when capacity is 0."""
|
||||
buf = IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=2.5,
|
||||
)
|
||||
chunk_size = 40000
|
||||
for i in range(2):
|
||||
samples = torch.full((chunk_size,), float(i), dtype=torch.float32)
|
||||
frame = _make_frame(samples)
|
||||
buf.update(frame)
|
||||
assert buf.remaining_capacity == 0
|
||||
assert buf.is_full()
|
||||
assert buf.head == buf.buffer_size
|
||||
assert torch.allclose(buf.sample_buffer[:chunk_size], torch.zeros(chunk_size), atol=1e-5)
|
||||
assert torch.allclose(buf.sample_buffer[chunk_size:], torch.ones(chunk_size), atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_frame_exceeds_buffer_raises(self):
|
||||
"""Frame larger than buffer size raises RuntimeError."""
|
||||
buf = IncrementalAudioBufferer(
|
||||
sample_rate=16000,
|
||||
buffer_size_in_secs=5.0,
|
||||
chunk_size_in_secs=2.5,
|
||||
overlap_size_in_secs=2.5,
|
||||
)
|
||||
oversized = torch.zeros(buf.buffer_size + 1)
|
||||
frame = _make_frame(oversized)
|
||||
with pytest.raises(RuntimeError, match="Frame size.*exceeds buffer size"):
|
||||
buf.update(frame)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_incremental_audio_bufferer_with_mono_stream(self, test_audios):
|
||||
"""Integration: feed frames from MonoStream; buffer contents and paddings are consistent."""
|
||||
sample_rate = 16000
|
||||
chunk_size_in_secs = 2.5
|
||||
buffer_size_in_secs = 5.0
|
||||
overlap_size_in_secs = 2.5
|
||||
for audio in test_audios:
|
||||
stream = MonoStream(sample_rate, frame_size_in_secs=chunk_size_in_secs, stream_id=0, pad_last_frame=False)
|
||||
stream.load_audio(audio, options=None)
|
||||
buf = IncrementalAudioBufferer(
|
||||
sample_rate=sample_rate,
|
||||
buffer_size_in_secs=buffer_size_in_secs,
|
||||
chunk_size_in_secs=chunk_size_in_secs,
|
||||
overlap_size_in_secs=overlap_size_in_secs,
|
||||
)
|
||||
for frame in iter(stream):
|
||||
frame = frame[0]
|
||||
buf.update(frame)
|
||||
# Newest frame is at [head - frame.size : head]; after update it's at [head - frame.size : head]
|
||||
start = buf.head - frame.size
|
||||
assert torch.allclose(buf.sample_buffer[start : buf.head], frame.samples, atol=1e-5)
|
||||
assert buf.remaining_capacity == max(0, buf.remaining_capacity)
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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
|
||||
|
||||
nemo_text_processing = pytest.importorskip("nemo_text_processing", reason="Requires nemo_text_processing")
|
||||
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def en_itn_model():
|
||||
return AlignmentPreservingInverseNormalizer(
|
||||
lang="en", input_case=AlignmentPreservingInverseNormalizer.LOWER_CASED, cache_dir=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def de_itn_model():
|
||||
return AlignmentPreservingInverseNormalizer(
|
||||
lang="de", input_case=AlignmentPreservingInverseNormalizer.LOWER_CASED, cache_dir=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def es_itn_model():
|
||||
return AlignmentPreservingInverseNormalizer(
|
||||
lang="es", input_case=AlignmentPreservingInverseNormalizer.LOWER_CASED, cache_dir=None
|
||||
)
|
||||
|
||||
|
||||
class TestAlignmentPreservingInverseNormalizer:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_cardinal_en(self, en_itn_model):
|
||||
text = "zzz minus twenty five thousand thirty seven zzz"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "minus", "twenty", "five", "thousand", "thirty", "seven", "zzz"]
|
||||
assert owords == ["zzz", "-25037", "zzz"]
|
||||
assert alignment == [([0], [0], "name"), ([1, 2, 3, 4, 5, 6], [1], "cardinal"), ([7], [2], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_time_en(self, en_itn_model):
|
||||
text = "zzz eleven fifty five p m zzz"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "eleven", "fifty", "five", "p", "m", "zzz"]
|
||||
assert owords == ["zzz", "11:55", "p.m.", "zzz"]
|
||||
assert alignment == [([0], [0], "name"), ([1, 2, 3, 4, 5], [1, 2], "time"), ([6], [3], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_money_en(self, en_itn_model):
|
||||
text = "zzz two hundred fifty dollars zzz"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "two", "hundred", "fifty", "dollars", "zzz"]
|
||||
assert owords == ["zzz", "$250", "zzz"]
|
||||
assert alignment == [([0], [0], "name"), ([1, 2, 3, 4], [1], "money"), ([5], [2], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_combo_en(self, en_itn_model):
|
||||
text = "eleven twenty seven fifty seven october twenty fourth nineteen seventy"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == [
|
||||
"eleven",
|
||||
"twenty",
|
||||
"seven",
|
||||
"fifty",
|
||||
"seven",
|
||||
"october",
|
||||
"twenty",
|
||||
"fourth",
|
||||
"nineteen",
|
||||
"seventy",
|
||||
]
|
||||
assert owords == ["1120", "07:57", "october", "24", "1970"]
|
||||
assert alignment == [([0, 1], [0], "date"), ([2, 3, 4], [1], "time"), ([5, 6, 7, 8, 9], [2, 3, 4], "date")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_measure_en(self, en_itn_model):
|
||||
text = "it is two hundred fifty meters long"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["it", "is", "two", "hundred", "fifty", "meters", "long"]
|
||||
assert owords == ["it", "is", "250", "m", "long"]
|
||||
assert alignment == [
|
||||
([0], [0], "name"),
|
||||
([1], [1], "name"),
|
||||
([2, 3, 4, 5], [2, 3], "measure"),
|
||||
([6], [4], "name"),
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_sterling_en(self, en_itn_model):
|
||||
text = "trade turnover of three million pounds sterling"
|
||||
iwords, owords, alignment = en_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["trade", "turnover", "of", "three", "million", "pounds", "sterling"]
|
||||
assert owords == ["trade", "turnover", "of", "£3", "million"]
|
||||
assert alignment == [
|
||||
([0], [0], "name"),
|
||||
([1], [1], "name"),
|
||||
([2], [2], "name"),
|
||||
([3, 4, 5, 6], [3, 4], "money"),
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_time_de(self, de_itn_model):
|
||||
text = "zzz drei uhr zwanzig zzz"
|
||||
iwords, owords, alignment = de_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "drei", "uhr", "zwanzig", "zzz"]
|
||||
assert owords == ['zzz', '03:20', 'Uhr', 'zzz']
|
||||
assert alignment == [([0], [0], "name"), ([1, 2, 3], [1, 2], "time"), ([4], [3], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_money_de(self, de_itn_model):
|
||||
text = "zzz zwei hundert fünfzig dollar zzz"
|
||||
iwords, owords, alignment = de_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "zwei", "hundert", "fünfzig", "dollar", "zzz"]
|
||||
assert owords == ["zzz", "$250", "zzz"]
|
||||
assert alignment == [([0], [0], "name"), ([1, 2, 3, 4], [1], "money"), ([5], [2], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_cardinal_de(self, de_itn_model):
|
||||
text = "zzz minus fünfundzwanzigtausendsiebenunddreißig zzz"
|
||||
iwords, owords, alignment = de_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["zzz", "minus", "fünfundzwanzigtausendsiebenunddreißig", "zzz"]
|
||||
assert owords == ["zzz", "-25037", "zzz"]
|
||||
assert alignment == [([0], [0], "name"), ([1, 2], [1], "cardinal"), ([3], [2], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_measure_de(self, de_itn_model):
|
||||
text = "es ist zweihundertfünfzig meter lang"
|
||||
iwords, owords, alignment = de_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == ["es", "ist", "zweihundertfünfzig", "meter", "lang"]
|
||||
assert owords == ["es", "ist", "250", "m", "lang"]
|
||||
assert alignment == [([0], [0], "name"), ([1], [1], "name"), ([2, 3], [2, 3], "measure"), ([4], [4], "name")]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_word_alignment_combo_es(self, es_itn_model):
|
||||
text = "un mil intereses al diez por ciento a la semana estándar derecho diez por ciento"
|
||||
iwords, owords, alignment = es_itn_model.get_word_alignment(text, sep=" ")
|
||||
assert iwords == [
|
||||
'un',
|
||||
'mil',
|
||||
'intereses',
|
||||
'al',
|
||||
'diez',
|
||||
'por',
|
||||
'ciento',
|
||||
'a',
|
||||
'la',
|
||||
'semana',
|
||||
'estándar',
|
||||
'derecho',
|
||||
'diez',
|
||||
'por',
|
||||
'ciento',
|
||||
]
|
||||
assert owords == ['1000', 'intereses', 'al', '10', '%', 'a', 'la', 'semana', 'estándar', 'derecho', '10', '%']
|
||||
assert alignment == [
|
||||
([0, 1], [0], 'cardinal'),
|
||||
([2], [1], 'name'),
|
||||
([3], [2], 'name'),
|
||||
([4, 5, 6], [3, 4], 'measure'),
|
||||
([7], [5], 'name'),
|
||||
([8], [6], 'name'),
|
||||
([9], [7], 'name'),
|
||||
([10], [8], 'name'),
|
||||
([11], [9], 'name'),
|
||||
([12, 13, 14], [10, 11], 'measure'),
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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
|
||||
from nemo.collections.asr.inference.utils.itn_utils import (
|
||||
fallback_to_trivial_alignment,
|
||||
find_tokens,
|
||||
get_semiotic_class,
|
||||
get_trivial_alignment,
|
||||
split_text,
|
||||
)
|
||||
|
||||
|
||||
class TestItnUtils:
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected_words, expected_n",
|
||||
[
|
||||
("hello world how are you", ["hello", "world", "how", "are", "you"], 5),
|
||||
("hello", ["hello"], 1),
|
||||
("a hello world b ccc d e", ["a", "hello", "world", "b", "ccc", "d", "e"], 7),
|
||||
(" a hello world b ccc d e", ["a", "hello", "world", "b", "ccc", "d", "e"], 7),
|
||||
("a hello world b ccc d e ", ["a", "hello", "world", "b", "ccc", "d", "e"], 7),
|
||||
(" a hello world b ccc d e ", ["a", "hello", "world", "b", "ccc", "d", "e"], 7),
|
||||
(" a hello world b ccc d e ", ["a", "hello", "world", "b", "ccc", "d", "e"], 7),
|
||||
],
|
||||
)
|
||||
def test_split_text(self, text, expected_words, expected_n):
|
||||
words, n = split_text(text)
|
||||
assert words == expected_words
|
||||
assert n == expected_n
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_semiotic_class(self):
|
||||
tokens = [{"tokens": {"name": "hello"}}]
|
||||
semiotic_class = get_semiotic_class(tokens)
|
||||
assert semiotic_class == "name"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_find_tokens(self):
|
||||
text = "tokens {name: hello} tokens {name: world} tokens {name: how} tokens {name: are} tokens {name: you}"
|
||||
tokens = find_tokens(text)
|
||||
assert tokens == [
|
||||
"tokens {name: hello}",
|
||||
"tokens {name: world}",
|
||||
"tokens {name: how}",
|
||||
"tokens {name: are}",
|
||||
"tokens {name: you}",
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_trivial_alignment(self):
|
||||
N = 5
|
||||
i_shift = 1
|
||||
o_shift = 2
|
||||
alignment = get_trivial_alignment(N, i_shift, o_shift)
|
||||
assert alignment == [
|
||||
([1], [2], "name"),
|
||||
([2], [3], "name"),
|
||||
([3], [4], "name"),
|
||||
([4], [5], "name"),
|
||||
([5], [6], "name"),
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fallback_to_trivial_alignment(self):
|
||||
input_words = ["hello", "world", "how", "are", "you"]
|
||||
input_words, output_words, word_alignment = fallback_to_trivial_alignment(input_words)
|
||||
assert input_words == ["hello", "world", "how", "are", "you"]
|
||||
assert output_words == ["hello", "world", "how", "are", "you"]
|
||||
assert word_alignment == [
|
||||
([0], [0], "name"),
|
||||
([1], [1], "name"),
|
||||
([2], [2], "name"),
|
||||
([3], [3], "name"),
|
||||
([4], [4], "name"),
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
# 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
|
||||
|
||||
from nemo.collections.asr.inference.utils.lcs_merge import MergingStrategy, lcs_merge, longest_common_substring
|
||||
|
||||
|
||||
class TestLCSMerge:
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"buffer, data, expected_start1, expected_start2, expected_length",
|
||||
[
|
||||
([1, 2, 3, 4, 5], [3, 4, 5, 6, 7], 2, 0, 3),
|
||||
([1, 2], [1], 0, 0, 1),
|
||||
([1], [1, 2], 0, 0, 1),
|
||||
(
|
||||
[1, 2, 3, 11, 12, 13, 4, 5, 6],
|
||||
[1, 2, 3, 4, 5, 6, 11, 12, 13],
|
||||
6,
|
||||
3,
|
||||
3,
|
||||
),
|
||||
([1, 2, 3, 11, 12, 13, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7, 11, 12, 13], 6, 3, 4),
|
||||
([1, 2, 3], [4, 5, 6], -1, -1, 0),
|
||||
([1, 2, 3, 1, 2, 3], [1, 2, 3], 3, 0, 3),
|
||||
([], [], -1, -1, 0),
|
||||
([1, 2, 3], [], -1, -1, 0),
|
||||
([1, 1, 1, 1, 1], [1, 1], 3, 0, 2),
|
||||
],
|
||||
)
|
||||
def test_longest_common_substring(self, buffer, data, expected_start1, expected_start2, expected_length):
|
||||
start1, start2, length = longest_common_substring(buffer, data)
|
||||
assert (start1, start2, length) == (expected_start1, expected_start2, expected_length)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"buffer, data, search_size, min_lcs_length, merging_strategy, expected_result",
|
||||
[
|
||||
([1, 2, 3, 4, 5], [3, 4, 5, 6, 7], 5, 1, MergingStrategy.LCSUBSTR, [1, 2, 3, 4, 5, 6, 7]),
|
||||
([1, 2, 3, 4, 5], [6, 7, 8, 9, 10], 5, 1, MergingStrategy.LCSUBSTR, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
([1, 2, 3, 4, 5], [3, 4, 5, 6, 7, 8, 9], 5, 1, MergingStrategy.LCSUBSTR, [1, 2, 3, 4, 5, 6, 7, 8, 9]),
|
||||
([1, 2, 3, 4, 9], [3, 4, 5, 6, 7], 5, 1, MergingStrategy.LCS, [1, 2, 3, 4, 5, 6, 7]),
|
||||
([1, 2, 3, 4, 5], [3, 4, 5, 6, 7], 1, 2, MergingStrategy.LCSUBSTR, [1, 2, 3, 4, 5, 3, 4, 5, 6, 7]),
|
||||
],
|
||||
)
|
||||
def test_lcs_merge(self, buffer, data, search_size, min_lcs_length, merging_strategy, expected_result):
|
||||
result = lcs_merge(
|
||||
buffer,
|
||||
data,
|
||||
search_size=search_size,
|
||||
min_lcs_length=min_lcs_length,
|
||||
merging_strategy=merging_strategy,
|
||||
sep_id=None,
|
||||
)
|
||||
assert result == expected_result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_empty_buffer(self):
|
||||
"""Test that empty buffer returns just the data."""
|
||||
result = lcs_merge([], [1, 2, 3], search_size=5, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_empty_data(self):
|
||||
"""Test that empty data returns the buffer unchanged."""
|
||||
result = lcs_merge([1, 2, 3], [], search_size=5, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_both_empty(self):
|
||||
"""Test merging two empty lists."""
|
||||
result = lcs_merge([], [], search_size=5, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("search_size", [0, -1, -10])
|
||||
def test_lcs_merge_invalid_search_size(self, search_size):
|
||||
"""Test that search_size <= 0 results in simple concatenation with separator."""
|
||||
buffer = [1, 2, 3]
|
||||
data = [4, 5, 6]
|
||||
result = lcs_merge(
|
||||
buffer, data, search_size=search_size, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR
|
||||
)
|
||||
assert result == [1, 2, 3, 4, 5, 6]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_with_sep_id_no_overlap(self):
|
||||
"""Test separator is inserted when no LCS is found."""
|
||||
buffer = [1, 2, 3]
|
||||
data = [7, 8, 9]
|
||||
sep_id = [100]
|
||||
result = lcs_merge(
|
||||
buffer, data, search_size=3, sep_id=sep_id, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR
|
||||
)
|
||||
assert result == [1, 2, 3, 100, 7, 8, 9]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_with_multi_token_sep_id(self):
|
||||
"""Test separator with multiple tokens is inserted correctly."""
|
||||
buffer = [1, 2, 3]
|
||||
data = [7, 8, 9]
|
||||
sep_id = [100, 101, 102]
|
||||
result = lcs_merge(
|
||||
buffer, data, search_size=3, sep_id=sep_id, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR
|
||||
)
|
||||
assert result == [1, 2, 3, 100, 101, 102, 7, 8, 9]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_with_sep_id_when_overlap_exists(self):
|
||||
"""Test separator is NOT inserted when LCS is found."""
|
||||
buffer = [1, 2, 3, 4, 5]
|
||||
data = [4, 5, 6, 7]
|
||||
sep_id = [100]
|
||||
result = lcs_merge(
|
||||
buffer, data, search_size=5, sep_id=sep_id, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR
|
||||
)
|
||||
assert result == [1, 2, 3, 4, 5, 6, 7]
|
||||
assert 100 not in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_search_size_larger_than_buffer(self):
|
||||
"""Test that search_size larger than buffer still works correctly."""
|
||||
buffer = [1, 2, 3]
|
||||
data = [2, 3, 4, 5]
|
||||
result = lcs_merge(buffer, data, search_size=100, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3, 4, 5]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_search_size_limits_overlap_detection(self):
|
||||
"""Test that search_size limits the overlap detection window."""
|
||||
buffer = [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
data = [2, 3, 9, 10] # overlap [2,3] is outside search window of last 3 elements
|
||||
result = lcs_merge(buffer, data, search_size=3, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
# No overlap in last 3 elements [6,7,8], so data is appended
|
||||
assert result == [1, 2, 3, 4, 5, 6, 7, 8, 2, 3, 9, 10]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_min_lcs_length_threshold(self):
|
||||
"""Test that LCS shorter than min_lcs_length causes concatenation."""
|
||||
buffer = [1, 2, 3, 4, 5]
|
||||
data = [5, 6, 7] # only 1 element overlap
|
||||
result = lcs_merge(buffer, data, search_size=5, min_lcs_length=2, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
# LCS length is 1, which is < min_lcs_length=2, so concatenate
|
||||
assert result == [1, 2, 3, 4, 5, 5, 6, 7]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_min_lcs_length_exact_match(self):
|
||||
"""Test that LCS equal to min_lcs_length triggers merge."""
|
||||
buffer = [1, 2, 3, 4, 5]
|
||||
data = [4, 5, 6, 7] # 2 element overlap
|
||||
result = lcs_merge(buffer, data, search_size=5, min_lcs_length=2, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_data_is_subset_of_buffer_end(self):
|
||||
"""Test when data is entirely contained in the end of buffer."""
|
||||
buffer = [1, 2, 3, 4, 5]
|
||||
data = [4, 5]
|
||||
result = lcs_merge(buffer, data, search_size=5, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3, 4, 5]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_complete_overlap(self):
|
||||
"""Test when data starts exactly where buffer search window begins."""
|
||||
buffer = [1, 2, 3, 4, 5]
|
||||
data = [3, 4, 5, 6, 7, 8]
|
||||
result = lcs_merge(buffer, data, search_size=3, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_lcs_strategy_with_gaps(self):
|
||||
"""Test LCS strategy handles non-contiguous common subsequences."""
|
||||
buffer = [1, 2, 100, 3, 4] # has 100 inserted
|
||||
data = [2, 3, 4, 5, 6] # continuous 2, 3, 4
|
||||
result = lcs_merge(buffer, data, search_size=5, min_lcs_length=1, merging_strategy=MergingStrategy.LCS)
|
||||
# LCS finds [2, 3, 4] even with gap
|
||||
assert result == [1, 2, 100, 3, 4, 5, 6]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_single_element_overlap(self):
|
||||
"""Test merging with single element overlap."""
|
||||
buffer = [1, 2, 3]
|
||||
data = [3, 4, 5]
|
||||
result = lcs_merge(buffer, data, search_size=3, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 2, 3, 4, 5]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_repeated_elements(self):
|
||||
"""Test merging lists with repeated elements."""
|
||||
buffer = [1, 1, 1, 2, 2, 2]
|
||||
data = [2, 2, 2, 3, 3, 3]
|
||||
result = lcs_merge(buffer, data, search_size=6, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == [1, 1, 1, 2, 2, 2, 3, 3, 3]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lcs_merge_long_sequences(self):
|
||||
"""Test merging longer sequences for performance sanity."""
|
||||
buffer = list(range(100))
|
||||
data = list(range(90, 150)) # overlap from 90-99
|
||||
result = lcs_merge(buffer, data, search_size=20, min_lcs_length=1, merging_strategy=MergingStrategy.LCSUBSTR)
|
||||
assert result == list(range(150))
|
||||
|
||||
|
||||
class TestLongestCommonSubstringEdgeCases:
|
||||
"""Additional edge case tests for longest_common_substring function."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_single_element_match(self):
|
||||
"""Test with single element lists that match."""
|
||||
start1, start2, length = longest_common_substring([5], [5])
|
||||
assert (start1, start2, length) == (0, 0, 1)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_single_element_no_match(self):
|
||||
"""Test with single element lists that don't match."""
|
||||
start1, start2, length = longest_common_substring([5], [6])
|
||||
assert (start1, start2, length) == (-1, -1, 0)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_match_at_buffer_start(self):
|
||||
"""Test when LCS is at the start of buffer."""
|
||||
start1, start2, length = longest_common_substring([1, 2, 3, 9, 9], [1, 2, 3, 8, 8])
|
||||
assert (start1, start2, length) == (0, 0, 3)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_match_at_data_end(self):
|
||||
"""Test when LCS is at the end of data."""
|
||||
start1, start2, length = longest_common_substring([7, 8, 9], [1, 2, 7, 8, 9])
|
||||
assert (start1, start2, length) == (0, 2, 3)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_entire_data_is_substring(self):
|
||||
"""Test when entire data is a substring of buffer."""
|
||||
start1, start2, length = longest_common_substring([1, 2, 3, 4, 5, 6], [3, 4, 5])
|
||||
assert (start1, start2, length) == (2, 0, 3)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_entire_buffer_is_substring(self):
|
||||
"""Test when entire buffer is a substring of data."""
|
||||
start1, start2, length = longest_common_substring([3, 4, 5], [1, 2, 3, 4, 5, 6])
|
||||
assert (start1, start2, length) == (0, 2, 3)
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.inference.utils.pipeline_eval import calculate_asr_laal
|
||||
from nemo.collections.asr.parts.utils.eval_utils import compute_laal
|
||||
|
||||
# EoU enabled (stop_history_eou >= 0) so ASR LAAL is computed.
|
||||
CFG = OmegaConf.create({"metrics": {"asr": {"gt_text_attr_name": "text"}}, "endpointing": {"stop_history_eou": 800}})
|
||||
|
||||
|
||||
class TestCalculateAsrLaal:
|
||||
@pytest.mark.unit
|
||||
def test_matches_direct_compute_laal(self):
|
||||
# Two finalized steps: "hello world" committed at 2.0 s, "foo" at 5.0 s of audio elapsed.
|
||||
# Each word inherits its step's delay (ms); LAAL is computed against the reference word count.
|
||||
durations = {"a.wav": 10.0} # seconds -> 10000 ms
|
||||
manifest = [{"audio_filepath": "a.wav", "text": "hello world foo bar"}] # 4 reference words
|
||||
output = {0: {"audio_filepath": "a.wav", "asr_segments": [("hello world", 2.0), (" foo", 5.0)]}}
|
||||
|
||||
expected = compute_laal([2000.0, 2000.0, 5000.0], 10000.0, 4)
|
||||
got = calculate_asr_laal(output, durations, manifest, CFG)
|
||||
assert got == pytest.approx(expected)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delay_capped_at_duration(self):
|
||||
# A delay beyond the audio duration is clamped to the duration.
|
||||
durations = {"a.wav": 3.0} # 3000 ms
|
||||
manifest = [{"audio_filepath": "a.wav", "text": "hello world"}]
|
||||
output = {0: {"audio_filepath": "a.wav", "asr_segments": [("hello world", 99.0)]}}
|
||||
|
||||
expected = compute_laal([3000.0, 3000.0], 3000.0, 2)
|
||||
assert calculate_asr_laal(output, durations, manifest, CFG) == pytest.approx(expected)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_manifest_returns_none(self):
|
||||
output = {0: {"audio_filepath": "a.wav", "asr_segments": [("hi", 1.0)]}}
|
||||
assert calculate_asr_laal(output, {"a.wav": 1.0}, None, CFG) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_reference_returns_none(self):
|
||||
# Stream's audio is absent from the manifest -> nothing to score.
|
||||
output = {0: {"audio_filepath": "missing.wav", "asr_segments": [("hi", 1.0)]}}
|
||||
manifest = [{"audio_filepath": "other.wav", "text": "hello"}]
|
||||
assert calculate_asr_laal(output, {"missing.wav": 1.0}, manifest, CFG) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_eou_disabled_returns_none(self):
|
||||
# EoU disabled (stop_history_eou < 0) -> one segment per utterance, no latency signal -> skipped.
|
||||
cfg = OmegaConf.create(
|
||||
{"metrics": {"asr": {"gt_text_attr_name": "text"}}, "endpointing": {"stop_history_eou": -1}}
|
||||
)
|
||||
manifest = [{"audio_filepath": "a.wav", "text": "hello world"}]
|
||||
output = {0: {"audio_filepath": "a.wav", "asr_segments": [("hello world", 2.0)]}}
|
||||
assert calculate_asr_laal(output, {"a.wav": 10.0}, manifest, cfg) is None
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 re
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
check_existance_of_required_attributes,
|
||||
drop_trailing_features,
|
||||
get_leading_punctuation_regex_pattern,
|
||||
get_repeated_punctuation_regex_pattern,
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineUtils:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_drop_trailing_features(self):
|
||||
x = torch.randn(10, 10, 20)
|
||||
expected_feature_buffer_len = 15
|
||||
x_dropped = drop_trailing_features(x, expected_feature_buffer_len)
|
||||
assert x_dropped.shape == (10, 10, 15)
|
||||
assert x_dropped.allclose(x[:, :, :15])
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected_text",
|
||||
[
|
||||
("", ""),
|
||||
(" ", " "),
|
||||
("simple text", "simple text"),
|
||||
("just a 2nd . Yeah, I hope", "just a 2nd. Yeah, I hope"),
|
||||
("Hello , world ! How are you ?", "Hello, world! How are you?"),
|
||||
("The quick, brown fox jumps ? over the lazy ! dog.", "The quick, brown fox jumps? over the lazy! dog."),
|
||||
],
|
||||
)
|
||||
def test_remove_leading_punctuation_spaces(self, text, expected_text):
|
||||
puncts = {"!", "?", ".", ","}
|
||||
pattern = get_leading_punctuation_regex_pattern(puncts)
|
||||
assert re.sub(pattern, r'\1', text) == expected_text
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected_text",
|
||||
[
|
||||
("", ""),
|
||||
(" ", " "),
|
||||
("simple text", "simple text"),
|
||||
("Hello, world!! How are you???", "Hello, world! How are you?"),
|
||||
("The quick,, brown fox jumps? over the lazy! dog..", "The quick, brown fox jumps? over the lazy! dog."),
|
||||
],
|
||||
)
|
||||
def test_remove_repeated_punctuation(self, text, expected_text):
|
||||
puncts = {"!", "?", ".", ","}
|
||||
pattern = get_repeated_punctuation_regex_pattern(puncts)
|
||||
assert re.sub(pattern, r'\1', text) == expected_text
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_check_existance_of_required_attributes(self):
|
||||
class TestClass:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
check_existance_of_required_attributes(TestClass, ['test_attr'])
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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
|
||||
|
||||
from nemo.collections.asr.inference.utils.state_management_utils import (
|
||||
detect_overlap,
|
||||
find_max_overlap,
|
||||
merge_segment_tail,
|
||||
merge_timesteps,
|
||||
merge_word_tail,
|
||||
)
|
||||
from nemo.collections.asr.inference.utils.text_segment import TextSegment, Word
|
||||
|
||||
|
||||
class TestStateManagementUtils:
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"timesteps1, timesteps2, expected_merged_timesteps",
|
||||
[
|
||||
([0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
([0, 1, 2, 3], [], [0, 1, 2, 3]),
|
||||
([], [4, 5, 6, 7], [4, 5, 6, 7]),
|
||||
([-1, 0, 1, 2], [0, 1, 2, 3], [0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
([-3, 1, 2, 3], [], [0, 4, 5, 6]),
|
||||
([], [-3, 1, 2, 3], [0, 4, 5, 6]),
|
||||
],
|
||||
)
|
||||
def test_merge_timesteps(self, timesteps1, timesteps2, expected_merged_timesteps):
|
||||
merged_timesteps = merge_timesteps(timesteps1, timesteps2)
|
||||
assert merged_timesteps == expected_merged_timesteps
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"state_tokens, new_tokens, limit, expected_max_overlap",
|
||||
[
|
||||
([0, 1, 2, 3], [2, 3, 4, 5], 4, 2),
|
||||
([0, 2, 3, 4], [2, 3, 4, 5], 4, 3),
|
||||
([0, 0, 0, 1], [2, 3, 4, 5], 4, 0),
|
||||
],
|
||||
)
|
||||
def test_find_max_overlap(self, state_tokens, new_tokens, limit, expected_max_overlap):
|
||||
max_overlap = find_max_overlap(state_tokens, new_tokens, limit)
|
||||
assert max_overlap == expected_max_overlap
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"state_tokens, state_timesteps, new_tokens, new_timesteps, expected_overlap",
|
||||
[
|
||||
([0, 1, 2, 3], [0.0, 1.0, 2.0, 3.0], [2, 3, 4, 5], [2.0, 3.0, 4.0, 5.0], 2),
|
||||
([0, 1, 2, 3], [0.0, 1.0, 2.0, 3.0], [2, 3, 4, 5], [1.0, 2.0, 4.0, 5.0], 2),
|
||||
([0, 1, 2, 3], [0.0, 1.0, 2.0, 3.0], [2, 3, 4, 5], [5.0, 7.0, 8.0, 9.0], 0),
|
||||
],
|
||||
)
|
||||
def test_detect_overlap(self, state_tokens, state_timesteps, new_tokens, new_timesteps, expected_overlap):
|
||||
overlap = detect_overlap(state_tokens, state_timesteps, new_tokens, new_timesteps)
|
||||
assert overlap == expected_overlap
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_word_tail_without_pnc(self):
|
||||
word_head = Word(text="meaning", start=0.0, end=1.0, conf=0.5)
|
||||
word_tail = Word(text="ful", start=1.0, end=2.0, conf=0.6)
|
||||
head, _ = merge_word_tail(word_head, word_tail, conf_aggregator=min)
|
||||
|
||||
assert head.text == "meaningful"
|
||||
assert head.start == 0.0
|
||||
assert head.end == 2.0
|
||||
assert head.conf == 0.5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_word_tail_with_pnc(self):
|
||||
|
||||
word_head = Word(text="meaning", start=0.0, end=1.0, conf=0.5)
|
||||
word_tail = Word(text="s", start=1.0, end=2.0, conf=0.6)
|
||||
pnc_head = Word(text="Meaning?", start=0.0, end=1.0, conf=0.5)
|
||||
new_head, new_pnc_head = merge_word_tail(word_head, word_tail, conf_aggregator=min, pnc_word_head=pnc_head)
|
||||
|
||||
assert new_head.text == "meanings"
|
||||
assert new_head.start == 0.0
|
||||
assert new_head.end == 2.0
|
||||
assert new_head.conf == 0.5
|
||||
assert new_pnc_head.text == "Meanings?"
|
||||
assert new_pnc_head.start == 0.0
|
||||
assert new_pnc_head.end == 2.0
|
||||
assert new_pnc_head.conf == 0.5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_segment_tail(self):
|
||||
seg1 = TextSegment(text="Good morn", start=0.0, end=1.0, conf=0.5)
|
||||
seg2 = TextSegment(text="ing", start=1.0, end=2.0, conf=0.6)
|
||||
merged_seg = merge_segment_tail(seg1, seg2, conf_aggregator=min)
|
||||
|
||||
assert merged_seg.text == "Good morning"
|
||||
assert merged_seg.start == 0.0
|
||||
assert merged_seg.end == 2.0
|
||||
assert merged_seg.conf == 0.5
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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
|
||||
|
||||
from nemo.collections.asr.inference.utils.text_segment import TextSegment, Word, join_segments
|
||||
|
||||
|
||||
class TestTextSegment:
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("text, expected_text", [("Hello!", "hello"), ("HeLLo!", "hello")])
|
||||
def test_normalize_text_inplace(self, text, expected_text):
|
||||
for cls in [Word, TextSegment]:
|
||||
text_segment = cls(text, 0, 1, 0.5)
|
||||
text_segment.normalize_text_inplace(punct_marks='!', sep=' ')
|
||||
assert text_segment.text == expected_text
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("text, expected_text", [("Hello!", "hello"), ("HeLLo!", "hello")])
|
||||
def test_with_normalized_text(self, text, expected_text):
|
||||
for cls in [Word, TextSegment]:
|
||||
text_segment = cls(text, 0, 1, 0.5)
|
||||
text_segment_copy = text_segment.with_normalized_text(punct_marks='!', sep=' ')
|
||||
assert text_segment_copy.text == expected_text
|
||||
assert text_segment.text == text
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_join_segments(self):
|
||||
for cls in [Word, TextSegment]:
|
||||
segments = [
|
||||
[cls('hello', 0, 1, 0.5), cls('world', 1, 2, 0.5)],
|
||||
[cls('how', 2, 3, 0.5), cls('are', 3, 4, 0.5), cls('you', 4, 5, 0.5)],
|
||||
]
|
||||
transcriptions = join_segments(segments, sep=' ')
|
||||
assert transcriptions == ['hello world', 'how are you']
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("text, expected_text", [("hello", "Hello"), ("World!", "World!")])
|
||||
def test_capitalize(self, text, expected_text):
|
||||
for cls in [Word, TextSegment]:
|
||||
text_segment = cls(text, 0, 1, 0.5)
|
||||
text_segment.capitalize()
|
||||
assert text_segment.text == expected_text
|
||||
@@ -0,0 +1,336 @@
|
||||
# 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 random
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.k2.rnnt_logprobs import rnnt_logprobs_torch
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.rnnt_numpy import RNNTLoss as RNNTLoss_Numpy
|
||||
from nemo.core.utils.optional_libs import K2_AVAILABLE, TRITON_AVAILABLE
|
||||
|
||||
if K2_AVAILABLE:
|
||||
import k2
|
||||
|
||||
from nemo.collections.asr.parts.k2.graph_transducer import GraphRnntLoss
|
||||
|
||||
if TRITON_AVAILABLE:
|
||||
from nemo.collections.asr.parts.k2.rnnt_logprobs_triton import rnnt_logprobs_triton
|
||||
|
||||
|
||||
EPS_SM_INPUT = 1e-6
|
||||
EPS_L_INPUT = 1e-4
|
||||
|
||||
DEVICES = ['cpu']
|
||||
|
||||
if K2_AVAILABLE and torch.cuda.is_available() and k2.with_cuda:
|
||||
DEVICES.append('cuda')
|
||||
|
||||
|
||||
@pytest.mark.skipif(not K2_AVAILABLE, reason="k2 is not installed, skipping Graph-RNNT tests.")
|
||||
class TestGraphRnnt:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
@pytest.mark.parametrize("num_frames", [1, 3, 6])
|
||||
@pytest.mark.parametrize("vocab_size", [3])
|
||||
def test_temporal_schema(self, device, blank_first, num_frames, vocab_size):
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
loss = GraphRnntLoss(blank=blank_id)
|
||||
temporal_schema = loss.get_temporal_schema(
|
||||
num_frames=num_frames, vocab_size=vocab_size, device=torch.device(device)
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for time_i in range(num_frames):
|
||||
for label_i in range(vocab_size):
|
||||
if label_i == blank_id:
|
||||
# transition to the next state
|
||||
etalon_schema_fst.append([time_i, time_i + 1, label_i, time_i, 0])
|
||||
else:
|
||||
# self-loop
|
||||
etalon_schema_fst.append([time_i, time_i, label_i, time_i, 0])
|
||||
etalon_schema_fst.append([num_frames, num_frames + 1, -1, -1, 0]) # transition to final state
|
||||
etalon_schema_fst.append([num_frames + 1]) # final state
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_temporal_schema = k2.Fsa.from_str(etalon_schema_fst_str, num_aux_labels=1)
|
||||
|
||||
assert temporal_schema.num_arcs == etalon_temporal_schema.num_arcs
|
||||
assert temporal_schema.shape == etalon_temporal_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
temporal_schema, etalon_temporal_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Temporal schema mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
temporal_schema.invert(),
|
||||
etalon_temporal_schema.invert(),
|
||||
log_semiring=True,
|
||||
treat_epsilons_specially=False,
|
||||
), "Temporal schema output labels mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
def test_unit_schema(self, device, blank_first):
|
||||
vocab_size = 3
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
if blank_first:
|
||||
labels = [1, 1, 2, 1]
|
||||
else:
|
||||
labels = [1, 1, 0, 1]
|
||||
loss = GraphRnntLoss(blank=blank_id)
|
||||
unit_schema = loss.get_unit_schema(
|
||||
units_tensor=torch.tensor(labels, device=torch.device(device)), vocab_size=vocab_size
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for label_i, label in enumerate(labels):
|
||||
etalon_schema_fst.append([label_i, label_i + 1, label, label, label_i, 0]) # forward: label
|
||||
etalon_schema_fst.append([label_i, label_i, blank_id, blank_id, label_i, 0]) # self-loop: blank
|
||||
etalon_schema_fst.append([len(labels), len(labels), blank_id, blank_id, len(labels), 0])
|
||||
etalon_schema_fst.append([len(labels), len(labels) + 1, -1, -1, -1, 0]) # transition to final state
|
||||
etalon_schema_fst.append([len(labels) + 1]) # final state
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_unit_schema = k2.Fsa.from_str(etalon_schema_fst_str, aux_label_names=["aux_labels", "unit_positions"])
|
||||
|
||||
assert unit_schema.num_arcs == etalon_unit_schema.num_arcs
|
||||
assert unit_schema.shape == etalon_unit_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema, etalon_unit_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema input labels mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema.invert(), etalon_unit_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema output labels mismatch"
|
||||
|
||||
# swap aux_labels and unit positions to test unit_positions
|
||||
unit_schema.aux_labels, unit_schema.unit_positions = unit_schema.unit_positions, unit_schema.aux_labels
|
||||
etalon_unit_schema.aux_labels, etalon_unit_schema.unit_positions = (
|
||||
etalon_unit_schema.unit_positions,
|
||||
etalon_unit_schema.aux_labels,
|
||||
)
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema.invert(), etalon_unit_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema unit positions mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
def test_grid_schema(self, device, blank_first):
|
||||
vocab_size = 3
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
if blank_first:
|
||||
labels = [1, 1, 2, 1]
|
||||
else:
|
||||
labels = [1, 1, 0, 1]
|
||||
text_length = len(labels)
|
||||
num_frames = 5
|
||||
loss = GraphRnntLoss(blank=blank_id)
|
||||
grid_schema = loss.get_grid(
|
||||
units_tensor=torch.tensor(labels, device=torch.device(device)),
|
||||
num_frames=num_frames,
|
||||
vocab_size=vocab_size,
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for frame_i in range(num_frames):
|
||||
for label_i in range(text_length + 1):
|
||||
state = frame_i * (text_length + 1) + label_i
|
||||
if label_i < text_length:
|
||||
next_state_label = state + 1
|
||||
# next unit
|
||||
etalon_schema_fst.append([state, next_state_label, labels[label_i], frame_i, label_i, 0])
|
||||
if frame_i < num_frames - 1:
|
||||
next_state_frame = (frame_i + 1) * (text_length + 1) + label_i
|
||||
# next time frame (blank)
|
||||
etalon_schema_fst.append([state, next_state_frame, blank_id, frame_i, label_i, 0])
|
||||
|
||||
last_grid_state = num_frames * (text_length + 1) - 1
|
||||
etalon_schema_fst.append([last_grid_state, last_grid_state + 1, blank_id, num_frames - 1, text_length, 0])
|
||||
etalon_schema_fst.append(
|
||||
[last_grid_state + 1, last_grid_state + 2, -1, -1, -1, 0]
|
||||
) # transition to final state
|
||||
etalon_schema_fst.append([last_grid_state + 2]) # final state
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_grid_schema = k2.Fsa.from_str(etalon_schema_fst_str, aux_label_names=["aux_labels", "unit_positions"])
|
||||
|
||||
assert grid_schema.num_arcs == etalon_grid_schema.num_arcs
|
||||
assert grid_schema.shape == etalon_grid_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema, etalon_grid_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema input labels mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema.invert(), etalon_grid_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema output labels mismatch"
|
||||
|
||||
# swap aux_labels and unit positions to test unit_positions
|
||||
grid_schema.aux_labels, grid_schema.unit_positions = grid_schema.unit_positions, grid_schema.aux_labels
|
||||
etalon_grid_schema.aux_labels, etalon_grid_schema.unit_positions = (
|
||||
etalon_grid_schema.unit_positions,
|
||||
etalon_grid_schema.aux_labels,
|
||||
)
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema.invert(), etalon_grid_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema unit positions mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("connect_composed", [True, False])
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
def test_small_compose_transducer(
|
||||
self, device, connect_composed, blank_first, rnnt_test_helper, rnn_loss_sample_data
|
||||
):
|
||||
if blank_first:
|
||||
sample_data = rnn_loss_sample_data.get_sample_small()
|
||||
else:
|
||||
sample_data = rnn_loss_sample_data.get_sample_small_blank_last()
|
||||
graph_rnnt = GraphRnntLoss(
|
||||
blank=sample_data.blank_id, connect_composed=connect_composed, use_grid_implementation=False
|
||||
)
|
||||
graph_cost, graph_grads = rnnt_test_helper.wrap_and_call(
|
||||
graph_rnnt, sample_data.logits, sample_data.targets, device
|
||||
)
|
||||
assert np.allclose(graph_cost, sample_data.expected_cost.numpy(), rtol=EPS_SM_INPUT), "costs mismatch."
|
||||
assert np.allclose(graph_grads, sample_data.expected_grads.numpy(), atol=1e-6), "gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_small_grid_transducer(self, device, rnnt_test_helper, rnn_loss_sample_data):
|
||||
sample_data = rnn_loss_sample_data.get_sample_small()
|
||||
graph_rnnt = GraphRnntLoss(blank=0, use_grid_implementation=True)
|
||||
graph_cost, graph_grads = rnnt_test_helper.wrap_and_call(
|
||||
graph_rnnt, sample_data.logits, sample_data.targets, device
|
||||
)
|
||||
assert np.allclose(graph_cost, sample_data.expected_cost.numpy(), rtol=EPS_SM_INPUT), "costs mismatch."
|
||||
assert np.allclose(graph_grads, sample_data.expected_grads.numpy(), atol=1e-6), "gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("use_triton", [True, False])
|
||||
def test_medium_grid_transducer(self, device, use_triton: bool, rnnt_test_helper, rnn_loss_sample_data):
|
||||
if use_triton and device == "cpu":
|
||||
pytest.skip("Triton does not support CPU yet")
|
||||
sample_data = rnn_loss_sample_data.get_sample_medium()
|
||||
graph_rnnt = GraphRnntLoss(blank=0, use_grid_implementation=True, use_triton=use_triton)
|
||||
graph_cost, graph_grads = rnnt_test_helper.wrap_and_call(
|
||||
graph_rnnt, sample_data.logits, sample_data.targets, device
|
||||
)
|
||||
assert np.allclose(graph_cost, sample_data.expected_cost.numpy(), rtol=EPS_SM_INPUT), "costs mismatch."
|
||||
assert np.allclose(graph_grads, sample_data.expected_grads.numpy(), atol=1e-6), "gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("use_triton", [True, False])
|
||||
def test_medium_random_var_size(self, device, use_triton: bool, rnnt_test_helper, rnn_loss_sample_data):
|
||||
if use_triton and device == "cpu":
|
||||
pytest.skip("Triton does not support CPU yet")
|
||||
sample_data = rnn_loss_sample_data.get_sample_medium_random_var_size(blank_first=True)
|
||||
graph_rnnt = GraphRnntLoss(blank=0, use_grid_implementation=True, use_triton=use_triton)
|
||||
graph_cost, graph_grads = rnnt_test_helper.wrap_and_call(
|
||||
graph_rnnt,
|
||||
sample_data.logits.detach(),
|
||||
sample_data.targets,
|
||||
device,
|
||||
input_lengths=sample_data.input_lengths,
|
||||
target_lengths=sample_data.target_lengths,
|
||||
)
|
||||
etalon_rnnt = RNNTLoss_Numpy(blank=0)
|
||||
etalon_cost, etalon_grads = rnnt_test_helper.wrap_and_call(
|
||||
etalon_rnnt,
|
||||
sample_data.logits.detach(),
|
||||
sample_data.targets,
|
||||
device,
|
||||
input_lengths=sample_data.input_lengths,
|
||||
target_lengths=sample_data.target_lengths,
|
||||
)
|
||||
assert np.allclose(graph_cost.sum(), etalon_cost, rtol=EPS_SM_INPUT), "costs mismatch."
|
||||
assert np.allclose(graph_grads, etalon_grads, atol=1e-4), "gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
def test_small_random_grid_compose_equivalent(self, device: torch.device, blank_first: bool, rnn_loss_sample_data):
|
||||
sample_data = rnn_loss_sample_data.get_sample_small_random(blank_first, device=device)
|
||||
criterion = GraphRnntLoss(blank=sample_data.blank_id, connect_composed=True, use_grid_implementation=False)
|
||||
text_tensor = sample_data.targets[0]
|
||||
num_frames = sample_data.logits.shape[1]
|
||||
graph_grid = criterion.get_grid(text_tensor, num_frames, sample_data.vocab_size)
|
||||
graph_composed = criterion.get_composed_lattice(text_tensor, num_frames, sample_data.vocab_size)
|
||||
assert k2.is_rand_equivalent(
|
||||
graph_grid, graph_composed, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid and composed graphs are not equivalent."
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TRITON_AVAILABLE, reason="Triton is not installed, skipping RNNT Log Probs tests")
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is unavailable")
|
||||
class TestRnntLogProbs:
|
||||
@pytest.mark.parametrize(
|
||||
"batch_size,num_frames,num_text_units,vocab_size",
|
||||
[
|
||||
(1, 4, 2, 4),
|
||||
(2, 3, 2, 5),
|
||||
(2, 16, 31, 17),
|
||||
(16, 129, 65, 2048),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"float_dtype",
|
||||
[torch.float32] + ([torch.bfloat16] if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else []),
|
||||
)
|
||||
def test_rnnt_logprobs_random(
|
||||
self, batch_size: int, num_frames: int, num_text_units: int, vocab_size: int, float_dtype: torch.dtype
|
||||
):
|
||||
"""
|
||||
Test Triton-based implementation using etalon Torch-based implementation for RNN-T log-probs.
|
||||
"""
|
||||
device = torch.device("cuda")
|
||||
torch.manual_seed(777)
|
||||
|
||||
targets = torch.tensor(
|
||||
[[random.randrange(0, vocab_size - 1) for i in range(num_text_units)] for j in range(batch_size)],
|
||||
device=device,
|
||||
dtype=torch.long,
|
||||
)
|
||||
|
||||
logits = torch.rand(
|
||||
[batch_size, num_frames, num_text_units + 1, vocab_size + 1],
|
||||
dtype=float_dtype,
|
||||
device=device,
|
||||
requires_grad=True,
|
||||
)
|
||||
|
||||
# Triton-based implementation works in float32 precision for accuracy purposes, should compare with float32
|
||||
target_scores_etalon, blank_scores_etalon = rnnt_logprobs_torch(
|
||||
logits=logits.to(torch.float32), targets=targets, blank_id=vocab_size
|
||||
)
|
||||
logits2 = logits.clone().detach()
|
||||
logits2.requires_grad_(True)
|
||||
target_scores, blank_scores = rnnt_logprobs_triton(logits=logits2, targets=targets, blank_id=vocab_size)
|
||||
target_scores[..., -1:] = 0.0
|
||||
target_scores_etalon[..., -1:] = 0.0
|
||||
assert torch.allclose(blank_scores, blank_scores_etalon, atol=1e-5)
|
||||
assert torch.allclose(target_scores, target_scores_etalon, atol=1e-5)
|
||||
|
||||
# test backward
|
||||
target_scales = torch.rand_like(target_scores, requires_grad=False)
|
||||
blank_scales = torch.rand_like(blank_scores, requires_grad=False)
|
||||
loss_etalon = (target_scales * target_scores_etalon + blank_scales * blank_scores_etalon).sum()
|
||||
loss = (target_scales * target_scores + blank_scales * blank_scores).sum()
|
||||
loss_etalon.backward()
|
||||
loss.backward()
|
||||
assert torch.allclose(logits.grad, logits2.grad, atol=1e-5)
|
||||
@@ -0,0 +1,260 @@
|
||||
# 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 numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
try:
|
||||
from nemo.collections.asr.parts.k2.w_transducer import GraphWTransducerLoss
|
||||
from nemo.core.utils.k2_guard import k2
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pytest.skip("k2 is not installed, skipping Graph-W-Transducer tests.", allow_module_level=True)
|
||||
|
||||
DEVICES = ['cpu']
|
||||
|
||||
if torch.cuda.is_available() and k2.with_cuda:
|
||||
DEVICES.append('cuda')
|
||||
|
||||
|
||||
class TestGraphWTransducerLoss:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
@pytest.mark.parametrize("num_frames", [1, 3, 6])
|
||||
@pytest.mark.parametrize("vocab_size", [3])
|
||||
@pytest.mark.parametrize("last_blank_mode", ["force_final", "allow_ignore"])
|
||||
def test_temporal_schema(self, device, blank_first, num_frames, vocab_size, last_blank_mode):
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
loss = GraphWTransducerLoss(blank=blank_id, last_blank_mode=last_blank_mode)
|
||||
temporal_schema = loss.get_temporal_schema(
|
||||
num_frames=num_frames, vocab_size=vocab_size, device=torch.device(device)
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for time_i in range(num_frames):
|
||||
for label_i in range(vocab_size):
|
||||
if label_i == blank_id:
|
||||
# transition to the next state
|
||||
etalon_schema_fst.append([time_i, time_i + 1, label_i, time_i, 0])
|
||||
else:
|
||||
# self-loop
|
||||
etalon_schema_fst.append([time_i, time_i, label_i, time_i, 0])
|
||||
|
||||
# eps transitions from the first state
|
||||
eps_from_first_state = vocab_size
|
||||
for time_i in range(1, num_frames):
|
||||
etalon_schema_fst.append([0, time_i, eps_from_first_state, 0, 0])
|
||||
|
||||
# eps transitions to the last state
|
||||
eps_to_last_state = vocab_size + 1
|
||||
last_state_eps = num_frames - 1 if last_blank_mode == "force_final" else num_frames
|
||||
for time_i in range(0, num_frames - 1):
|
||||
etalon_schema_fst.append([time_i, last_state_eps, eps_to_last_state, time_i, 0])
|
||||
|
||||
# transition to the final state
|
||||
etalon_schema_fst.append([num_frames, num_frames + 1, -1, -1, 0])
|
||||
# final state
|
||||
etalon_schema_fst.append([num_frames + 1])
|
||||
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_temporal_schema = k2.Fsa.from_str(etalon_schema_fst_str, num_aux_labels=1)
|
||||
|
||||
assert temporal_schema.num_arcs == etalon_temporal_schema.num_arcs
|
||||
assert temporal_schema.shape == etalon_temporal_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
temporal_schema, etalon_temporal_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Temporal schema mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
temporal_schema.invert(),
|
||||
etalon_temporal_schema.invert(),
|
||||
log_semiring=False,
|
||||
treat_epsilons_specially=False,
|
||||
), "Temporal schema output labels mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
def test_unit_schema(self, device, blank_first):
|
||||
vocab_size = 3
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
if blank_first:
|
||||
labels = [1, 1, 2, 1]
|
||||
else:
|
||||
labels = [1, 1, 0, 1]
|
||||
loss = GraphWTransducerLoss(blank=blank_id)
|
||||
unit_schema = loss.get_unit_schema(
|
||||
units_tensor=torch.tensor(labels, device=torch.device(device)), vocab_size=vocab_size
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for label_i, label in enumerate(labels):
|
||||
etalon_schema_fst.append([label_i, label_i + 1, label, label, label_i, 0]) # forward: label
|
||||
etalon_schema_fst.append([label_i, label_i, blank_id, blank_id, label_i, 0]) # self-loop: blank
|
||||
etalon_schema_fst.append([len(labels), len(labels), blank_id, blank_id, len(labels), 0])
|
||||
# eps-transitions
|
||||
etalon_schema_fst.append([0, 0, vocab_size, vocab_size, 0, 0])
|
||||
etalon_schema_fst.append([len(labels), len(labels), vocab_size + 1, vocab_size + 1, len(labels), 0])
|
||||
|
||||
etalon_schema_fst.append([len(labels), len(labels) + 1, -1, -1, -1, 0]) # transition to final state
|
||||
etalon_schema_fst.append([len(labels) + 1]) # final state
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_unit_schema = k2.Fsa.from_str(etalon_schema_fst_str, aux_label_names=["aux_labels", "unit_positions"])
|
||||
|
||||
assert unit_schema.num_arcs == etalon_unit_schema.num_arcs
|
||||
assert unit_schema.shape == etalon_unit_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema, etalon_unit_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema input labels mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema.invert(), etalon_unit_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema output labels mismatch"
|
||||
|
||||
# swap aux_labels and unit positions to test unit_positions
|
||||
unit_schema.aux_labels, unit_schema.unit_positions = unit_schema.unit_positions, unit_schema.aux_labels
|
||||
etalon_unit_schema.aux_labels, etalon_unit_schema.unit_positions = (
|
||||
etalon_unit_schema.unit_positions,
|
||||
etalon_unit_schema.aux_labels,
|
||||
)
|
||||
assert k2.is_rand_equivalent(
|
||||
unit_schema.invert(), etalon_unit_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Unit schema unit positions mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
@pytest.mark.parametrize("last_blank_mode", ["force_final", "allow_ignore"])
|
||||
def test_grid_schema(self, device, blank_first, last_blank_mode):
|
||||
vocab_size = 3
|
||||
blank_id = 0 if blank_first else vocab_size - 1
|
||||
if blank_first:
|
||||
labels = [1, 1, 2, 1]
|
||||
else:
|
||||
labels = [1, 1, 0, 1]
|
||||
text_length = len(labels)
|
||||
num_frames = 5
|
||||
loss = GraphWTransducerLoss(blank=blank_id, last_blank_mode=last_blank_mode)
|
||||
grid_schema = loss.get_grid(
|
||||
units_tensor=torch.tensor(labels, device=torch.device(device)),
|
||||
num_frames=num_frames,
|
||||
vocab_size=vocab_size,
|
||||
)
|
||||
|
||||
etalon_schema_fst: List[List[int]] = []
|
||||
for frame_i in range(num_frames):
|
||||
for label_i in range(text_length + 1):
|
||||
state = frame_i * (text_length + 1) + label_i
|
||||
if label_i < text_length:
|
||||
next_state_label = state + 1
|
||||
# next unit
|
||||
etalon_schema_fst.append([state, next_state_label, labels[label_i], frame_i, label_i, 0])
|
||||
if frame_i < num_frames - 1:
|
||||
next_state_frame = (frame_i + 1) * (text_length + 1) + label_i
|
||||
# next time frame (blank)
|
||||
etalon_schema_fst.append([state, next_state_frame, blank_id, frame_i, label_i, 0])
|
||||
|
||||
# start eps-transition
|
||||
for frame_i in range(1, num_frames):
|
||||
etalon_schema_fst.append([0, frame_i * (text_length + 1), vocab_size, 0, 0, 0])
|
||||
|
||||
last_grid_state = num_frames * (text_length + 1) - 1
|
||||
|
||||
# end eps-transitions
|
||||
if last_blank_mode == "force_final":
|
||||
last_eps_state = last_grid_state
|
||||
else:
|
||||
assert last_blank_mode == "allow_ignore"
|
||||
last_eps_state = last_grid_state + 1
|
||||
|
||||
for frame_i in range(num_frames - 1):
|
||||
etalon_schema_fst.append(
|
||||
[(frame_i + 1) * (text_length + 1) - 1, last_eps_state, vocab_size + 1, frame_i, text_length, 0]
|
||||
)
|
||||
|
||||
etalon_schema_fst.append([last_grid_state, last_grid_state + 1, blank_id, num_frames - 1, text_length, 0])
|
||||
etalon_schema_fst.append(
|
||||
[last_grid_state + 1, last_grid_state + 2, -1, -1, -1, 0]
|
||||
) # transition to final state
|
||||
etalon_schema_fst.append([last_grid_state + 2]) # final state
|
||||
etalon_schema_fst = sorted(etalon_schema_fst) # required for k2.Fsa.from_str
|
||||
etalon_schema_fst_str = "\n".join([" ".join(map(str, line)) for line in etalon_schema_fst])
|
||||
etalon_grid_schema = k2.Fsa.from_str(etalon_schema_fst_str, aux_label_names=["aux_labels", "unit_positions"])
|
||||
|
||||
assert grid_schema.num_arcs == etalon_grid_schema.num_arcs
|
||||
assert grid_schema.shape == etalon_grid_schema.shape # (num_states, None)
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema, etalon_grid_schema, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema input labels mismatch"
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema.invert(), etalon_grid_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema output labels mismatch"
|
||||
|
||||
# swap aux_labels and unit positions to test unit_positions
|
||||
grid_schema.aux_labels, grid_schema.unit_positions = grid_schema.unit_positions, grid_schema.aux_labels
|
||||
etalon_grid_schema.aux_labels, etalon_grid_schema.unit_positions = (
|
||||
etalon_grid_schema.unit_positions,
|
||||
etalon_grid_schema.aux_labels,
|
||||
)
|
||||
assert k2.is_rand_equivalent(
|
||||
grid_schema.invert(), etalon_grid_schema.invert(), log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid schema unit positions mismatch"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("blank_first", [True, False])
|
||||
@pytest.mark.parametrize("last_blank_mode", ["allow_ignore", "force_final"])
|
||||
def test_small_random_grid_compose_equivalent(
|
||||
self, device: torch.device, blank_first: bool, last_blank_mode, rnn_loss_sample_data
|
||||
):
|
||||
sample_data = rnn_loss_sample_data.get_sample_small_random(blank_first, device=device)
|
||||
criterion = GraphWTransducerLoss(
|
||||
blank=sample_data.blank_id,
|
||||
last_blank_mode=last_blank_mode,
|
||||
connect_composed=True,
|
||||
use_grid_implementation=False,
|
||||
)
|
||||
text_tensor = sample_data.targets[0]
|
||||
num_frames = sample_data.logits.shape[1]
|
||||
graph_grid = criterion.get_grid(text_tensor, num_frames, sample_data.vocab_size)
|
||||
graph_composed = criterion.get_composed_lattice(text_tensor, num_frames, sample_data.vocab_size)
|
||||
assert k2.is_rand_equivalent(
|
||||
graph_grid, graph_composed, log_semiring=True, treat_epsilons_specially=False
|
||||
), "Grid and composed graphs are not equivalent."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("last_blank_mode", ["allow_ignore", "force_final"])
|
||||
@pytest.mark.parametrize("use_grid_implementation", [True, False])
|
||||
def test_small_grid_transducer_inf_penalty(
|
||||
self, device, last_blank_mode, use_grid_implementation, rnnt_test_helper, rnn_loss_sample_data
|
||||
):
|
||||
"""
|
||||
With -inf eps penalty W-Transducer loss should be equivalent to RNN-T loss.
|
||||
"""
|
||||
sample_data = rnn_loss_sample_data.get_sample_small()
|
||||
graph_rnnt = GraphWTransducerLoss(
|
||||
blank=0,
|
||||
eps_weight=-100.0,
|
||||
last_blank_mode=last_blank_mode,
|
||||
use_grid_implementation=use_grid_implementation,
|
||||
)
|
||||
graph_cost, graph_grads = rnnt_test_helper.wrap_and_call(
|
||||
graph_rnnt, sample_data.logits, sample_data.targets, device
|
||||
)
|
||||
assert np.allclose(graph_cost, sample_data.expected_cost.numpy(), rtol=1e-6), "costs mismatch."
|
||||
assert np.allclose(graph_grads, sample_data.expected_grads.numpy(), atol=1e-6), "gradient mismatch."
|
||||
@@ -0,0 +1,763 @@
|
||||
# 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 os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig, ListConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.models import ASRModel, EncDecCTCModel, EncDecMultiTaskModel, EncDecRNNTModel
|
||||
from nemo.collections.asr.parts.submodules.adapters import (
|
||||
multi_head_attention_adapter_module,
|
||||
transformer_multi_head_attention_adapter_module,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils import adapter_utils
|
||||
from nemo.collections.common.parts import adapter_modules
|
||||
from nemo.core.classes.mixins.access_mixins import AccessMixin
|
||||
from nemo.core.classes.mixins.adapter_mixins import AdapterModuleMixin, get_registered_adapter
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
from nemo.utils import model_utils
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model():
|
||||
preprocessor = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoderAdapter',
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 50,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 50,
|
||||
'num_classes': 28,
|
||||
'vocabulary': [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
],
|
||||
}
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
|
||||
model_instance = EncDecCTCModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conformer_ctc_adapter():
|
||||
preprocessor = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoderAdapter',
|
||||
'feat_in': 64,
|
||||
'feat_out': -1,
|
||||
'n_layers': 2,
|
||||
'd_model': 128,
|
||||
'subsampling': 'striding',
|
||||
'subsampling_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 4,
|
||||
'conv_kernel_size': 31,
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 128,
|
||||
'num_classes': 28,
|
||||
'vocabulary': [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
],
|
||||
}
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
|
||||
model_instance = EncDecCTCModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rnnt_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
# fmt: off
|
||||
labels = [' ', 'a', 'b', 'c', 'd', 'e', 'f',
|
||||
'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w',
|
||||
'x', 'y', 'z', "'",
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
model_defaults = {'enc_hidden': 96, 'pred_hidden': 64}
|
||||
|
||||
# Test case where Encoder (default) is not adapter compatible
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {'pred_hidden': model_defaults['pred_hidden'], 'pred_rnn_layers': 1},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {'joint_hidden': 32, 'activation': 'relu'},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 10}}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'labels': ListConfig(labels),
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecRNNTModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def multitask_model(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
# fmt: off
|
||||
tokenizer = {
|
||||
'dir': None,
|
||||
'type': 'agg',
|
||||
'langs': {
|
||||
'spl_tokens': {
|
||||
'dir': os.path.join(test_data_dir, 'asr', 'tokenizers', 'canary'),
|
||||
'type': 'bpe',
|
||||
},
|
||||
'en': {
|
||||
'dir': os.path.join(test_data_dir, 'asr', 'tokenizers', 'an4_spe_128'),
|
||||
'type': 'bpe',
|
||||
}
|
||||
},
|
||||
'custom_tokenizer': {
|
||||
'_target_': 'nemo.collections.common.tokenizers.canary_tokenizer.CanaryTokenizer',
|
||||
'tokenizers': None,
|
||||
}
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
model_defaults = {"asr_enc_hidden": 128, "lm_enc_hidden": 128, "lm_dec_hidden": 128}
|
||||
|
||||
# Test case where Encoder (default) is not adapter compatible
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': 64,
|
||||
'feat_out': -1,
|
||||
'n_layers': 2,
|
||||
'd_model': 128,
|
||||
'subsampling': 'striding',
|
||||
'subsampling_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 4,
|
||||
'conv_kernel_size': 31,
|
||||
}
|
||||
|
||||
transf_encoder = {
|
||||
"_target_": "nemo.collections.asr.modules.transformer.transformer_encoders.TransformerEncoder",
|
||||
"num_layers": 1,
|
||||
"hidden_size": "${model_defaults.lm_enc_hidden}",
|
||||
"inner_size": int(4 * model_defaults['lm_enc_hidden']),
|
||||
"num_attention_heads": 8,
|
||||
"ffn_dropout": 0.1,
|
||||
"attn_score_dropout": 0.1,
|
||||
"attn_layer_dropout": 0.1,
|
||||
"mask_future": False,
|
||||
"pre_ln": True,
|
||||
"pre_ln_final_layer_norm": True,
|
||||
}
|
||||
|
||||
transf_decoder = {
|
||||
"_target_": "nemo.collections.asr.modules.transformer.get_nemo_transformer",
|
||||
"model_name": None,
|
||||
"pretrained": False,
|
||||
"encoder": None,
|
||||
"pre_ln_final_layer_norm": True,
|
||||
"config_dict": {
|
||||
"max_sequence_length": 512,
|
||||
"num_token_types": 0,
|
||||
"embedding_dropout": 0.1,
|
||||
"learn_positional_encodings": False,
|
||||
"hidden_size": "${model_defaults.lm_dec_hidden}",
|
||||
"inner_size": "${multiply:${model_defaults.lm_dec_hidden}, 4}",
|
||||
"num_layers": 2,
|
||||
"num_attention_heads": 8,
|
||||
"ffn_dropout": 0.1,
|
||||
"attn_score_dropout": 0.1,
|
||||
"attn_layer_dropout": 0.1,
|
||||
"hidden_act": "relu",
|
||||
"pre_ln": True,
|
||||
"vocab_size": None, # Will be set by the model at runtime
|
||||
"adapter": True, # Add support for adapter class
|
||||
},
|
||||
}
|
||||
|
||||
head = {
|
||||
"_target_": "nemo.collections.asr.parts.submodules.token_classifier.TokenClassifier",
|
||||
"num_layers": 1,
|
||||
"activation": "relu",
|
||||
"log_softmax": True,
|
||||
"hidden_size": "${transf_decoder.config_dict.hidden_size}",
|
||||
"num_classes": None, # Will be set by the model at runtime
|
||||
"dropout": 0.0,
|
||||
"use_transformer_init": True,
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'beam', 'beam': {'beam_size': 1, 'len_pen': 0.0, 'max_generation_delta': 50}}
|
||||
|
||||
loss = {
|
||||
"_target_": "nemo.collections.common.losses.smoothed_cross_entropy.SmoothedCrossEntropyLoss",
|
||||
"label_smoothing": 0.0,
|
||||
"pad_id": None,
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'sample_rate': 16000,
|
||||
'prompt_format': 'canary',
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'encoder': DictConfig(encoder),
|
||||
'transf_encoder': DictConfig(transf_encoder),
|
||||
'transf_decoder': DictConfig(transf_decoder),
|
||||
'head': DictConfig(head),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecMultiTaskModel(cfg=modelConfig)
|
||||
|
||||
# Execute the model class swap logic
|
||||
model_instance.replace_adapter_compatible_modules()
|
||||
return model_instance
|
||||
|
||||
|
||||
def get_adapter_cfg(in_features=50, dim=100, norm_pos='pre', atype='linear', **kwargs):
|
||||
valid_types = ['linear', 'mha', 'relmha', 'transf_mha']
|
||||
if atype not in valid_types:
|
||||
raise ValueError(f"Invalid type. Valid types = {atype}")
|
||||
|
||||
if atype == 'linear':
|
||||
cfg = adapter_modules.LinearAdapterConfig(in_features=in_features, dim=dim, norm_position=norm_pos)
|
||||
elif atype == 'mha':
|
||||
cfg = multi_head_attention_adapter_module.MultiHeadAttentionAdapterConfig(
|
||||
n_head=kwargs.get('n_head', 1),
|
||||
n_feat=in_features,
|
||||
proj_dim=kwargs.get('proj_dim', None),
|
||||
)
|
||||
elif atype == 'transf_mha':
|
||||
cfg = transformer_multi_head_attention_adapter_module.TransformerMultiHeadAttentionAdapterConfig(
|
||||
num_attention_heads=kwargs.get('n_head', 1),
|
||||
hidden_size=in_features,
|
||||
proj_dim=kwargs.get('proj_dim', None),
|
||||
)
|
||||
else: # atype == 'relmha'
|
||||
cfg = multi_head_attention_adapter_module.RelPositionMultiHeadAttentionAdapterConfig(
|
||||
n_head=kwargs.get('n_head', 1), n_feat=in_features
|
||||
)
|
||||
|
||||
print(cfg._target_)
|
||||
|
||||
cfg = OmegaConf.structured(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
class TestASRAdapterMixin:
|
||||
@pytest.mark.unit
|
||||
def test_class_paths_are_correct(self):
|
||||
# Resolve all object names in module
|
||||
obj_keys = list(dir(adapter_utils))
|
||||
for key in obj_keys:
|
||||
if 'CLASSPATH' in key:
|
||||
classpath = getattr(adapter_utils, key)
|
||||
# This will raise import error if it fails
|
||||
_ = model_utils.import_class_by_path(classpath)
|
||||
|
||||
# Try getting thmulti_head_attention_adapter_module.pye config of the class
|
||||
config_path = classpath + "Config"
|
||||
_ = model_utils.import_class_by_path(config_path)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor(self, model):
|
||||
original_num_params = model.num_weights
|
||||
|
||||
model.add_adapter(name='adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor_mha_adapter(self, model):
|
||||
with pytest.raises(ValueError):
|
||||
model.add_adapter(name='adapter_0', cfg=get_adapter_cfg(atype='mha'))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_conformer_constructor_mha_adapter(self, conformer_ctc_adapter):
|
||||
original_num_params = conformer_ctc_adapter.num_weights
|
||||
|
||||
conformer_ctc_adapter.add_adapter(name='adapter_0', cfg=get_adapter_cfg(atype='relmha'))
|
||||
new_num_params = conformer_ctc_adapter.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor_encoder_module(self, model):
|
||||
original_num_params = model.num_weights
|
||||
|
||||
model.add_adapter(name='encoder:adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor_decoder_module(self, model):
|
||||
original_num_params = model.num_weights
|
||||
|
||||
model.add_adapter(name='decoder:adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
assert model.decoder.is_adapter_available()
|
||||
assert model.decoder.get_enabled_adapters()[0] == 'adapter_0'
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor_joint_module_ctc_skip(self, model):
|
||||
original_num_params = model.num_weights
|
||||
|
||||
# this step should exit without adding adapters and without errors
|
||||
with pytest.raises(ValueError):
|
||||
model.add_adapter(name='joint:adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params == original_num_params
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_constructor_joint_module_rnnt(self, rnnt_model):
|
||||
original_num_params = rnnt_model.num_weights
|
||||
|
||||
rnnt_model.add_adapter(name='joint:adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = rnnt_model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
assert rnnt_model.joint.is_adapter_available()
|
||||
assert rnnt_model.joint.get_enabled_adapters()[0] == 'adapter_0'
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_multiple_adapter(self, model):
|
||||
original_num_params = model.num_weights
|
||||
|
||||
model.add_adapter(name='adapter_0', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
original_num_params = new_num_params
|
||||
model.add_adapter(name='adapter_1', cfg=get_adapter_cfg())
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name', ['adapter_0', 'encoder:adapter_0', 'decoder:adapter_0'])
|
||||
def test_asr_forward_linear_pre(self, model, name):
|
||||
model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
model.add_adapter(name=name, cfg=get_adapter_cfg())
|
||||
new_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name', ['adapter_0', 'encoder:adapter_0', 'decoder:adapter_0'])
|
||||
def test_asr_forward_linear_post(self, model, name):
|
||||
model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
model.add_adapter(name=name, cfg=get_adapter_cfg(norm_pos='post'))
|
||||
new_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name', ['adapter_0', 'encoder:adapter_0'])
|
||||
def test_conformer_forward_mha(self, conformer_ctc_adapter, name):
|
||||
conformer_ctc_adapter.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = conformer_ctc_adapter(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
conformer_ctc_adapter.add_adapter(name=name, cfg=get_adapter_cfg(in_features=128, atype='mha'))
|
||||
new_output = conformer_ctc_adapter(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('adapter_type', ['linear', 'attn'])
|
||||
@pytest.mark.parametrize(
|
||||
'name', ['adapter_0', 'encoder:adapter_0', 'transf_encoder:adapter_0', 'transf_decoder:adapter_0']
|
||||
)
|
||||
def test_canary_forward_mha(self, multitask_model, name, adapter_type):
|
||||
multitask_model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
transcript = torch.randint(0, multitask_model.tokenizer.vocab_size, size=(2, 10))
|
||||
transcript_len = torch.tensor([10, 9], dtype=torch.int32)
|
||||
|
||||
origial_output = multitask_model(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=input_signal_length,
|
||||
transcript=transcript,
|
||||
transcript_length=transcript_len,
|
||||
)
|
||||
og_logprob = origial_output[0]
|
||||
og_enc_out = origial_output[2]
|
||||
|
||||
if adapter_type == 'attn':
|
||||
adapter_type = 'transf_mha' if 'transf' in name else 'mha'
|
||||
|
||||
multitask_model.add_adapter(name=name, cfg=get_adapter_cfg(in_features=128, atype=adapter_type, proj_dim=4))
|
||||
|
||||
new_output = multitask_model(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=input_signal_length,
|
||||
transcript=transcript,
|
||||
transcript_length=transcript_len,
|
||||
)
|
||||
|
||||
new_logprob = new_output[0]
|
||||
new_enc_out = new_output[2]
|
||||
|
||||
assert torch.mean(torch.abs(og_logprob - new_logprob)) < 1e-5
|
||||
assert torch.mean(torch.abs(og_enc_out - new_enc_out)) < 1e-5
|
||||
|
||||
if 'linear' in adapter_type:
|
||||
mod_name = name.split(":")[-1]
|
||||
for mod in multitask_model.modules():
|
||||
if isinstance(mod, AdapterModuleMixin):
|
||||
amodule = mod.get_adapter_module(mod_name)
|
||||
if amodule is not None:
|
||||
assert isinstance(amodule, adapter_modules.LinearAdapter)
|
||||
|
||||
# Try to use incorrect adapter
|
||||
with pytest.raises(ValueError):
|
||||
multitask_model.add_adapter(
|
||||
name="transf_encoder:adapter_1", cfg=get_adapter_cfg(in_features=128, atype='mha')
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name', ['transf_decoder:adapter_0'])
|
||||
def test_canary_forward_mha_decoder_fails_without_support(self, multitask_model, name):
|
||||
multitask_model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
|
||||
# Change internal class of transf_decoder module
|
||||
adapter_class = multitask_model.transf_decoder.__class__
|
||||
multitask_model.transf_decoder.__class__ = get_registered_adapter(adapter_class).base_class
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
adapter_type = 'transf_mha' if 'transf' in name else 'mha'
|
||||
multitask_model.add_adapter(name=name, cfg=get_adapter_cfg(in_features=128, atype=adapter_type))
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name1', ['adapter_0', 'encoder:adapter_0', 'decoder:adapter_0'])
|
||||
@pytest.mark.parametrize('name2', ['adapter_1', 'encoder:adapter_1', 'decoder:adapter_1'])
|
||||
def test_asr_multi_adapter_forward(self, model, name1, name2):
|
||||
model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
model.add_adapter(name=name1, cfg=get_adapter_cfg())
|
||||
model.add_adapter(name=name2, cfg=get_adapter_cfg())
|
||||
new_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
resolved_name1 = model.resolve_adapter_module_name_(name1)[-1]
|
||||
resolved_name2 = model.resolve_adapter_module_name_(name2)[-1]
|
||||
assert model.get_enabled_adapters() == [resolved_name1, resolved_name2]
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.parametrize('name1', ['decoder:adapter_0', 'joint:adapter_0'])
|
||||
@pytest.mark.parametrize('name2', ['decoder:adapter_1', 'joint:adapter_1'])
|
||||
@pytest.mark.unit
|
||||
def test_asr_multi_adapter_forward(self, rnnt_model, name1, name2):
|
||||
rnnt_model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = rnnt_model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
rnnt_model.add_adapter(name=name1, cfg=get_adapter_cfg())
|
||||
rnnt_model.add_adapter(name=name2, cfg=get_adapter_cfg())
|
||||
new_output = rnnt_model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
resolved_name1 = rnnt_model.resolve_adapter_module_name_(name1)[-1]
|
||||
resolved_name2 = rnnt_model.resolve_adapter_module_name_(name2)[-1]
|
||||
assert rnnt_model.get_enabled_adapters() == [resolved_name1, resolved_name2]
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name1', ['adapter_0', 'encoder:adapter_0', 'decoder:adapter_0'])
|
||||
@pytest.mark.parametrize('name2', ['adapter_1', 'encoder:adapter_1', 'decoder:adapter_1'])
|
||||
def test_asr_multi_adapter_partial_forward(self, model, name1, name2):
|
||||
model.eval()
|
||||
torch.random.manual_seed(0)
|
||||
input_signal = torch.randn(2, 512)
|
||||
input_signal_length = torch.tensor([512, 512], dtype=torch.int32)
|
||||
|
||||
origial_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
model.add_adapter(name=name1, cfg=get_adapter_cfg())
|
||||
model.add_adapter(name=name2, cfg=get_adapter_cfg())
|
||||
|
||||
model.set_enabled_adapters(name=name1, enabled=False)
|
||||
new_output = model(input_signal=input_signal, input_signal_length=input_signal_length)[0]
|
||||
|
||||
resolved_name2 = model.resolve_adapter_module_name_(name2)[-1]
|
||||
assert model.get_enabled_adapters() == [resolved_name2]
|
||||
assert torch.mean(torch.abs(origial_output - new_output)) < 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('name', ['adapter_0', 'encoder:adapter_0', 'decoder:adapter_0'])
|
||||
def test_asr_forward_unfrozen_adapters(self, model, name):
|
||||
model.eval()
|
||||
original_num_params = model.num_weights
|
||||
|
||||
dim = 10
|
||||
model.add_adapter(name=name, cfg=get_adapter_cfg(dim=dim))
|
||||
model.freeze()
|
||||
model.unfreeze_enabled_adapters()
|
||||
|
||||
assert original_num_params == 5443
|
||||
|
||||
original_params = 0
|
||||
adapter_params = 0
|
||||
for name, param in model.named_parameters():
|
||||
if 'adapter' not in name:
|
||||
assert param.requires_grad is False
|
||||
original_params += param.numel()
|
||||
else:
|
||||
assert param.requires_grad is True
|
||||
adapter_params += param.numel()
|
||||
|
||||
for mname, module in model.named_modules():
|
||||
if isinstance(module, (torch.nn.BatchNorm1d, torch.nn.BatchNorm2d, torch.nn.BatchNorm3d)):
|
||||
assert module.track_running_stats is False
|
||||
|
||||
assert original_params > adapter_params
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_constructor_pretrained(self):
|
||||
# Check to/from config_dict:
|
||||
cfg = ASRModel.from_pretrained('stt_en_citrinet_256', map_location='cpu', return_config=True)
|
||||
adapter_metadata = get_registered_adapter(cfg.encoder._target_)
|
||||
if adapter_metadata is not None:
|
||||
cfg.encoder._target_ = adapter_metadata.adapter_class_path
|
||||
model = ASRModel.from_pretrained('stt_en_citrinet_256', override_config_path=cfg)
|
||||
|
||||
assert isinstance(model, AdapterModuleMixin)
|
||||
assert hasattr(model, 'encoder')
|
||||
assert isinstance(model.encoder, AdapterModuleMixin)
|
||||
|
||||
model.add_adapter('adapter_0', cfg=get_adapter_cfg(in_features=cfg.encoder.jasper[0].filters, dim=5))
|
||||
assert model.is_adapter_available()
|
||||
|
||||
model.freeze()
|
||||
model.unfreeze_enabled_adapters()
|
||||
assert model.num_weights < 1e5
|
||||
|
||||
@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_constructor_pretrained_rnnt(self):
|
||||
# Check to/from config_dict:
|
||||
cfg = ASRModel.from_pretrained('stt_en_fastconformer_transducer_large', map_location='cpu', return_config=True)
|
||||
adapter_metadata = get_registered_adapter(cfg.encoder._target_)
|
||||
if adapter_metadata is not None:
|
||||
cfg.encoder._target_ = adapter_metadata.adapter_class_path
|
||||
model = ASRModel.from_pretrained('stt_en_fastconformer_transducer_large', override_config_path=cfg)
|
||||
|
||||
assert isinstance(model, AdapterModuleMixin)
|
||||
assert hasattr(model, 'encoder')
|
||||
assert isinstance(model.encoder, AdapterModuleMixin)
|
||||
assert hasattr(model, 'decoder')
|
||||
assert isinstance(model.decoder, AdapterModuleMixin)
|
||||
assert hasattr(model, 'joint')
|
||||
assert isinstance(model.joint, AdapterModuleMixin)
|
||||
|
||||
model.add_adapter('adapter_0', cfg=get_adapter_cfg(in_features=cfg.encoder.d_model, dim=5))
|
||||
model.add_adapter('decoder:adapter_1', cfg=get_adapter_cfg(in_features=cfg.decoder.prednet.pred_hidden, dim=5))
|
||||
model.add_adapter('joint:adapter_2', cfg=get_adapter_cfg(in_features=cfg.joint.jointnet.joint_hidden, dim=5))
|
||||
assert model.is_adapter_available()
|
||||
|
||||
model.freeze()
|
||||
model.unfreeze_enabled_adapters()
|
||||
assert model.num_weights < 2e5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_asr_model_adapter_loss(self, model):
|
||||
original_num_params = model.num_weights
|
||||
x = torch.randn(2, 512)
|
||||
x_len = torch.tensor([400, 512], dtype=torch.int32)
|
||||
|
||||
adapter_cfg = get_adapter_cfg() # type: adapter_modules.LinearAdapterConfig
|
||||
adapter_cfg.adapter_strategy.l2_lambda = 0.01
|
||||
|
||||
model.add_adapter(name='adapter_0', cfg=adapter_cfg)
|
||||
new_num_params = model.num_weights
|
||||
assert new_num_params > original_num_params
|
||||
|
||||
model.train() # set training mode to true
|
||||
|
||||
with torch.no_grad():
|
||||
AccessMixin.reset_registry(model)
|
||||
AccessMixin.update_access_cfg({'save_encoder_tensors': False}, model.model_guid)
|
||||
_ = model(input_signal=x, input_signal_length=x_len)
|
||||
|
||||
# extract losses
|
||||
auxiliary_losses = AccessMixin.get_module_registry(model)
|
||||
|
||||
loss = list(auxiliary_losses.values())[0]
|
||||
assert 'adapter_loss' in loss
|
||||
assert loss['adapter_loss'][0] == torch.tensor(0.0) # initially adapter is 0 init, no loss required.
|
||||
|
||||
AccessMixin.reset_registry(model)
|
||||
@@ -0,0 +1,331 @@
|
||||
# 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 pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.submodules import adapters as adapter_modules
|
||||
from nemo.core.classes.mixins import adapter_mixin_strategies
|
||||
from nemo.utils import config_utils
|
||||
|
||||
|
||||
def _create_masks(att_mask, max_audio_length, padding_length):
|
||||
# pad_mask is the masking to be used to ignore paddings
|
||||
pad_mask = torch.arange(0, max_audio_length).expand(padding_length.size(0), -1) < padding_length.unsqueeze(-1)
|
||||
|
||||
# pad_mask_for_att_mask is the mask which helps to ignore paddings
|
||||
pad_mask_for_att_mask = pad_mask.unsqueeze(1).repeat([1, max_audio_length, 1])
|
||||
pad_mask_for_att_mask = torch.logical_and(pad_mask_for_att_mask, pad_mask_for_att_mask.transpose(1, 2))
|
||||
# att_mask is the masking to be used by the MHA layers to ignore the tokens not supposed to be visible
|
||||
att_mask = att_mask[:, :max_audio_length, :max_audio_length]
|
||||
# paddings should also get ignored, so pad_mask_for_att_mask is used to ignore their corresponding scores
|
||||
att_mask = torch.logical_and(pad_mask_for_att_mask, att_mask.to(pad_mask_for_att_mask.device))
|
||||
|
||||
pad_mask = ~pad_mask
|
||||
att_mask = ~att_mask
|
||||
|
||||
return pad_mask, att_mask
|
||||
|
||||
|
||||
def get_mask(lengths: torch.Tensor):
|
||||
max_seq_len = lengths.max()
|
||||
att_mask = torch.ones(1, max_seq_len, max_seq_len, dtype=torch.bool)
|
||||
|
||||
pad_mask, att_mask = _create_masks(att_mask, max_seq_len, lengths)
|
||||
return pad_mask, att_mask
|
||||
|
||||
|
||||
class TestASRAdapterModules:
|
||||
@pytest.mark.unit
|
||||
def test_mha_adapter_config(self):
|
||||
IGNORED_ARGS = ['_target_']
|
||||
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
adapter_modules.MultiHeadAttentionAdapter,
|
||||
adapter_modules.MultiHeadAttentionAdapterConfig,
|
||||
ignore_args=IGNORED_ARGS,
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_relpos_mha_adapter_config(self):
|
||||
IGNORED_ARGS = ['_target_']
|
||||
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
adapter_modules.RelPositionMultiHeadAttentionAdapter,
|
||||
adapter_modules.RelPositionMultiHeadAttentionAdapterConfig,
|
||||
ignore_args=IGNORED_ARGS,
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_abs_pos_encoding_adapter_config(self):
|
||||
IGNORED_ARGS = ['_target_']
|
||||
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
adapter_modules.PositionalEncodingAdapter,
|
||||
adapter_modules.PositionalEncodingAdapterConfig,
|
||||
ignore_args=IGNORED_ARGS,
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rel_pos_encoding_adapter_config(self):
|
||||
IGNORED_ARGS = ['_target_']
|
||||
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
adapter_modules.RelPositionalEncodingAdapter,
|
||||
adapter_modules.RelPositionalEncodingAdapterConfig,
|
||||
ignore_args=IGNORED_ARGS,
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transformer_mha_adapter_config(self):
|
||||
IGNORED_ARGS = ['_target_']
|
||||
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
adapter_modules.TransformerMultiHeadAttentionAdapter,
|
||||
adapter_modules.TransformerMultiHeadAttentionAdapterConfig,
|
||||
ignore_args=IGNORED_ARGS,
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('n_head', [1, 2, 10])
|
||||
@pytest.mark.parametrize('proj_dim', [None, -1])
|
||||
def test_mha_adapter_init(self, n_head, proj_dim):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
adapter = adapter_modules.MultiHeadAttentionAdapter(
|
||||
n_head=n_head, n_feat=50, dropout_rate=0.0, proj_dim=proj_dim
|
||||
)
|
||||
|
||||
pad_mask, att_mask = get_mask(lengths)
|
||||
|
||||
with torch.no_grad():
|
||||
assert adapter.linear_out.weight.sum() == 0
|
||||
if hasattr(adapter.linear_out, 'bias') and adapter.linear_out.bias is not None:
|
||||
assert adapter.linear_out.bias.sum() == 0
|
||||
|
||||
out = adapter(x, x, x, att_mask)
|
||||
assert out.sum().abs() <= 1e-8
|
||||
assert out.shape == x.shape
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('n_head', [1, 2, 10])
|
||||
@pytest.mark.parametrize('proj_dim', [None, -1])
|
||||
def test_relmha_adapter_init(self, n_head, proj_dim):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
adapter = adapter_modules.RelPositionMultiHeadAttentionAdapter(
|
||||
n_head=n_head, n_feat=50, dropout_rate=0.0, proj_dim=proj_dim
|
||||
)
|
||||
relpos_enc = adapter_modules.RelPositionalEncodingAdapter(d_model=50)
|
||||
|
||||
pad_mask, att_mask = get_mask(lengths)
|
||||
relpos_enc.extend_pe(lengths.max(), device='cpu', dtype=torch.float32)
|
||||
|
||||
with torch.no_grad():
|
||||
assert adapter.linear_out.weight.sum() == 0
|
||||
if hasattr(adapter.linear_out, 'bias') and adapter.linear_out.bias is not None:
|
||||
assert adapter.linear_out.bias.sum() == 0
|
||||
|
||||
_, pos_emb = relpos_enc(x)
|
||||
out = adapter(x, x, x, att_mask, pos_emb)
|
||||
assert out.sum().abs() <= 1e-8
|
||||
assert out.shape == x.shape
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_relmha_adapter_with_torch_sdpa(self):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
adapter_torch_sdpa = adapter_modules.RelPositionMultiHeadAttentionAdapter(
|
||||
n_head=2, n_feat=50, dropout_rate=0.0, proj_dim=-1, use_pytorch_sdpa=True
|
||||
)
|
||||
adapter = adapter_modules.RelPositionMultiHeadAttentionAdapter(
|
||||
n_head=2, n_feat=50, dropout_rate=0.0, proj_dim=-1, use_pytorch_sdpa=False
|
||||
)
|
||||
# to dont reset linear_out parameters to zero
|
||||
adapter.linear_out = torch.nn.Linear(adapter.linear_out.in_features, adapter.linear_out.out_features)
|
||||
for original_param, sdpa_param in zip(adapter.parameters(), adapter_torch_sdpa.parameters()):
|
||||
sdpa_param.data.copy_(original_param.data)
|
||||
relpos_enc = adapter_modules.RelPositionalEncodingAdapter(d_model=50)
|
||||
|
||||
pad_mask, att_mask = get_mask(lengths)
|
||||
relpos_enc.extend_pe(lengths.max(), device='cpu', dtype=torch.float32)
|
||||
|
||||
with torch.no_grad():
|
||||
_, pos_emb = relpos_enc(x)
|
||||
out = adapter(x, x, x, att_mask, pos_emb)
|
||||
out_sdpa = adapter_torch_sdpa(x, x, x, att_mask, pos_emb)
|
||||
assert torch.allclose(out_sdpa, out, atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mha_adapter_with_torch_sdpa(self):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
adapter_torch_sdpa = adapter_modules.MultiHeadAttentionAdapter(
|
||||
n_head=2, n_feat=50, dropout_rate=0.0, proj_dim=-1, use_pytorch_sdpa=True
|
||||
)
|
||||
adapter = adapter_modules.MultiHeadAttentionAdapter(
|
||||
n_head=2, n_feat=50, dropout_rate=0.0, proj_dim=-1, use_pytorch_sdpa=False
|
||||
)
|
||||
# to dont reset linear_out parameters to zero
|
||||
adapter.linear_out = torch.nn.Linear(adapter.linear_out.in_features, adapter.linear_out.out_features)
|
||||
|
||||
for original_param, sdpa_param in zip(adapter.parameters(), adapter_torch_sdpa.parameters()):
|
||||
sdpa_param.data.copy_(original_param.data)
|
||||
|
||||
pad_mask, att_mask = get_mask(lengths)
|
||||
with torch.no_grad():
|
||||
out = adapter(x, x, x, att_mask)
|
||||
out_sdpa = adapter_torch_sdpa(x, x, x, att_mask)
|
||||
assert torch.allclose(out_sdpa, out, atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_abspos_encoding_init(self):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
relpos_enc = adapter_modules.PositionalEncodingAdapter(d_model=50)
|
||||
|
||||
relpos_enc.extend_pe(lengths.max(), device='cpu', dtype=torch.float32)
|
||||
|
||||
with torch.no_grad():
|
||||
out, pos_emb = relpos_enc(x)
|
||||
assert (out - pos_emb - x).sum().abs() <= 1e-5
|
||||
assert out.shape == x.shape
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_relpos_encoding_init(self):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
relpos_enc = adapter_modules.RelPositionalEncodingAdapter(d_model=50)
|
||||
|
||||
relpos_enc.extend_pe(lengths.max(), device='cpu', dtype=torch.float32)
|
||||
|
||||
with torch.no_grad():
|
||||
out, pos_emb = relpos_enc(x)
|
||||
assert (out - x).sum().abs() <= 1e-8
|
||||
assert out.shape == x.shape
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('n_head', [1, 2, 10])
|
||||
@pytest.mark.parametrize('proj_dim', [None, -1])
|
||||
def test_transformer_mha_adapter_init(self, n_head, proj_dim):
|
||||
torch.random.manual_seed(0)
|
||||
x = torch.randn(2, 32, 50)
|
||||
lengths = torch.randint(1, x.size(1), size=(x.size(0),))
|
||||
lengths[torch.randint(0, x.size(0), size=(1,))[0]] = x.size(1)
|
||||
|
||||
adapter = adapter_modules.TransformerMultiHeadAttentionAdapter(
|
||||
num_attention_heads=n_head, hidden_size=50, attn_layer_dropout=0.0, proj_dim=proj_dim
|
||||
)
|
||||
|
||||
pad_mask, att_mask = get_mask(lengths)
|
||||
att_mask = att_mask.unsqueeze(1)
|
||||
|
||||
with torch.no_grad():
|
||||
assert adapter.out_projection.weight.sum() == 0
|
||||
if hasattr(adapter.out_projection, 'bias') and adapter.out_projection.bias is not None:
|
||||
assert adapter.out_projection.bias.sum() == 0
|
||||
|
||||
out = adapter(x, x, x, att_mask)
|
||||
assert out.sum().abs() <= 1e-8
|
||||
assert out.shape == x.shape
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mha_adapter_strategy(self):
|
||||
adapter = adapter_modules.MultiHeadAttentionAdapter(n_head=1, n_feat=50, dropout_rate=0.0)
|
||||
assert hasattr(adapter, 'adapter_strategy')
|
||||
assert adapter.adapter_strategy is not None
|
||||
# assert default strategy is set
|
||||
assert isinstance(adapter.adapter_strategy, adapter_modules.MHAResidualAddAdapterStrategy)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_relpos_mha_adapter_strategy(self):
|
||||
adapter = adapter_modules.RelPositionMultiHeadAttentionAdapter(n_head=1, n_feat=50, dropout_rate=0.0)
|
||||
assert hasattr(adapter, 'adapter_strategy')
|
||||
assert adapter.adapter_strategy is not None
|
||||
# assert default strategy is set
|
||||
assert isinstance(adapter.adapter_strategy, adapter_modules.MHAResidualAddAdapterStrategy)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_abspos_encoding_adapter_strategy(self):
|
||||
adapter = adapter_modules.PositionalEncodingAdapter(d_model=50)
|
||||
assert hasattr(adapter, 'adapter_strategy')
|
||||
assert adapter.adapter_strategy is not None
|
||||
# assert default strategy is set
|
||||
assert isinstance(adapter.adapter_strategy, adapter_mixin_strategies.ReturnResultAdapterStrategy)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_relpos_encoding_adapter_strategy(self):
|
||||
adapter = adapter_modules.RelPositionalEncodingAdapter(d_model=50)
|
||||
assert hasattr(adapter, 'adapter_strategy')
|
||||
assert adapter.adapter_strategy is not None
|
||||
# assert default strategy is set
|
||||
assert isinstance(adapter.adapter_strategy, adapter_mixin_strategies.ReturnResultAdapterStrategy)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transformer_mha_adapter_strategy(self):
|
||||
adapter = adapter_modules.TransformerMultiHeadAttentionAdapter(
|
||||
num_attention_heads=1, hidden_size=50, attn_layer_dropout=0.0
|
||||
)
|
||||
assert hasattr(adapter, 'adapter_strategy')
|
||||
assert adapter.adapter_strategy is not None
|
||||
# assert default strategy is set
|
||||
assert isinstance(adapter.adapter_strategy, adapter_modules.MHAResidualAddAdapterStrategy)
|
||||
@@ -0,0 +1,583 @@
|
||||
# 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 json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import open_dict
|
||||
from torch.utils.data import DataLoader, Dataset
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text import _speech_collate_fn
|
||||
from nemo.collections.asr.models.aed_multitask_models import MultiTaskTranscriptionConfig
|
||||
from nemo.collections.asr.parts.mixins import TranscribeConfig, TranscriptionMixin
|
||||
from nemo.collections.asr.parts.mixins.transcription import GenericTranscriptionType
|
||||
from nemo.collections.asr.parts.submodules.multitask_decoding import MultiTaskDecodingConfig
|
||||
from nemo.collections.asr.parts.utils import Hypothesis
|
||||
|
||||
|
||||
class DummyModel(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.encoder = torch.nn.Linear(1, 1)
|
||||
|
||||
self.execution_count = 0
|
||||
self.flag_begin = False
|
||||
self.flag_end = False
|
||||
|
||||
def forward(self, x):
|
||||
# Input: [1, 1] Output = [1, 1
|
||||
out = self.encoder(x)
|
||||
return out
|
||||
|
||||
|
||||
class DummyDatasetAudioOnly(Dataset):
|
||||
def __init__(self, audio_files: List[str], config: Dict):
|
||||
self.audio_files = audio_files
|
||||
self.config = config
|
||||
|
||||
def __getitem__(self, index):
|
||||
data = self.audio_files[index]
|
||||
data = torch.tensor([float(data)]).view(1)
|
||||
return data
|
||||
|
||||
def __len__(self):
|
||||
return len(self.audio_files)
|
||||
|
||||
|
||||
class DummyDataset(Dataset):
|
||||
def __init__(self, audio_tensors: List[str], config: Dict = None):
|
||||
self.audio_tensors = audio_tensors
|
||||
self.config = config
|
||||
|
||||
def __getitem__(self, index):
|
||||
data = self.audio_tensors[index]
|
||||
samples = torch.tensor(data)
|
||||
# Calculate seq length
|
||||
seq_len = torch.tensor(samples.shape[0], dtype=torch.long)
|
||||
|
||||
# Dummy text tokens
|
||||
text_tokens = torch.tensor([0], dtype=torch.long)
|
||||
text_tokens_len = torch.tensor(1, dtype=torch.long)
|
||||
|
||||
return (samples, seq_len, text_tokens, text_tokens_len)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.audio_tensors)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def audio_files(test_data_dir):
|
||||
"""
|
||||
Returns a list of audio files for testing.
|
||||
"""
|
||||
import soundfile as sf
|
||||
|
||||
audio_file1 = os.path.join(test_data_dir, "asr", "train", "an4", "wav", "an46-mmap-b.wav")
|
||||
audio_file2 = os.path.join(test_data_dir, "asr", "train", "an4", "wav", "an104-mrcb-b.wav")
|
||||
|
||||
audio1, _ = sf.read(audio_file1, dtype='float32')
|
||||
audio2, _ = sf.read(audio_file2, dtype='float32')
|
||||
|
||||
return audio1, audio2
|
||||
|
||||
|
||||
class TranscribableDummy(DummyModel, TranscriptionMixin):
|
||||
def _transcribe_on_begin(self, audio, trcfg: TranscribeConfig):
|
||||
super()._transcribe_on_begin(audio, trcfg)
|
||||
self.flag_begin = True
|
||||
|
||||
def _transcribe_input_manifest_processing(self, audio_files: List[str], temp_dir: str, trcfg: TranscribeConfig):
|
||||
# Create a dummy manifest
|
||||
manifest_path = os.path.join(temp_dir, 'dummy_manifest.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
for audio_file in audio_files:
|
||||
entry = {'audio_filepath': audio_file, 'duration': 100000, 'text': ''}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
ds_config = {
|
||||
'paths2audio_files': audio_files,
|
||||
'batch_size': trcfg.batch_size,
|
||||
'temp_dir': temp_dir,
|
||||
'num_workers': trcfg.num_workers,
|
||||
'channel_selector': trcfg.channel_selector,
|
||||
}
|
||||
|
||||
return ds_config
|
||||
|
||||
def _setup_transcribe_dataloader(self, config: Dict) -> DataLoader:
|
||||
dataset = DummyDatasetAudioOnly(config['paths2audio_files'], config)
|
||||
|
||||
return DataLoader(
|
||||
dataset=dataset,
|
||||
batch_size=config['batch_size'],
|
||||
num_workers=config['num_workers'],
|
||||
pin_memory=False,
|
||||
drop_last=False,
|
||||
)
|
||||
|
||||
def _transcribe_forward(self, batch: Any, trcfg: TranscribeConfig):
|
||||
output = self(batch)
|
||||
return output
|
||||
|
||||
def _transcribe_output_processing(self, outputs, trcfg: TranscribeConfig) -> GenericTranscriptionType:
|
||||
self.execution_count += 1
|
||||
|
||||
result = []
|
||||
for output in outputs:
|
||||
result.append(float(output.item()))
|
||||
|
||||
if hasattr(trcfg, 'output_type') and trcfg.output_type == 'dict':
|
||||
results = {'output': result}
|
||||
return results
|
||||
|
||||
if hasattr(trcfg, 'output_type') and trcfg.output_type == 'dict2':
|
||||
results = [{'output': res} for res in result]
|
||||
return results
|
||||
|
||||
if hasattr(trcfg, 'output_type') and trcfg.output_type == 'tuple':
|
||||
result = tuple(result)
|
||||
return result
|
||||
|
||||
# Pass list of results by default
|
||||
return result
|
||||
|
||||
def _transcribe_on_end(self, trcfg: TranscribeConfig):
|
||||
super()._transcribe_on_end(trcfg)
|
||||
self.flag_end = True
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dummy_model():
|
||||
return TranscribableDummy()
|
||||
|
||||
|
||||
class TestTranscriptionMixin:
|
||||
@pytest.mark.unit
|
||||
def test_constructor_non_instance(self):
|
||||
model = DummyModel()
|
||||
assert not isinstance(model, TranscriptionMixin)
|
||||
assert not hasattr(model, 'transcribe')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcribe(self, dummy_model):
|
||||
dummy_model = dummy_model.eval()
|
||||
dummy_model.encoder.weight.data.fill_(1.0)
|
||||
dummy_model.encoder.bias.data.fill_(0.0)
|
||||
|
||||
audio = ['1.0', '2.0', '3.0']
|
||||
outputs = dummy_model.transcribe(audio, batch_size=1)
|
||||
assert len(outputs) == 3
|
||||
assert outputs[0] == 1.0
|
||||
assert outputs[1] == 2.0
|
||||
assert outputs[2] == 3.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_generator(self, dummy_model):
|
||||
dummy_model = dummy_model.eval()
|
||||
dummy_model.encoder.weight.data.fill_(1.0)
|
||||
dummy_model.encoder.bias.data.fill_(0.0)
|
||||
|
||||
audio = ['1.0', '2.0', '3.0']
|
||||
|
||||
transribe_config = TranscribeConfig(batch_size=1)
|
||||
generator = dummy_model.transcribe_generator(audio, override_config=transribe_config)
|
||||
|
||||
outputs = []
|
||||
index = 1
|
||||
for result in generator:
|
||||
outputs.extend(result)
|
||||
assert len(result) == 1
|
||||
assert len(outputs) == index
|
||||
index += 1
|
||||
|
||||
assert len(outputs) == 3
|
||||
assert outputs[0] == 1.0
|
||||
assert outputs[1] == 2.0
|
||||
assert outputs[2] == 3.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_generator_explicit_stop_check(self, dummy_model):
|
||||
dummy_model = dummy_model.eval()
|
||||
dummy_model.encoder.weight.data.fill_(1.0)
|
||||
dummy_model.encoder.bias.data.fill_(0.0)
|
||||
|
||||
audio = ['1.0', '2.0', '3.0']
|
||||
|
||||
transribe_config = TranscribeConfig(batch_size=1)
|
||||
generator = dummy_model.transcribe_generator(audio, override_config=transribe_config)
|
||||
|
||||
outputs = []
|
||||
index = 1
|
||||
while True:
|
||||
try:
|
||||
result = next(generator)
|
||||
except StopIteration:
|
||||
break
|
||||
outputs.extend(result)
|
||||
assert len(result) == 1
|
||||
assert len(outputs) == index
|
||||
index += 1
|
||||
|
||||
assert len(outputs) == 3
|
||||
assert outputs[0] == 1.0
|
||||
assert outputs[1] == 2.0
|
||||
assert outputs[2] == 3.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_check_flags(self, dummy_model):
|
||||
dummy_model = dummy_model.eval()
|
||||
|
||||
audio = ['1.0', '2.0', '3.0']
|
||||
dummy_model.transcribe(audio, batch_size=1)
|
||||
assert dummy_model.flag_begin
|
||||
assert dummy_model.flag_end
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transribe_override_config_incorrect(self, dummy_model):
|
||||
# Not subclassing TranscribeConfig
|
||||
@dataclass
|
||||
class OverrideConfig:
|
||||
batch_size: int = 1
|
||||
output_type: str = 'dict'
|
||||
|
||||
dummy_model = dummy_model.eval()
|
||||
|
||||
audio = [1.0, 2.0, 3.0]
|
||||
override_cfg = OverrideConfig(batch_size=1, output_type='dict')
|
||||
with pytest.raises(ValueError):
|
||||
_ = dummy_model.transcribe(audio, override_config=override_cfg)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transribe_override_config_correct(self, dummy_model):
|
||||
@dataclass
|
||||
class OverrideConfig(TranscribeConfig):
|
||||
output_type: str = 'dict'
|
||||
verbose: bool = False
|
||||
|
||||
dummy_model = dummy_model.eval()
|
||||
dummy_model.encoder.weight.data.fill_(1.0)
|
||||
dummy_model.encoder.bias.data.fill_(0.0)
|
||||
|
||||
audio = ['1.0', '2.0', '3.0']
|
||||
override_cfg = OverrideConfig(batch_size=1, output_type='dict')
|
||||
outputs = dummy_model.transcribe(audio, override_config=override_cfg)
|
||||
|
||||
assert isinstance(outputs, dict)
|
||||
assert len(outputs) == 1
|
||||
assert dummy_model.execution_count == 3
|
||||
assert outputs['output'][0] == 1.0
|
||||
assert outputs['output'][1] == 2.0
|
||||
assert outputs['output'][2] == 3.0
|
||||
|
||||
# Reset execution count
|
||||
dummy_model.execution_count = 0
|
||||
|
||||
override_cfg = OverrideConfig(batch_size=1, output_type='dict2')
|
||||
outputs = dummy_model.transcribe(audio, override_config=override_cfg)
|
||||
|
||||
# Output now is list of dict of value each
|
||||
assert isinstance(outputs, list)
|
||||
assert len(outputs) == 3
|
||||
assert dummy_model.execution_count == 3
|
||||
assert outputs[0]['output'] == 1.0
|
||||
assert outputs[1]['output'] == 2.0
|
||||
assert outputs[2]['output'] == 3.0
|
||||
|
||||
# Reset execution count
|
||||
dummy_model.execution_count = 0
|
||||
|
||||
# Test tuple
|
||||
override_cfg = OverrideConfig(batch_size=1, output_type='tuple')
|
||||
outputs = dummy_model.transcribe(audio, override_config=override_cfg)
|
||||
|
||||
assert isinstance(outputs, tuple)
|
||||
assert len(outputs) == 1
|
||||
assert dummy_model.execution_count == 3
|
||||
assert outputs[0][0] == 1.0
|
||||
assert outputs[0][1] == 2.0
|
||||
assert outputs[0][2] == 3.0
|
||||
|
||||
pytest.mark.with_downloads()
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_return_hypothesis(self, test_data_dir, fast_conformer_ctc_model):
|
||||
audio_file = os.path.join(test_data_dir, "asr", "train", "an4", "wav", "an46-mmap-b.wav")
|
||||
|
||||
# Audio file test
|
||||
outputs = fast_conformer_ctc_model.transcribe(audio_file, batch_size=1, return_hypotheses=True)
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], Hypothesis)
|
||||
|
||||
hyp = outputs[0]
|
||||
assert isinstance(hyp.text, str)
|
||||
assert isinstance(hyp.y_sequence, torch.Tensor)
|
||||
assert isinstance(hyp.alignments, torch.Tensor)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_tensor(self, audio_files, fast_conformer_ctc_model):
|
||||
|
||||
audio, _ = audio_files
|
||||
# Numpy array test
|
||||
outputs = fast_conformer_ctc_model.transcribe(audio, batch_size=1)
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_multiple_tensor(self, audio_files, fast_conformer_ctc_model):
|
||||
|
||||
audio, audio_2 = audio_files
|
||||
# Mix second audio to torch.tensor()
|
||||
audio_2 = torch.tensor(audio_2)
|
||||
|
||||
# Numpy array test
|
||||
outputs = fast_conformer_ctc_model.transcribe([audio, audio_2], batch_size=2)
|
||||
assert len(outputs) == 2
|
||||
assert isinstance(outputs[0], Hypothesis)
|
||||
assert isinstance(outputs[1], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_dataloader(self, audio_files, fast_conformer_ctc_model):
|
||||
|
||||
audio, audio2 = audio_files
|
||||
|
||||
dataset = DummyDataset([audio, audio2])
|
||||
collate_fn = lambda x: _speech_collate_fn(x, pad_id=0)
|
||||
dataloader = DataLoader(dataset, batch_size=2, shuffle=False, num_workers=0, collate_fn=collate_fn)
|
||||
|
||||
# DataLoader test
|
||||
outputs = fast_conformer_ctc_model.transcribe(dataloader, batch_size=1)
|
||||
assert len(outputs) == 2
|
||||
assert isinstance(outputs[0], Hypothesis)
|
||||
assert isinstance(outputs[1], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_return_nbest_rnnt(self, audio_files, fast_conformer_transducer_model):
|
||||
fast_conformer_transducer_model.eval()
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
orig_decoding_config = copy.deepcopy(fast_conformer_transducer_model.cfg.decoding)
|
||||
|
||||
decoding_config = copy.deepcopy(fast_conformer_transducer_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
|
||||
fast_conformer_transducer_model.change_decoding_strategy(decoding_config)
|
||||
|
||||
outputs = fast_conformer_transducer_model.transcribe([audio1, audio2], batch_size=1, timestamps=False)
|
||||
|
||||
assert len(outputs) == 2
|
||||
assert all(len(output) >= 1 for output in outputs)
|
||||
assert all(isinstance(output, list) for output in outputs)
|
||||
assert all(isinstance(hyp, Hypothesis) for output in outputs for hyp in output)
|
||||
|
||||
# Reset the decoding strategy to original
|
||||
fast_conformer_transducer_model.change_decoding_strategy(orig_decoding_config)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_return_nbest_canary(self, audio_files, canary_1b_flash):
|
||||
canary_1b_flash.eval()
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
orig_decoding_config = copy.deepcopy(canary_1b_flash.cfg.decoding)
|
||||
|
||||
decoding_config = copy.deepcopy(canary_1b_flash.cfg.decoding)
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["beam"]["beam_size"] = 4
|
||||
decoding_config["beam"]["return_best_hypothesis"] = False
|
||||
canary_1b_flash.change_decoding_strategy(decoding_config)
|
||||
|
||||
outputs = canary_1b_flash.transcribe([audio1, audio2], batch_size=1, timestamps=False)
|
||||
|
||||
assert len(outputs) == 2
|
||||
assert all(len(output) >= 1 for output in outputs)
|
||||
assert all(isinstance(output, list) for output in outputs)
|
||||
assert all(isinstance(hyp, Hypothesis) for output in outputs for hyp in output)
|
||||
|
||||
# Reset the decoding strategy to original
|
||||
canary_1b_flash.change_decoding_strategy(orig_decoding_config)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_timestamps_with_transcribe(self, audio_files, fast_conformer_ctc_model):
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
output = fast_conformer_ctc_model.transcribe([audio1, audio2], timestamps=True)
|
||||
|
||||
# check len of output
|
||||
assert len(output) == 2
|
||||
|
||||
# check hypothesis object
|
||||
assert isinstance(output[0], Hypothesis)
|
||||
# check transcript
|
||||
assert output[0].text == 'stop'
|
||||
assert output[1].text == 'start'
|
||||
|
||||
# check timestamp
|
||||
assert output[0].timestamp['segment'][0]['start'] == pytest.approx(0.4)
|
||||
assert output[0].timestamp['segment'][0]['end'] == pytest.approx(0.48)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_timestamps_with_transcribe_hybrid(self, audio_files, fast_conformer_hybrid_model):
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
output = fast_conformer_hybrid_model.transcribe([audio1, audio2], timestamps=True)
|
||||
|
||||
# check len of output
|
||||
assert len(output) == 2
|
||||
|
||||
# check hypothesis object
|
||||
assert isinstance(output[0], Hypothesis)
|
||||
# check transcript
|
||||
assert output[0].text == 'Stop?'
|
||||
assert output[1].text == 'Start.'
|
||||
|
||||
# check timestamp
|
||||
assert output[0].timestamp['segment'][0]['start'] == pytest.approx(0.48)
|
||||
assert output[0].timestamp['segment'][0]['end'] == pytest.approx(0.72)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_timestamps_with_transcribe_hybrid_ctc_head(self, audio_files, fast_conformer_hybrid_model):
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
fast_conformer_hybrid_model.change_decoding_strategy(decoder_type='ctc')
|
||||
|
||||
output = fast_conformer_hybrid_model.transcribe([audio1, audio2], timestamps=True)
|
||||
|
||||
# check len of output
|
||||
assert len(output) == 2
|
||||
|
||||
# check hypothesis object
|
||||
assert isinstance(output[0], Hypothesis)
|
||||
# check transcript
|
||||
assert output[0].text in ['Stop', 'Stop?']
|
||||
assert output[1].text in ['Start', 'Start.']
|
||||
|
||||
# check timestamp
|
||||
assert output[0].timestamp['segment'][0]['start'] == pytest.approx(0.4)
|
||||
assert output[0].timestamp['segment'][0]['end'] == pytest.approx(0.72)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_timestamps_with_transcribe_canary_flash(self, audio_files, canary_1b_flash):
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
output = canary_1b_flash.transcribe([audio1, audio2], timestamps=True)
|
||||
|
||||
# check len of output
|
||||
assert len(output) == 2
|
||||
|
||||
# check hypothesis object
|
||||
assert isinstance(output[0], Hypothesis)
|
||||
# check transcript
|
||||
assert output[0].text == 'Stop'
|
||||
assert output[1].text == 'start'
|
||||
|
||||
# check timestamp
|
||||
assert output[0].timestamp['segment'][0]['start'] == pytest.approx(0.32)
|
||||
assert output[0].timestamp['segment'][0]['end'] == pytest.approx(0.72)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_return_nbest_hybrid_rnnt_ctc_prompt(self, audio_files, hybrid_rnnt_ctc_bpe_model_with_prompt):
|
||||
"""Test n-best hypothesis return for hybrid RNNT-CTC BPE model with prompts."""
|
||||
hybrid_rnnt_ctc_bpe_model_with_prompt.eval()
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
orig_decoding_config = copy.deepcopy(hybrid_rnnt_ctc_bpe_model_with_prompt.cfg.decoding)
|
||||
|
||||
decoding_config = copy.deepcopy(hybrid_rnnt_ctc_bpe_model_with_prompt.cfg.decoding)
|
||||
with open_dict(decoding_config):
|
||||
decoding_config["strategy"] = "beam"
|
||||
decoding_config["beam"]["beam_size"] = 4
|
||||
decoding_config["beam"]["return_best_hypothesis"] = False
|
||||
|
||||
hybrid_rnnt_ctc_bpe_model_with_prompt.change_decoding_strategy(decoding_config)
|
||||
|
||||
# Transcribe audio with prompt parameters
|
||||
hypotheses = hybrid_rnnt_ctc_bpe_model_with_prompt.transcribe(
|
||||
[audio1, audio2], batch_size=2, return_hypotheses=True, target_lang="en-US"
|
||||
)
|
||||
|
||||
# Check results
|
||||
assert len(hypotheses) == 2
|
||||
assert isinstance(hypotheses[0], list) # n-best list
|
||||
assert len(hypotheses[0]) > 0 # at least one hypothesis
|
||||
|
||||
# Restore original decoding config
|
||||
hybrid_rnnt_ctc_bpe_model_with_prompt.change_decoding_strategy(orig_decoding_config)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_timestamps_with_transcribe_hybrid_prompt(self, audio_files, hybrid_rnnt_ctc_bpe_model_with_prompt):
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
output = hybrid_rnnt_ctc_bpe_model_with_prompt.transcribe(
|
||||
[audio1, audio2], timestamps=True, target_lang="en-US"
|
||||
)
|
||||
|
||||
# check len of output
|
||||
assert len(output) == 2
|
||||
|
||||
# check hypothesis object
|
||||
assert isinstance(output[0], Hypothesis)
|
||||
# check transcript
|
||||
assert output[0].text == 'Stop'
|
||||
assert output[1].text == 'Start'
|
||||
|
||||
# check timestamp
|
||||
assert output[0].timestamp['segment'][0]['start'] == pytest.approx(0.16)
|
||||
assert output[0].timestamp['segment'][0]['end'] == pytest.approx(0.56)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_transcribe_returns_xattn(self, audio_files, canary_1b_v2):
|
||||
canary_1b_v2.eval()
|
||||
audio1, audio2 = audio_files
|
||||
|
||||
orig_decoding_config = copy.deepcopy(canary_1b_v2.cfg.decoding)
|
||||
|
||||
decoding_config = MultiTaskDecodingConfig()
|
||||
decoding_config.return_xattn_scores = True
|
||||
canary_1b_v2.change_decoding_strategy(decoding_config)
|
||||
|
||||
config = MultiTaskTranscriptionConfig(
|
||||
batch_size=4,
|
||||
return_hypotheses=True,
|
||||
num_workers=0,
|
||||
verbose=False,
|
||||
prompt={'source_lang': 'en', 'target_lang': 'en'},
|
||||
enable_chunking=False,
|
||||
)
|
||||
|
||||
output = canary_1b_v2.transcribe([audio1, audio2], override_config=config)
|
||||
assert output[0].xatt_scores is not None
|
||||
assert output[1].xatt_scores is not None
|
||||
|
||||
# Reset the decoding strategy to original
|
||||
canary_1b_v2.change_decoding_strategy(orig_decoding_config)
|
||||
@@ -0,0 +1,629 @@
|
||||
# Copyright (c) 2021, 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 random
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.losses.rnnt import MultiblankRNNTLossPytorch, RNNTLossPytorch, TDTLossPytorch
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.rnnt_numpy import RNNTLoss as RNNTLoss_Numpy
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.rnnt_pytorch import (
|
||||
MultiblankRNNTLossNumba,
|
||||
RNNTLossNumba,
|
||||
TDTLossNumba,
|
||||
)
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
DEVICES = ['cpu']
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append('cuda')
|
||||
|
||||
CUDA_ONLY_DEVICE = ['cuda']
|
||||
|
||||
DTYPES = [np.float32]
|
||||
if numba_utils.is_numba_cuda_fp16_supported():
|
||||
DTYPES.append(np.float16)
|
||||
|
||||
|
||||
def wrap_and_call(fn, acts, labels, device):
|
||||
if not torch.is_tensor(acts):
|
||||
acts = torch.tensor(acts)
|
||||
|
||||
if 'cuda' in device:
|
||||
acts = acts.cuda()
|
||||
|
||||
if not acts.requires_grad:
|
||||
acts.requires_grad = True
|
||||
|
||||
lengths = [acts.shape[1]] * acts.shape[0]
|
||||
label_lengths = [len(l) for l in labels]
|
||||
labels = torch.LongTensor(labels)
|
||||
lengths = torch.LongTensor(lengths)
|
||||
label_lengths = torch.LongTensor(label_lengths)
|
||||
if 'cuda' in device:
|
||||
labels = labels.cuda()
|
||||
lengths = lengths.cuda()
|
||||
label_lengths = label_lengths.cuda()
|
||||
|
||||
costs = fn(acts, labels, lengths, label_lengths)
|
||||
cost = torch.sum(costs)
|
||||
cost.backward()
|
||||
|
||||
if 'cuda' in device:
|
||||
torch.cuda.synchronize()
|
||||
|
||||
if acts.grad is not None:
|
||||
grad = acts.grad.data.cpu().numpy()
|
||||
else:
|
||||
grad = None
|
||||
|
||||
return costs.data.cpu().numpy(), grad
|
||||
|
||||
|
||||
class TestRNNTLossPytorch:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_case_small(self, device, dtype):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
acts = np.array(
|
||||
[
|
||||
[
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.6, 0.1, 0.1], [0.1, 0.1, 0.2, 0.8, 0.1]],
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.2, 0.1, 0.1], [0.7, 0.1, 0.2, 0.1, 0.1]],
|
||||
]
|
||||
]
|
||||
).astype(dtype)
|
||||
labels = [[1, 2]]
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 1e-4
|
||||
rtol = 1e-5 if dtype == np.float32 else 1e-3
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum')
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy()
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
fn_ag = RNNTLossPytorch(blank=0, reduction='sum') # ag for automatic gradient computation
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
expected_cost = 4.495666
|
||||
expected_grads = np.array(
|
||||
[
|
||||
[
|
||||
[
|
||||
[-0.13116688, -0.3999269, 0.17703125, 0.17703125, 0.17703125],
|
||||
[-0.18572757, 0.12247056, -0.18168412, 0.12247056, 0.12247056],
|
||||
[-0.32091254, 0.06269141, 0.06928472, 0.12624499, 0.06269141],
|
||||
],
|
||||
[
|
||||
[0.05456069, -0.21824276, 0.05456069, 0.05456069, 0.05456069],
|
||||
[0.12073959, 0.12073959, -0.48295835, 0.12073959, 0.12073959],
|
||||
[-0.6925882, 0.16871116, 0.18645467, 0.16871116, 0.16871116],
|
||||
],
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
assert np.allclose(pt_cost, expected_cost, atol=cost_threshold, rtol=1e-6), "small_test costs mismatch."
|
||||
assert np.allclose(pt_grads, expected_grads, atol=grad_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
assert np.allclose(pt_cost, np_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, atol=grad_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
assert np.allclose(ag_cost, np_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(ag_grads, np_grads, atol=cost_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_case_small_random(self, device, dtype):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 1e-4
|
||||
rtol = 1e-5 if dtype == np.float32 else 1e-3
|
||||
|
||||
rng = np.random.RandomState(0)
|
||||
acts = rng.randn(1, 4, 3, 3).astype(dtype)
|
||||
labels = [[1, 2]]
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum')
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy()
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
fn_ag = RNNTLossPytorch(blank=0, reduction='sum') # ag for automatic gradient computation
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, np_cost, atol=cost_threshold, rtol=rtol), "small_random_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, atol=grad_threshold, rtol=rtol), "small_random_test gradient mismatch."
|
||||
|
||||
assert np.allclose(pt_cost, ag_cost, atol=cost_threshold, rtol=rtol), "small_random_test costs mismatch."
|
||||
assert np.allclose(pt_grads, ag_grads, atol=grad_threshold, rtol=rtol), "small_random_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
@pytest.mark.parametrize('fastemit_lambda', [1.0, 0.01, 0.00001])
|
||||
def test_case_small_random_fastemit_reg(self, device, dtype, fastemit_lambda):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
rng = np.random.RandomState(0)
|
||||
acts = rng.randn(1, 4, 3, 3)
|
||||
labels = [[1, 2]]
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum', fastemit_lambda=fastemit_lambda)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy(fastemit_lambda=fastemit_lambda)
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, np_cost, rtol=1e-6), "small_random_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, rtol=1e-5), "small_random_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_case_big_tensor(self, device, dtype):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# minibatch x T x U x alphabet_size
|
||||
activations = [
|
||||
[
|
||||
[
|
||||
[0.06535690384862791, 0.7875301411923206, 0.08159176605666074],
|
||||
[0.5297155426466327, 0.7506749639230854, 0.7541348379087998],
|
||||
[0.6097641124736383, 0.8681404965673826, 0.6225318186056529],
|
||||
],
|
||||
[
|
||||
[0.6685222872103057, 0.8580392805336061, 0.16453892311765583],
|
||||
[0.989779515236694, 0.944298460961015, 0.6031678586829663],
|
||||
[0.9467833543605416, 0.666202507295747, 0.28688179752461884],
|
||||
],
|
||||
[
|
||||
[0.09418426230195986, 0.3666735970751962, 0.736168049462793],
|
||||
[0.1666804425271342, 0.7141542198635192, 0.3993997272216727],
|
||||
[0.5359823524146038, 0.29182076440286386, 0.6126422611507932],
|
||||
],
|
||||
[
|
||||
[0.3242405528768486, 0.8007644367291621, 0.5241057606558068],
|
||||
[0.779194617063042, 0.18331417220174862, 0.113745182072432],
|
||||
[0.24022162381327106, 0.3394695622533106, 0.1341595066017014],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[0.5055615569388828, 0.051597282072282646, 0.6402903936686337],
|
||||
[0.43073311517251, 0.8294731834714112, 0.1774668847323424],
|
||||
[0.3207001991262245, 0.04288308912457006, 0.30280282975568984],
|
||||
],
|
||||
[
|
||||
[0.6751777088333762, 0.569537369330242, 0.5584738347504452],
|
||||
[0.08313242153985256, 0.06016544344162322, 0.10795752845152584],
|
||||
[0.7486153608562472, 0.943918041459349, 0.4863558118797222],
|
||||
],
|
||||
[
|
||||
[0.4181986264486809, 0.6524078485043804, 0.024242983423721887],
|
||||
[0.13458171554507403, 0.3663418070512402, 0.2958297395361563],
|
||||
[0.9236695822497084, 0.6899291482654177, 0.7418981733448822],
|
||||
],
|
||||
[
|
||||
[0.25000547599982104, 0.6034295486281007, 0.9872887878887768],
|
||||
[0.5926057265215715, 0.8846724004467684, 0.5434495396894328],
|
||||
[0.6607698886038497, 0.3771277082495921, 0.3580209022231813],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
expected_costs = [4.2806528590890736, 3.9384369822503591]
|
||||
expected_grads = [
|
||||
[
|
||||
[
|
||||
[-1.86843902e-01, -6.25548810e-02, 2.49398798e-01],
|
||||
[-2.03376666e-01, 2.02399328e-01, 9.77333169e-04],
|
||||
[-1.41016081e-01, 7.91234672e-02, 6.18926100e-02],
|
||||
],
|
||||
[
|
||||
[-1.15517676e-02, -8.12802389e-02, 9.28319991e-02],
|
||||
[-1.54257029e-01, 2.29432687e-01, -7.51756504e-02],
|
||||
[-2.46593088e-01, 1.46404594e-01, 1.00188486e-01],
|
||||
],
|
||||
[
|
||||
[-1.29182907e-02, -6.15932420e-02, 7.45115355e-02],
|
||||
[-5.59857301e-02, 2.19830811e-01, -1.63845062e-01],
|
||||
[-4.97626871e-01, 2.09239945e-01, 2.88386941e-01],
|
||||
],
|
||||
[
|
||||
[1.36048580e-02, -3.02196294e-02, 1.66147724e-02],
|
||||
[1.13924511e-01, 6.27811998e-02, -1.76705718e-01],
|
||||
[-6.67078257e-01, 3.67658824e-01, 2.99419403e-01],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[-3.56343776e-01, -5.53474613e-02, 4.11691219e-01],
|
||||
[-9.69219357e-02, 2.94591039e-02, 6.74628317e-02],
|
||||
[-6.35175705e-02, 2.76544970e-02, 3.58630717e-02],
|
||||
],
|
||||
[
|
||||
[-1.54499024e-01, -7.39420280e-02, 2.28441030e-01],
|
||||
[-1.66789949e-01, -8.78955179e-05, 1.66877866e-01],
|
||||
[-1.72369644e-01, 1.05565332e-01, 6.68043196e-02],
|
||||
],
|
||||
[
|
||||
[2.38748826e-02, -1.18255816e-01, 9.43809375e-02],
|
||||
[-1.04707085e-01, -1.08934477e-01, 2.13641584e-01],
|
||||
[-3.69844258e-01, 1.80118099e-01, 1.89726159e-01],
|
||||
],
|
||||
[
|
||||
[2.57137045e-02, -7.94617534e-02, 5.37480488e-02],
|
||||
[1.22328237e-01, -2.38788679e-01, 1.16460443e-01],
|
||||
[-5.98686993e-01, 3.02203178e-01, 2.96483815e-01],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
activations = np.array(activations).astype(dtype)
|
||||
labels = [[1, 2], [1, 1]]
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 1e-4
|
||||
rtol = 1e-3 if dtype == np.float32 else 0.1
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum')
|
||||
pt_costs, pt_grads = wrap_and_call(fn_pt, activations, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy()
|
||||
np_costs, np_grads = wrap_and_call(fn_np, activations, labels, device)
|
||||
|
||||
fn_ag = RNNTLossPytorch(blank=0, reduction='sum')
|
||||
ag_costs, ag_grads = wrap_and_call(fn_ag, activations, labels, device)
|
||||
|
||||
assert np.allclose(pt_costs, sum(expected_costs), atol=cost_threshold), "big_test average costs mismatch."
|
||||
assert np.allclose(
|
||||
pt_grads, expected_grads, atol=grad_threshold, rtol=1e-3
|
||||
), "big_test grads for average cost mismatch."
|
||||
|
||||
assert np.allclose(pt_costs, np_costs, atol=cost_threshold, rtol=rtol), "big_test average costs mismatch."
|
||||
assert np.allclose(
|
||||
pt_grads, np_grads, atol=grad_threshold, rtol=rtol
|
||||
), "big_test grads for average cost mismatch."
|
||||
|
||||
assert np.allclose(pt_costs, ag_costs, atol=cost_threshold, rtol=rtol), "big_test average costs mismatch."
|
||||
assert np.allclose(
|
||||
pt_grads, ag_grads, atol=grad_threshold, rtol=rtol
|
||||
), "big_test grads for average cost mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_case_large_random(self, device, dtype):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
rng = np.random.RandomState(0)
|
||||
acts = rng.randn(4, 8, 11, 5).astype(dtype)
|
||||
labels = [
|
||||
[1, 2, 4, 3, 2, 2, 1, 1, 1, 1],
|
||||
[3, 2, 2, 3, 4, 1, 1, 1, 1, 1],
|
||||
[4, 4, 1, 2, 1, 3, 4, 3, 1, 2],
|
||||
[1, 1, 2, 1, 2, 3, 3, 1, 1, 1],
|
||||
]
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 1e-4
|
||||
rtol = 1e-3 if dtype == np.float32 else 5e-2
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum')
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy()
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
fn_ag = RNNTLossPytorch(blank=0, reduction='sum')
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, np_cost, atol=cost_threshold, rtol=rtol), "large_random_test costs mismatch."
|
||||
assert np.allclose(ag_cost, np_cost, atol=cost_threshold, rtol=rtol), "large_random_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, atol=grad_threshold, rtol=rtol), "large_random_test gradient mismatch."
|
||||
assert np.allclose(ag_grads, np_grads, atol=grad_threshold, rtol=rtol), "large_random_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_case_small_clamp(self, device, dtype):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
GRAD_CLAMP = 0.1
|
||||
acts = np.array(
|
||||
[
|
||||
[
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.6, 0.1, 0.1], [0.1, 0.1, 0.2, 0.8, 0.1]],
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.2, 0.1, 0.1], [0.7, 0.1, 0.2, 0.1, 0.1]],
|
||||
]
|
||||
]
|
||||
).astype(dtype)
|
||||
labels = [[1, 2]]
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 5e-5
|
||||
rtol = 1e-5 if dtype == np.float32 else 1e-3
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum', clamp=GRAD_CLAMP)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy(blank=0, clamp=GRAD_CLAMP)
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
expected_cost = 4.495666
|
||||
expected_grads = np.array(
|
||||
[
|
||||
[
|
||||
[
|
||||
[-0.1, -0.1, 0.1, 0.1, 0.1],
|
||||
[-0.1, 0.1, -0.1, 0.1, 0.1],
|
||||
[-0.1, 0.06269141, 0.06928472, 0.1, 0.06269141],
|
||||
],
|
||||
[
|
||||
[0.05456069, -0.1, 0.05456069, 0.05456069, 0.05456069],
|
||||
[0.1, 0.1, -0.1, 0.1, 0.1],
|
||||
[-0.1, 0.1, 0.1, 0.1, 0.1],
|
||||
],
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
assert np.allclose(pt_cost, expected_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(pt_grads, expected_grads, atol=grad_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
assert np.allclose(pt_cost, np_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, atol=grad_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
@pytest.mark.parametrize('fastemit_lambda', [1.0, 0.01, 0.00001])
|
||||
def test_case_small_fastemit_clamp(self, device, dtype, fastemit_lambda):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
GRAD_CLAMP = 0.1
|
||||
acts = np.array(
|
||||
[
|
||||
[
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.6, 0.1, 0.1], [0.1, 0.1, 0.2, 0.8, 0.1]],
|
||||
[[0.1, 0.6, 0.1, 0.1, 0.1], [0.1, 0.1, 0.2, 0.1, 0.1], [0.7, 0.1, 0.2, 0.1, 0.1]],
|
||||
]
|
||||
]
|
||||
).astype(dtype)
|
||||
labels = [[1, 2]]
|
||||
|
||||
cost_threshold = 1e-8 if dtype == np.float32 else 1e-3
|
||||
grad_threshold = 1e-8 if dtype == np.float32 else 5e-4
|
||||
rtol = 1e-5 if dtype == np.float32 else 1e-3
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum', fastemit_lambda=fastemit_lambda, clamp=GRAD_CLAMP)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_np = RNNTLoss_Numpy(blank=0, fastemit_lambda=fastemit_lambda, clamp=GRAD_CLAMP)
|
||||
np_cost, np_grads = wrap_and_call(fn_np, acts, labels, device)
|
||||
|
||||
expected_cost = 4.495666
|
||||
expected_cost += expected_cost * fastemit_lambda
|
||||
|
||||
assert np.allclose(pt_cost, expected_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(pt_cost, np_cost, atol=cost_threshold, rtol=rtol), "small_test costs mismatch."
|
||||
assert np.allclose(pt_grads, np_grads, atol=grad_threshold, rtol=rtol), "small_test gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
def test_case_small_random_accumulated(self, device):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
torch.manual_seed(0)
|
||||
base_layer = torch.randn(3, 5, requires_grad=True)
|
||||
|
||||
mid1 = torch.randn(1, 4, 3, 3, requires_grad=True)
|
||||
labels1 = [[1, 3]]
|
||||
|
||||
mid2 = torch.randn(1, 6, 5, 3, requires_grad=True)
|
||||
labels2 = [[1, 2, 3, 4]]
|
||||
|
||||
def zero_grad():
|
||||
if base_layer.grad is not None:
|
||||
base_layer.grad = None
|
||||
if mid1.grad is not None:
|
||||
mid1.grad = None
|
||||
if mid2.grad is not None:
|
||||
mid2.grad = None
|
||||
|
||||
fn_pt = RNNTLossNumba(blank=0, reduction='sum')
|
||||
fn_np = RNNTLoss_Numpy()
|
||||
|
||||
# run 1
|
||||
acts1 = torch.matmul(mid1, base_layer) # [1, 4, 3, 5]
|
||||
pt_cost1, _ = wrap_and_call(fn_pt, acts1, labels1, device)
|
||||
pt_grads1 = base_layer.grad.detach().cpu().numpy()
|
||||
|
||||
zero_grad()
|
||||
|
||||
acts1 = torch.matmul(mid1, base_layer) # [1, 4, 3, 5]
|
||||
np_cost1, _ = wrap_and_call(fn_np, acts1, labels1, device)
|
||||
np_grads1 = base_layer.grad.detach().cpu().numpy()
|
||||
|
||||
zero_grad()
|
||||
|
||||
assert np.allclose(pt_grads1, np_grads1, atol=1e-6)
|
||||
|
||||
# run 2
|
||||
acts2 = torch.matmul(mid2, base_layer) # [1, 4, 3, 5]
|
||||
pt_cost2, _ = wrap_and_call(fn_pt, acts2, labels2, device)
|
||||
pt_grads2 = base_layer.grad.clone().cpu().numpy()
|
||||
|
||||
zero_grad()
|
||||
|
||||
acts2 = torch.matmul(mid2, base_layer) # [1, 4, 3, 5]
|
||||
np_cost2, _ = wrap_and_call(fn_np, acts2, labels2, device)
|
||||
np_grads2 = base_layer.grad.clone().cpu().numpy()
|
||||
|
||||
zero_grad()
|
||||
|
||||
assert np.allclose(pt_grads2, np_grads2, atol=1e-6)
|
||||
|
||||
# run 1 + 2
|
||||
acts1 = torch.matmul(mid1, base_layer) # [1, 4, 3, 5]
|
||||
pt_cost1, _ = wrap_and_call(fn_pt, acts1, labels1, device)
|
||||
|
||||
acts2 = torch.matmul(mid2, base_layer) # [1, 6, 5, 5]
|
||||
pt_cost2, _ = wrap_and_call(fn_pt, acts2, labels2, device)
|
||||
pt_grads1_p_2 = base_layer.grad.clone().cpu().numpy()
|
||||
|
||||
assert np.allclose(pt_grads1_p_2, np_grads1 + np_grads2, atol=1e-5)
|
||||
|
||||
|
||||
class TestMultiblankRNNTLoss:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', DEVICES)
|
||||
def test_case_randomized_act_label(self, device):
|
||||
if device == 'cuda':
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
B, T, U, V = 4, 8, 4, 8 # here V is number of non blank labels
|
||||
big_blank_durations = [2, 4, 8]
|
||||
sigma = 0.1
|
||||
|
||||
acts = torch.rand([B, T, U, V + 1 + len(big_blank_durations)])
|
||||
labels = [[random.randrange(0, V) for i in range(U - 1)] for j in range(B)]
|
||||
|
||||
fn_pt = MultiblankRNNTLossNumba(
|
||||
blank=V + len(big_blank_durations),
|
||||
reduction='sum',
|
||||
big_blank_durations=big_blank_durations,
|
||||
sigma=sigma,
|
||||
)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_ag = MultiblankRNNTLossPytorch(
|
||||
blank=V + len(big_blank_durations),
|
||||
reduction='sum',
|
||||
big_blank_durations=big_blank_durations,
|
||||
sigma=sigma,
|
||||
) # ag for automatic gradient computation
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, ag_cost, rtol=1e-6), "multi-blank costs mismatch."
|
||||
assert np.allclose(pt_grads, ag_grads, rtol=1e-2), "multi-blank gradient mismatch."
|
||||
|
||||
|
||||
class TestTDTLoss:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', CUDA_ONLY_DEVICE)
|
||||
def test_case_randomized_act_label(self, device):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
B, T, U, V = 4, 8, 4, 8 # here V is number of non blank labels
|
||||
durations = [0, 1, 2, 3, 4, 5]
|
||||
sigma = 0.05
|
||||
|
||||
acts = torch.rand([B, T, U, V + 1 + len(durations)])
|
||||
labels = [[random.randrange(0, V) for i in range(U - 1)] for j in range(B)]
|
||||
|
||||
fn_pt = TDTLossNumba(blank=V, reduction='sum', durations=durations, sigma=sigma)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_ag = TDTLossPytorch(
|
||||
blank=V, reduction='sum', durations=durations, sigma=sigma
|
||||
) # ag for automatic gradient computation
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, ag_cost, rtol=1e-6), "tdt costs mismatch."
|
||||
assert np.allclose(pt_grads, ag_grads, rtol=1e-2), "td gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', CUDA_ONLY_DEVICE)
|
||||
def test_case_randomized_act_label_no_0_duration(self, device):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
B, T, U, V = 4, 8, 4, 8 # here V is number of non blank labels
|
||||
durations = [1, 2, 3, 4, 5]
|
||||
sigma = 0.05
|
||||
|
||||
acts = torch.rand([B, T, U, V + 1 + len(durations)])
|
||||
labels = [[random.randrange(0, V) for i in range(U - 1)] for j in range(B)]
|
||||
|
||||
fn_pt = TDTLossNumba(blank=V, reduction='sum', durations=durations, sigma=sigma)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
fn_ag = TDTLossPytorch(
|
||||
blank=V, reduction='sum', durations=durations, sigma=sigma
|
||||
) # ag for automatic gradient computation
|
||||
ag_cost, ag_grads = wrap_and_call(fn_ag, acts, labels, device)
|
||||
|
||||
assert np.allclose(pt_cost, ag_cost, rtol=1e-6), "tdt costs mismatch."
|
||||
assert np.allclose(pt_grads, ag_grads, rtol=1e-2), "td gradient mismatch."
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('device', CUDA_ONLY_DEVICE)
|
||||
def test_case_fixed_case_act_label(self, device):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
B, T, U, V = 1, 3, 2, 3 # here V is number of non blank labels
|
||||
durations = [0, 1, 2]
|
||||
sigma = 0.05
|
||||
|
||||
acts = torch.zeros([B, T, U, V + 1 + len(durations)])
|
||||
labels = [[(i + j) % (V - 1) for i in range(U - 1)] for j in range(B)]
|
||||
|
||||
fn_pt = TDTLossNumba(blank=V, reduction='sum', durations=durations, sigma=sigma)
|
||||
pt_cost, pt_grads = wrap_and_call(fn_pt, acts, labels, device)
|
||||
|
||||
expected_cost = 4.155739
|
||||
expected_grads = [
|
||||
[
|
||||
[
|
||||
[-0.64962804, 0.25, 0.25, 0.14962798, 0.2672583, -0.16792619, -0.09933221],
|
||||
[0.01651875, 0.01651875, 0.01651875, -0.04955626, 0.022025, -0.01227201, -0.009753],
|
||||
],
|
||||
[
|
||||
[-0.04892651, 0.01714851, 0.01714851, 0.01462949, -0.01143234, -0.01143234, 0.02286467],
|
||||
[0.12531489, 0.12531489, 0.12531489, -0.37594467, 0.16708651, 0.13027048, -0.29735702],
|
||||
],
|
||||
[
|
||||
[-0.02572276, 0.00857425, 0.00857425, 0.00857425, -0.02286468, 0.01143234, 0.01143234],
|
||||
[0.13388914, 0.13388914, 0.13388914, -0.40166742, 0.17851885, -0.35703772, 0.17851885],
|
||||
],
|
||||
]
|
||||
]
|
||||
|
||||
assert np.allclose(pt_cost, expected_cost, rtol=1e-6), "tdt costs mismatch."
|
||||
assert np.allclose(pt_grads, expected_grads, rtol=1e-2), "td gradient mismatch."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,797 @@
|
||||
# Copyright (c) 2021, 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 numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from numba import cuda
|
||||
|
||||
from nemo.collections.asr.losses.rnnt_pytorch import MultiblankRNNTLossPytorch, TDTLossPytorch
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss import rnnt_numpy
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.rnnt_pytorch import certify_inputs
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.utils.cuda_utils import gpu_rnnt_kernel, reduce
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
|
||||
DTYPES = [torch.float32]
|
||||
if numba_utils.is_numba_cuda_fp16_supported():
|
||||
DTYPES.append(torch.float16)
|
||||
|
||||
|
||||
def log_softmax(x, axis=-1):
|
||||
x = torch.from_numpy(x) # zero-copy
|
||||
x = x.float()
|
||||
x = torch.log_softmax(x, dim=axis)
|
||||
x = x.numpy()
|
||||
return x
|
||||
|
||||
|
||||
def log_softmax_grad(x, axis=-1):
|
||||
x = torch.tensor(x, requires_grad=True) # alloc memory
|
||||
y = torch.log_softmax(x, dim=axis)
|
||||
y.sum().backward()
|
||||
return x.grad.numpy()
|
||||
|
||||
|
||||
class TestRNNTCUDAKernels:
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_compute_alphas_kernel(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 11, 3]
|
||||
B, T, U, V = original_shape
|
||||
threshold = 1e-5 if dtype == torch.float32 else 3e-4
|
||||
|
||||
# Numpy kernel
|
||||
x = random.randn(*original_shape)
|
||||
labels = np.array([[1, 1, 1, 2, 2, 2, 1, 2, 2, 1]]) # [1, 10]
|
||||
label_len = len(labels[0]) + 1
|
||||
blank_idx = 0
|
||||
|
||||
x_np = log_softmax(x, axis=-1)
|
||||
ground_alphas, ground_log_likelihood = rnnt_numpy.forward_pass(
|
||||
x_np[0, :, :label_len, :], labels[0, : label_len - 1], blank_idx
|
||||
)
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x_c = torch.tensor(x, device=device, dtype=dtype)
|
||||
labels_c = torch.tensor(labels, device=device, dtype=torch.int64)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.int64, device=device)
|
||||
label_lengths = torch.tensor([len(labels[0])], dtype=torch.int64, device=device)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x_c, labels_c, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x_c = x_c.view([-1])
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x_c, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x_c, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_alphas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape alphas
|
||||
alphas = alphas.view([B, T, U])
|
||||
diff = ground_alphas - alphas[0].cpu().numpy()
|
||||
|
||||
assert np.abs(diff).mean() <= threshold
|
||||
assert np.square(diff).mean() <= (threshold**2)
|
||||
|
||||
ll_diff = ground_log_likelihood - llForward[0].cpu().numpy()
|
||||
|
||||
assert np.abs(ll_diff).mean() <= threshold
|
||||
assert np.square(ll_diff).mean() <= (threshold**2)
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_compute_betas_kernel(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 11, 3]
|
||||
B, T, U, V = original_shape
|
||||
threshold = 1e-5 if dtype == torch.float32 else 3e-4
|
||||
|
||||
# Numpy kernel
|
||||
x = random.randn(*original_shape)
|
||||
labels = np.array([[1, 1, 1, 2, 2, 2, 1, 2, 2, 1]]) # [1, 10]
|
||||
label_len = len(labels[0]) + 1
|
||||
blank_idx = 0
|
||||
|
||||
x_np = log_softmax(x, axis=-1)
|
||||
ground_alphas, ground_log_likelihood = rnnt_numpy.backward_pass(
|
||||
x_np[0, :, :label_len, :], labels[0, : label_len - 1], blank_idx
|
||||
)
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x_c = torch.tensor(x, device=device, dtype=dtype)
|
||||
labels_c = torch.tensor(labels, device=device, dtype=torch.int64)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
betas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
llBackward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.int64, device=device)
|
||||
label_lengths = torch.tensor([len(labels[0])], dtype=torch.int64, device=device)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x_c, labels_c, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x_c = x_c.view([-1])
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x_c, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x_c, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# beta kernel
|
||||
gpu_rnnt_kernel.compute_betas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
betas,
|
||||
llBackward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape alphas
|
||||
betas = betas.view([B, T, U])
|
||||
diff = ground_alphas - betas[0].cpu().numpy()
|
||||
|
||||
assert np.abs(diff).mean() <= threshold
|
||||
assert np.square(diff).mean() <= (threshold**2)
|
||||
|
||||
ll_diff = ground_log_likelihood - llBackward[0].cpu().numpy()
|
||||
|
||||
assert np.abs(ll_diff).mean() <= threshold
|
||||
assert np.square(ll_diff).mean() <= (threshold**2)
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_compute_grads_kernel(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
fastemit_lambda = 0.0
|
||||
clamp = 0.0
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 11, 3]
|
||||
B, T, U, V = original_shape
|
||||
threshold = 1e-5 if dtype == torch.float32 else 3e-5
|
||||
|
||||
# Numpy kernel
|
||||
x = random.randn(*original_shape)
|
||||
labels = torch.from_numpy(np.array([[1, 1, 1, 2, 2, 2, 1, 2, 2, 1]], dtype=np.int64)) # [1, 10]
|
||||
audio_len = torch.from_numpy(np.array([T], dtype=np.int64))
|
||||
label_len = torch.from_numpy(np.array([U - 1], dtype=np.int64))
|
||||
blank_idx = 0
|
||||
|
||||
x_np = torch.from_numpy(x)
|
||||
x_np.requires_grad_(True)
|
||||
|
||||
"""
|
||||
Here we will directly utilize the numpy variant of the loss without explicitly calling
|
||||
the numpy functions for alpha, beta and grads.
|
||||
|
||||
This is because the grads returned by the rnnt_numpy.transduce_batch() are :
|
||||
d/dx (alpha + beta alignment)(log_softmax(x)).
|
||||
But according to the chain rule, we'd still need to compute the gradient of log_softmax(x)
|
||||
and update the alignments by hand. Instead, we will rely on pytorch to compute the gradient
|
||||
of the log_softmax(x) step and propagate it backwards.
|
||||
"""
|
||||
loss_func = rnnt_numpy.RNNTLoss(blank_idx, fastemit_lambda=fastemit_lambda, clamp=clamp)
|
||||
loss_val = loss_func(x_np, labels, audio_len, label_len)
|
||||
loss_val.sum().backward()
|
||||
true_grads = x_np.grad
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x_c = torch.tensor(x, device=device, dtype=dtype)
|
||||
labels_c = labels.clone().to(device=device, dtype=torch.int64)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
betas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
llBackward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.int64, device=device)
|
||||
label_lengths = torch.tensor([len(labels[0])], dtype=torch.int64, device=device)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x_c, labels_c, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x_c = x_c.view([-1])
|
||||
grads = torch.zeros_like(x_c, requires_grad=False)
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x_c, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x_c, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_alphas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# beta kernel
|
||||
gpu_rnnt_kernel.compute_betas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
betas,
|
||||
llBackward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# gamma kernel
|
||||
grad_blocks_per_grid = B * T * U
|
||||
grad_threads_per_block = gpu_rnnt_kernel.GPU_RNNT_THREAD_SIZE
|
||||
gpu_rnnt_kernel.compute_grad_kernel[grad_blocks_per_grid, grad_threads_per_block, stream, 0](
|
||||
grads,
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
betas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
fastemit_lambda,
|
||||
clamp,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape grads
|
||||
grads = grads.view([B, T, U, V])
|
||||
diff = true_grads - grads[0].cpu().numpy()
|
||||
|
||||
assert np.abs(diff).mean() <= threshold
|
||||
assert np.square(diff).mean() <= (threshold**2) * 5.0
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_compute_grads_kernel_fastemit(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
fastemit_lambda = 0.001
|
||||
clamp = 0.0
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 11, 3]
|
||||
B, T, U, V = original_shape
|
||||
threshold = 1e-5 if dtype == torch.float32 else 3e-5
|
||||
|
||||
# Numpy kernel
|
||||
x = random.randn(*original_shape)
|
||||
labels = torch.from_numpy(np.array([[1, 1, 1, 2, 2, 2, 1, 2, 2, 1]], dtype=np.int64)) # [1, 10]
|
||||
audio_len = torch.from_numpy(np.array([T], dtype=np.int64))
|
||||
label_len = torch.from_numpy(np.array([U - 1], dtype=np.int64))
|
||||
blank_idx = 0
|
||||
|
||||
x_np = torch.from_numpy(x)
|
||||
x_np.requires_grad_(True)
|
||||
|
||||
"""
|
||||
Here we will directly utilize the numpy variant of the loss without explicitly calling
|
||||
the numpy functions for alpha, beta and grads.
|
||||
|
||||
This is because the grads returned by the rnnt_numpy.transduce_batch() are :
|
||||
d/dx (alpha + beta alignment)(log_softmax(x)).
|
||||
But according to the chain rule, we'd still need to compute the gradient of log_softmax(x)
|
||||
and update the alignments by hand. Instead, we will rely on pytorch to compute the gradient
|
||||
of the log_softmax(x) step and propagate it backwards.
|
||||
"""
|
||||
loss_func = rnnt_numpy.RNNTLoss(blank_idx, fastemit_lambda=fastemit_lambda, clamp=clamp)
|
||||
loss_val = loss_func(x_np, labels, audio_len, label_len)
|
||||
loss_val.sum().backward()
|
||||
true_grads = x_np.grad
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x_c = torch.tensor(x, device=device, dtype=dtype)
|
||||
labels_c = labels.clone().to(device=device, dtype=torch.int64)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
betas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
llBackward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.int64, device=device)
|
||||
label_lengths = torch.tensor([len(labels[0])], dtype=torch.int64, device=device)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x_c, labels_c, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x_c = x_c.view([-1])
|
||||
grads = torch.zeros_like(x_c, requires_grad=False)
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x_c, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x_c, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_alphas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# beta kernel
|
||||
gpu_rnnt_kernel.compute_betas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
betas,
|
||||
llBackward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# gamma kernel
|
||||
grad_blocks_per_grid = B * T * U
|
||||
grad_threads_per_block = gpu_rnnt_kernel.GPU_RNNT_THREAD_SIZE
|
||||
gpu_rnnt_kernel.compute_grad_kernel[grad_blocks_per_grid, grad_threads_per_block, stream, 0](
|
||||
grads,
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
betas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
fastemit_lambda,
|
||||
clamp,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape grads
|
||||
grads = grads.view([B, T, U, V])
|
||||
diff = true_grads - grads[0].cpu().numpy()
|
||||
|
||||
assert np.abs(diff).mean() <= threshold
|
||||
assert np.square(diff).mean() <= (threshold**2) * 5
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_compute_grads_kernel_clamp(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
fastemit_lambda = 0.0
|
||||
clamp = 0.1
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 11, 3]
|
||||
B, T, U, V = original_shape
|
||||
threshold = 1e-5 if dtype == torch.float32 else 3e-5
|
||||
|
||||
# Numpy kernel
|
||||
x = random.randn(*original_shape)
|
||||
labels = torch.from_numpy(np.array([[1, 1, 1, 2, 2, 2, 1, 2, 2, 1]], dtype=np.int64)) # [1, 10]
|
||||
audio_len = torch.from_numpy(np.array([T], dtype=np.int64))
|
||||
label_len = torch.from_numpy(np.array([U - 1], dtype=np.int64))
|
||||
blank_idx = 0
|
||||
|
||||
x_np = torch.from_numpy(x)
|
||||
x_np.requires_grad_(True)
|
||||
|
||||
"""
|
||||
Here we will directly utilize the numpy variant of the loss without explicitly calling
|
||||
the numpy functions for alpha, beta and grads.
|
||||
|
||||
This is because the grads returned by the rnnt_numpy.transduce_batch() are :
|
||||
d/dx (alpha + beta alignment)(log_softmax(x)).
|
||||
But according to the chain rule, we'd still need to compute the gradient of log_softmax(x)
|
||||
and update the alignments by hand. Instead, we will rely on pytorch to compute the gradient
|
||||
of the log_softmax(x) step and propagate it backwards.
|
||||
"""
|
||||
loss_func = rnnt_numpy.RNNTLoss(blank_idx, fastemit_lambda=fastemit_lambda, clamp=clamp)
|
||||
loss_val = loss_func(x_np, labels, audio_len, label_len)
|
||||
loss_val.sum().backward()
|
||||
true_grads = x_np.grad
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x_c = torch.tensor(x, device=device, dtype=dtype)
|
||||
labels_c = labels.clone().to(device=device, dtype=torch.int64)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
betas = torch.zeros(B * T * U, device=device, dtype=x_c.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
llBackward = torch.zeros(B, device=device, dtype=x_c.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.int64, device=device)
|
||||
label_lengths = torch.tensor([len(labels[0])], dtype=torch.int64, device=device)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x_c, labels_c, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x_c = x_c.view([-1])
|
||||
grads = torch.zeros_like(x_c, requires_grad=False)
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x_c, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x_c, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_alphas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# beta kernel
|
||||
gpu_rnnt_kernel.compute_betas_kernel[B, U, stream, 0](
|
||||
x_c,
|
||||
denom,
|
||||
betas,
|
||||
llBackward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
)
|
||||
|
||||
# gamma kernel
|
||||
grad_blocks_per_grid = B * T * U
|
||||
grad_threads_per_block = gpu_rnnt_kernel.GPU_RNNT_THREAD_SIZE
|
||||
gpu_rnnt_kernel.compute_grad_kernel[grad_blocks_per_grid, grad_threads_per_block, stream, 0](
|
||||
grads,
|
||||
x_c,
|
||||
denom,
|
||||
alphas,
|
||||
betas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels_c,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
fastemit_lambda,
|
||||
clamp,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape grads
|
||||
grads = grads.view([B, T, U, V])
|
||||
diff = true_grads - grads[0].cpu().numpy()
|
||||
|
||||
assert np.abs(diff).mean() <= threshold
|
||||
assert np.square(diff).mean() <= (threshold**2) * 5
|
||||
|
||||
|
||||
class TestTDTCUDAKernels:
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
def test_compute_alphas_kernel(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 15, 11, 3]
|
||||
durations = [0, 1, 2]
|
||||
B, T, U, V = original_shape
|
||||
Vd = len(durations)
|
||||
|
||||
duration_act_shape = [B, T, U, Vd]
|
||||
sigma = 0.05
|
||||
|
||||
# for passing into the kernel function -- it expected unnormalized logits
|
||||
x = random.randn(*original_shape)
|
||||
# for passing into the pytorch function -- it expected normalized logits
|
||||
normalized_x = log_softmax(x, axis=-1) - 0.05
|
||||
|
||||
xd = random.randn(*duration_act_shape)
|
||||
# duration logits are normalized before passing into the loss computation.
|
||||
xd = log_softmax(xd, axis=-1)
|
||||
|
||||
labels = np.array([[1, 1, 1, 1, 0, 0, 1, 0, 0, 1]]) # [1, 10]
|
||||
blank_idx = V - 1
|
||||
|
||||
pytorch_tdt_loss = TDTLossPytorch(blank_idx, durations, sigma=sigma)
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x = torch.tensor(x, device=device, dtype=torch.float32)
|
||||
normalized_x = torch.tensor(normalized_x, device=device, dtype=torch.float32)
|
||||
xd = torch.tensor(xd, device=device, dtype=torch.float32)
|
||||
labels = torch.tensor(labels, device=device, dtype=torch.long)
|
||||
durations = torch.tensor(durations, device=device, dtype=torch.long)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.long, device=device)
|
||||
label_lengths = torch.tensor([U - 1], dtype=torch.long, device=device)
|
||||
|
||||
ground_log_likelihood, ground_alphas = pytorch_tdt_loss.compute_forward_prob(
|
||||
normalized_x, xd, labels, input_lengths, label_lengths
|
||||
)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x, labels, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x = x.view([-1])
|
||||
xd = xd.view([-1])
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_tdt_alphas_kernel[B, U, stream, 0](
|
||||
x,
|
||||
xd,
|
||||
denom,
|
||||
sigma,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
durations,
|
||||
Vd,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape alphas
|
||||
alphas = alphas.view([B, T, U])
|
||||
diff = torch.norm(ground_alphas - alphas)
|
||||
ll_diff = torch.norm(ground_log_likelihood - llForward)
|
||||
|
||||
assert diff <= 1e-3
|
||||
assert ll_diff <= 1e-3
|
||||
|
||||
|
||||
class TestMultiblankRNNTCUDAKernels:
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
def test_compute_alphas_kernel(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 15, 11, 6]
|
||||
big_blank_durations = [2, 3, 4]
|
||||
B, T, U, V = original_shape
|
||||
num_big_blanks = len(big_blank_durations)
|
||||
|
||||
sigma = 0.05
|
||||
|
||||
# for passing into the kernel function -- it expected unnormalized logits
|
||||
x = random.randn(*original_shape)
|
||||
# for passing into the pytorch function -- it expected normalized logits
|
||||
normalized_x = log_softmax(x, axis=-1) - sigma
|
||||
|
||||
labels = np.array([[1, 1, 1, 1, 0, 0, 1, 0, 0, 1]]) # [1, 10]
|
||||
blank_idx = V - 1
|
||||
|
||||
pytorch_multiblank_loss = MultiblankRNNTLossPytorch(blank_idx, big_blank_durations, sigma=sigma)
|
||||
|
||||
# Pytorch kernel
|
||||
device = torch.device('cuda')
|
||||
if hasattr(cuda, 'external_stream'):
|
||||
stream = cuda.external_stream(torch.cuda.current_stream(device).cuda_stream)
|
||||
else:
|
||||
stream = cuda.default_stream()
|
||||
|
||||
x = torch.tensor(x, device=device, dtype=torch.float32)
|
||||
normalized_x = torch.tensor(normalized_x, device=device, dtype=torch.float32)
|
||||
labels = torch.tensor(labels, device=device, dtype=torch.long)
|
||||
big_blank_durations = torch.tensor(big_blank_durations, device=device, dtype=torch.long)
|
||||
|
||||
# Allocate workspace memory
|
||||
denom = torch.zeros(B * T * U, device=device, dtype=x.dtype)
|
||||
alphas = torch.zeros(B * T * U, device=device, dtype=x.dtype)
|
||||
llForward = torch.zeros(B, device=device, dtype=x.dtype)
|
||||
input_lengths = torch.tensor([T], dtype=torch.long, device=device)
|
||||
label_lengths = torch.tensor([U - 1], dtype=torch.long, device=device)
|
||||
|
||||
ground_log_likelihood, ground_alphas = pytorch_multiblank_loss.compute_forward_prob(
|
||||
normalized_x, labels, input_lengths, label_lengths
|
||||
)
|
||||
|
||||
# certify input data
|
||||
certify_inputs(x, labels, input_lengths, label_lengths)
|
||||
|
||||
# flatten activation tensor (for pointer based indexing)
|
||||
x = x.view([-1])
|
||||
|
||||
# call kernel
|
||||
# log softmax reduction
|
||||
reduce.reduce_max(x, denom, rows=V, cols=B * T * U, minus=False, stream=stream)
|
||||
reduce.reduce_exp(x, denom, rows=V, cols=B * T * U, minus=True, stream=stream)
|
||||
|
||||
# alpha kernel
|
||||
gpu_rnnt_kernel.compute_multiblank_alphas_kernel[B, U, stream, 0](
|
||||
x,
|
||||
denom,
|
||||
sigma,
|
||||
alphas,
|
||||
llForward,
|
||||
input_lengths,
|
||||
label_lengths,
|
||||
labels,
|
||||
B,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
blank_idx,
|
||||
big_blank_durations,
|
||||
num_big_blanks,
|
||||
)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
# reshape alphas
|
||||
alphas = alphas.view([B, T, U])
|
||||
diff = torch.norm(ground_alphas - alphas)
|
||||
ll_diff = torch.norm(ground_log_likelihood - llForward)
|
||||
|
||||
assert diff <= 1e-3
|
||||
assert ll_diff <= 1e-3
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2021, 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 numpy as np
|
||||
import pytest
|
||||
from numba import cuda
|
||||
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.utils.cuda_utils import reduce
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
DTYPES = [np.float32]
|
||||
if numba_utils.is_numba_cuda_fp16_supported():
|
||||
DTYPES.append(np.float16)
|
||||
|
||||
|
||||
class TestRNNTCUDAReductions:
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_reduce_max(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 4, 3]
|
||||
x = random.randn(*original_shape).reshape([-1]).astype(dtype)
|
||||
dx = random.randn(*x.shape).astype(dtype)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
dx_c = cuda.to_device(dx, stream=stream)
|
||||
|
||||
# call kernel
|
||||
cols = np.prod(original_shape[:3])
|
||||
reduce.reduce_max(x_c, dx_c, rows=original_shape[-1], cols=cols, minus=False, stream=stream)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
dx_result = dx_c.copy_to_host(stream=stream)
|
||||
del x_c, dx_c
|
||||
|
||||
# collect results in first [B * T * U] values; for all V
|
||||
assert np.abs(dx_result[cols:] - dx[cols:]).sum() <= 1e-7
|
||||
# make sure dx_result updates the [B * T * U] values
|
||||
assert np.abs(dx_result[:cols] - dx[:cols]).sum() > 0
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Reductions can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_reduce_exp(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
random = np.random.RandomState(0)
|
||||
original_shape = [1, 5, 4, 2]
|
||||
x = random.randn(*original_shape).reshape([-1]).astype(dtype)
|
||||
dx = np.zeros_like(x).astype(dtype)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
dx_c = cuda.to_device(dx, stream=stream)
|
||||
|
||||
# call kernel
|
||||
cols = np.prod(original_shape[:3])
|
||||
reduce.reduce_exp(x_c, dx_c, rows=original_shape[-1], cols=cols, minus=False, stream=stream)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
dx_result = dx_c.copy_to_host(stream=stream)
|
||||
del x_c, dx_c
|
||||
|
||||
# collect results in first [B * T * U] values; for all V
|
||||
assert (dx_result[cols:] - dx[cols:]).sum() <= 1e-7
|
||||
|
||||
# make sure dx_result updates the [B * T * U] values
|
||||
assert np.abs(dx_result[:cols] - dx[:cols]).sum() > 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,368 @@
|
||||
# Copyright (c) 2021, 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 numpy as np
|
||||
import pytest
|
||||
from numba import cuda
|
||||
|
||||
from nemo.collections.asr.parts.numba.rnnt_loss.utils import global_constants, rnnt_helper
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
DTYPES = [np.float32]
|
||||
if numba_utils.is_numba_cuda_fp16_supported():
|
||||
DTYPES.append(np.float16)
|
||||
|
||||
|
||||
class TestRNNTHelper:
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_log_sum_exp(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.log_sum_exp(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.zeros([8]).astype(dtype) # np.random.rand(8192)
|
||||
y = np.ones([8]).astype(dtype) # np.random.rand(8192)
|
||||
threshold = 1e-5 if dtype == np.float32 else 2e-3
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
assert (x_new.sum() - 10.506093500145782) <= threshold
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_log_sum_exp_neg_inf(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.log_sum_exp(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.asarray([global_constants.FP32_NEG_INF] * 8).astype(dtype)
|
||||
y = np.ones([len(x)]).astype(dtype)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
assert np.allclose(x_new, np.ones_like(x_new), atol=1e-5)
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_div_up(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.div_up(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10).astype(dtype) # np.random.rand(8192)
|
||||
y = np.full([8], fill_value=2).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == ((10 + 2 - 1) // 2)
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_add(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.add(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10).astype(dtype) # np.random.rand(8192)
|
||||
y = np.full([8], fill_value=2).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == 12
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_maximum(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.maximum(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10).astype(dtype) # np.random.rand(8192)
|
||||
y = np.full([8], fill_value=2).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == 10
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_identity(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0]:
|
||||
x[x_pos] = rnnt_helper.identity(x[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == x[i]
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', [np.float32, np.float16])
|
||||
def test_negate(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0]:
|
||||
x[x_pos] = rnnt_helper.negate(x[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == -x[i]
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_exponential(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0]:
|
||||
x[x_pos] = rnnt_helper.exponential(x[x_pos])
|
||||
|
||||
x = np.random.rand(8).astype(dtype)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c
|
||||
|
||||
y = np.exp(x)
|
||||
for i in range(len(x_new)):
|
||||
assert (x_new[i] - y[i]) < 1e-4
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
def test_log_plus(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# wrapper kernel for device function that is tested
|
||||
@cuda.jit
|
||||
def _kernel(x, y):
|
||||
x_pos = cuda.grid(1)
|
||||
if x_pos < x.shape[0] and x_pos < y.shape[0]:
|
||||
x[x_pos] = rnnt_helper.log_plus(x[x_pos], y[x_pos])
|
||||
|
||||
x = np.full([8], fill_value=10.0).astype(dtype) # np.random.rand(8192)
|
||||
y = np.full([8], fill_value=2.0).astype(dtype) # np.random.rand(8192)
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = global_constants.threads_per_block()
|
||||
blocks_per_grid = (x.shape[0] + threads_per_block - 1) // threads_per_block
|
||||
_kernel[blocks_per_grid, threads_per_block, stream](x_c, y_c)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
z = np.log1p(np.exp(-np.fabs(x - y))) + np.maximum(x, y)
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert x_new[i] == z[i]
|
||||
|
||||
@pytest.mark.skipif(not cuda.is_available(), reason="CUDA Helpers can only be run when CUDA is available")
|
||||
@pytest.mark.parametrize('batch_size', [8, 128, 256])
|
||||
@pytest.mark.parametrize('fastemit_lambda', [0.0, 0.001])
|
||||
@pytest.mark.parametrize('dtype', DTYPES)
|
||||
@pytest.mark.unit
|
||||
def test_compute_costs_data(self, batch_size, fastemit_lambda, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
np.random.seed(0)
|
||||
x = np.full([batch_size], fill_value=0.0) # np.random.rand(8192)
|
||||
y = np.random.randn(batch_size).astype(dtype) # np.random.rand(8192)
|
||||
threshold = 1e-5 if dtype == np.float32 else 1e-5
|
||||
|
||||
stream = cuda.stream()
|
||||
x_c = cuda.to_device(x, stream=stream)
|
||||
y_c = cuda.to_device(y, stream=stream)
|
||||
|
||||
# call kernel
|
||||
threads_per_block = min(x.shape[0], 32)
|
||||
blocks_per_grid = (x.shape[0] + (threads_per_block - 1)) // threads_per_block
|
||||
# Kernel call (source, dest, extra_args_...)
|
||||
rnnt_helper.compute_costs_data[blocks_per_grid, threads_per_block, stream](y_c, x_c, fastemit_lambda)
|
||||
|
||||
# sync kernel
|
||||
stream.synchronize()
|
||||
|
||||
x_new = x_c.copy_to_host(stream=stream)
|
||||
del x_c, y_c
|
||||
|
||||
res = -(y.astype(np.float32).copy())
|
||||
res *= 1.0 + fastemit_lambda
|
||||
|
||||
for i in range(len(x_new)):
|
||||
assert abs(x_new[i] - res[i]) < threshold, f"index failed {i}"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,297 @@
|
||||
# Copyright (c) 2021, 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 omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.parts.numba.spec_augment import spec_aug_numba
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
|
||||
def get_cfg(seed=0, dtype='float32', **kwargs):
|
||||
# fmt: off
|
||||
default = dict(b=2, f=80, t=20, device='cuda',
|
||||
freq_masks=2, time_masks=2, freq_width=27, time_width=0.05, mask_value=0.0,
|
||||
seed=seed, dtype=dtype)
|
||||
default.update(**kwargs)
|
||||
cfg = OmegaConf.create(default)
|
||||
# fmt: on
|
||||
return cfg
|
||||
|
||||
|
||||
# fmt: off
|
||||
def prepare_data(b, f, t, device='cuda', freq_masks=0, time_masks=0, freq_width=10, time_width=0.1,
|
||||
seed=0, dtype='float32',
|
||||
**kwargs):
|
||||
torch.manual_seed(seed)
|
||||
|
||||
if dtype == 'float16':
|
||||
dtype = torch.float16
|
||||
else:
|
||||
dtype = torch.float
|
||||
|
||||
x = torch.randn([b, f, t], dtype=dtype, device=device)
|
||||
x_len = torch.randint(t, size=[b], device=x.device)
|
||||
|
||||
sh = x.shape
|
||||
bs = sh[0]
|
||||
|
||||
if isinstance(time_width, int):
|
||||
adaptive_temporal_width = False
|
||||
else:
|
||||
if time_width > 1.0 or time_width < 0.0:
|
||||
raise ValueError('If `time_width` is a float value, must be in range [0, 1]')
|
||||
|
||||
adaptive_temporal_width = True
|
||||
|
||||
orginal_time_width = time_width
|
||||
|
||||
# Construct the freq and time masks as well as start positions
|
||||
if freq_masks > 0:
|
||||
freq_starts = torch.randint(0, sh[1] - freq_width + 1, size=[bs, freq_masks], device=x.device)
|
||||
freq_lengths = torch.randint(0, freq_width + 1, size=[bs, freq_masks], device=x.device)
|
||||
else:
|
||||
freq_starts = torch.zeros([bs, 1], dtype=torch.int64, device=x.device)
|
||||
freq_lengths = torch.zeros([bs, 1], dtype=torch.int64, device=x.device)
|
||||
|
||||
if time_masks > 0:
|
||||
if adaptive_temporal_width:
|
||||
time_width = (x_len * orginal_time_width).int().clamp(min=1)
|
||||
else:
|
||||
time_width = (
|
||||
torch.tensor(orginal_time_width, dtype=torch.int32, device=x.device)
|
||||
.unsqueeze(0)
|
||||
.repeat(sh[0])
|
||||
)
|
||||
|
||||
time_starts = []
|
||||
time_lengths = []
|
||||
for idx in range(sh[0]):
|
||||
time_starts.append(
|
||||
torch.randint(
|
||||
0, max(1, x_len[idx] - time_width[idx]), size=[1, time_masks], device=x.device
|
||||
)
|
||||
)
|
||||
time_lengths.append(
|
||||
torch.randint(0, time_width[idx] + 1, size=[1, time_masks], device=x.device)
|
||||
)
|
||||
|
||||
time_starts = torch.cat(time_lengths, 0)
|
||||
time_lengths = torch.cat(time_lengths, 0)
|
||||
else:
|
||||
time_starts = torch.zeros([bs, 1], dtype=torch.int64, device=x.device)
|
||||
time_lengths = torch.zeros([bs, 1], dtype=torch.int64, device=x.device)
|
||||
|
||||
output = dict(
|
||||
x=x,
|
||||
x_len=x_len,
|
||||
freq_starts=freq_starts,
|
||||
freq_lengths=freq_lengths,
|
||||
time_starts=time_starts,
|
||||
time_lengths=time_lengths,
|
||||
sh=sh,
|
||||
)
|
||||
return output
|
||||
# fmt: on
|
||||
|
||||
|
||||
def launch_kernel(data, cfg):
|
||||
# Launch CUDA kernel
|
||||
# fmt: off
|
||||
data['x'] = spec_aug_numba.launch_spec_augment_kernel(
|
||||
x=data['x'], x_len=data['x_len'],
|
||||
freq_starts=data['freq_starts'], freq_lengths=data['freq_lengths'],
|
||||
time_starts=data['time_starts'], time_lengths=data['time_lengths'],
|
||||
freq_masks=cfg.freq_masks, time_masks=cfg.time_masks, mask_value=cfg.mask_value
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
def freq_mask_check(x, x_len, f_start, f_len, mask_value, bidx):
|
||||
check_result = True
|
||||
for fidx in range(f_start, f_start + f_len):
|
||||
if not (x[bidx, fidx, :] == mask_value).all():
|
||||
check_result = False
|
||||
break
|
||||
assert check_result
|
||||
|
||||
|
||||
def time_mask_check(x, x_len, t_start, t_len, mask_value, bidx):
|
||||
check_result = True
|
||||
for tidx in range(t_start, t_start + t_len):
|
||||
if tidx >= x_len[bidx]:
|
||||
# this sample has smaller length than the time index of mask, ignore
|
||||
continue
|
||||
|
||||
if not (x[bidx, :, tidx] == mask_value).all():
|
||||
check_result = False
|
||||
break
|
||||
assert check_result
|
||||
|
||||
|
||||
class TestSpecAugmentNumba:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.parametrize('dtype', ['float16', 'float32'])
|
||||
def test_spec_aug_kernel(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0, dtype=dtype)
|
||||
cfg.freq_masks = 2
|
||||
cfg.time_masks = 10
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
|
||||
# Assert freq masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for f_start, f_len in zip(data['freq_starts'][bidx], data['freq_lengths'][bidx]):
|
||||
freq_mask_check(x, x_len, f_start, f_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.parametrize('dtype', ['float16', 'float32'])
|
||||
def test_spec_aug_kernel_large_batch(self, dtype):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
# Change max threads per block temporarily
|
||||
original_buffer = spec_aug_numba.MAX_THREAD_BUFFER
|
||||
spec_aug_numba.MAX_THREAD_BUFFER = 4
|
||||
|
||||
cfg = get_cfg(seed=0, dtype=dtype)
|
||||
cfg.freq_masks = 2
|
||||
cfg.time_masks = 10
|
||||
cfg.b = spec_aug_numba.MAX_THREAD_BUFFER + 1
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
|
||||
# Assert freq masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for f_start, f_len in zip(data['freq_starts'][bidx], data['freq_lengths'][bidx]):
|
||||
freq_mask_check(x, x_len, f_start, f_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
# Assert time masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for t_start, t_len in zip(data['time_starts'][bidx], data['time_lengths'][bidx]):
|
||||
time_mask_check(x, x_len, t_start, t_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
spec_aug_numba.MAX_THREAD_BUFFER = original_buffer
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_spec_aug_kernel_mask_value(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0)
|
||||
cfg.freq_masks = 2
|
||||
cfg.time_masks = 10
|
||||
cfg.mask_value = -1.0
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
|
||||
# Assert freq masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for f_start, f_len in zip(data['freq_starts'][bidx], data['freq_lengths'][bidx]):
|
||||
freq_mask_check(x, x_len, f_start, f_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
# Assert time masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for t_start, t_len in zip(data['time_starts'][bidx], data['time_lengths'][bidx]):
|
||||
time_mask_check(x, x_len, t_start, t_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_spec_aug_kernel_grad(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0)
|
||||
cfg.freq_masks = 2
|
||||
cfg.time_masks = 10
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
|
||||
result = data['x'] # inplace modification via kernel
|
||||
y = torch.ones_like(result, requires_grad=True)
|
||||
z = y + result
|
||||
z.mean().backward()
|
||||
|
||||
assert y.grad is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_spec_aug_kernel_no_freq_mask(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0)
|
||||
cfg.freq_masks = 0
|
||||
cfg.time_masks = 10
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
|
||||
# Assert time masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for t_start, t_len in zip(data['time_starts'][bidx], data['time_lengths'][bidx]):
|
||||
time_mask_check(x, x_len, t_start, t_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_spec_aug_kernel_no_time_mask(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0)
|
||||
cfg.freq_masks = 2
|
||||
cfg.time_masks = 0
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
launch_kernel(data, cfg)
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
|
||||
# Assert freq masks are correct
|
||||
for bidx in range(sh[0]):
|
||||
for f_start, f_len in zip(data['freq_starts'][bidx], data['freq_lengths'][bidx]):
|
||||
freq_mask_check(x, x_len, f_start, f_len, mask_value=cfg.mask_value, bidx=bidx)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_spec_aug_kernel_no_freq_time_mask(self):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
cfg = get_cfg(seed=0)
|
||||
cfg.freq_masks = 0
|
||||
cfg.time_masks = 0
|
||||
|
||||
data = prepare_data(**cfg)
|
||||
|
||||
x, x_len, sh = data['x'], data['x_len'], data['sh']
|
||||
x_copy = x.clone()
|
||||
launch_kernel(data, cfg)
|
||||
|
||||
# Assert no data edits occured
|
||||
assert (data['x'] - x_copy).abs().mean() <= 1e-9
|
||||
@@ -0,0 +1,390 @@
|
||||
# Copyright (c) 2020, 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 json
|
||||
import os
|
||||
|
||||
import tempfile
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from omegaconf import DictConfig, ListConfig
|
||||
|
||||
from nemo.collections.asr.data import audio_to_label
|
||||
from nemo.collections.asr.models import EncDecClassificationModel, EncDecFrameClassificationModel, configs
|
||||
from nemo.utils.config_utils import assert_dataclass_signature_match
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def speech_classification_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 32,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASRDecoderClassification',
|
||||
'params': {
|
||||
'feat_in': 32,
|
||||
'num_classes': 30,
|
||||
},
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'labels': ListConfig(["dummy_cls_{}".format(i + 1) for i in range(30)]),
|
||||
}
|
||||
)
|
||||
model = EncDecClassificationModel(cfg=modelConfig)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def frame_classification_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 32,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.common.parts.MultiLayerPerceptron',
|
||||
'params': {
|
||||
'hidden_size': 32,
|
||||
'num_classes': 5,
|
||||
},
|
||||
}
|
||||
|
||||
optim = {
|
||||
'name': 'sgd',
|
||||
'lr': 0.01,
|
||||
'weight_decay': 0.001,
|
||||
'momentum': 0.9,
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'optim': DictConfig(optim),
|
||||
'labels': ListConfig(["0", "1"]),
|
||||
}
|
||||
)
|
||||
model = EncDecFrameClassificationModel(cfg=modelConfig)
|
||||
return model
|
||||
|
||||
|
||||
class TestEncDecClassificationModel:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, speech_classification_model):
|
||||
asr_model = speech_classification_model.train()
|
||||
|
||||
conv_cnt = (64 * 32 * 1 + 32) + (64 * 1 * 1 + 32) # separable kernel + bias + pointwise kernel + bias
|
||||
bn_cnt = (4 * 32) * 2 # 2 * moving averages
|
||||
dec_cnt = 32 * 30 + 30 # fc + bias
|
||||
|
||||
param_count = conv_cnt + bn_cnt + dec_cnt
|
||||
assert asr_model.num_weights == param_count
|
||||
|
||||
# Check to/from config_dict:
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecClassificationModel.from_config_dict(confdict)
|
||||
|
||||
assert isinstance(instance2, EncDecClassificationModel)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, speech_classification_model):
|
||||
asr_model = speech_classification_model.eval()
|
||||
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins = asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logprobs_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch = asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logprobs_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, speech_classification_model):
|
||||
asr_model = speech_classification_model.train()
|
||||
|
||||
old_labels = copy.deepcopy(asr_model._cfg.labels)
|
||||
nw1 = asr_model.num_weights
|
||||
asr_model.change_labels(new_labels=old_labels)
|
||||
# No change
|
||||
assert nw1 == asr_model.num_weights
|
||||
new_labels = copy.deepcopy(old_labels)
|
||||
new_labels.append('dummy_cls_31')
|
||||
new_labels.append('dummy_cls_32')
|
||||
new_labels.append('dummy_cls_33')
|
||||
asr_model.change_labels(new_labels=new_labels)
|
||||
# fully connected + bias
|
||||
assert asr_model.num_weights == nw1 + 3 * (asr_model.decoder._feat_in + 1)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcription(self, speech_classification_model, test_data_dir):
|
||||
# Ground truth labels = ["yes", "no"]
|
||||
audio_filenames = ['an22-flrp-b.wav', 'an90-fbbh-b.wav']
|
||||
audio_paths = [os.path.join(test_data_dir, "asr", "train", "an4", "wav", fp) for fp in audio_filenames]
|
||||
|
||||
model = speech_classification_model.eval()
|
||||
|
||||
# Test Top 1 classification transcription
|
||||
results = model.transcribe(audio_paths, batch_size=2)
|
||||
assert len(results) == 2
|
||||
assert results[0].shape == torch.Size([1])
|
||||
|
||||
# Test Top 5 classification transcription
|
||||
model._accuracy.top_k = [5] # set top k to 5 for accuracy calculation
|
||||
results = model.transcribe(audio_paths, batch_size=2)
|
||||
assert len(results) == 2
|
||||
assert results[0].shape == torch.Size([5])
|
||||
|
||||
# Test Top 1 and Top 5 classification transcription
|
||||
model._accuracy.top_k = [1, 5]
|
||||
results = model.transcribe(audio_paths, batch_size=2)
|
||||
assert len(results) == 2
|
||||
assert results[0].shape == torch.Size([2, 1])
|
||||
assert results[1].shape == torch.Size([2, 5])
|
||||
assert model._accuracy.top_k == [1, 5]
|
||||
|
||||
# Test log probs extraction
|
||||
model._accuracy.top_k = [1]
|
||||
results = model.transcribe(audio_paths, batch_size=2, logprobs=True)
|
||||
assert len(results) == 2
|
||||
assert results[0].shape == torch.Size([len(model.cfg.labels)])
|
||||
|
||||
# Test log probs extraction remains same for any top_k
|
||||
model._accuracy.top_k = [5]
|
||||
results = model.transcribe(audio_paths, batch_size=2, logprobs=True)
|
||||
assert len(results) == 2
|
||||
assert results[0].shape == torch.Size([len(model.cfg.labels)])
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_EncDecClassificationDatasetConfig_for_AudioToSpeechLabelDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'tarred_audio_filepaths',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'tarred_shard_strategy',
|
||||
'shuffle_n',
|
||||
# `featurizer` is supplied at runtime
|
||||
'featurizer',
|
||||
# additional ignored arguments
|
||||
'vad_stream',
|
||||
'int_values',
|
||||
'sample_rate',
|
||||
'normalize_audio',
|
||||
'augmentor',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {'trim_silence': 'trim'}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_label.AudioToSpeechLabelDataset,
|
||||
configs.EncDecClassificationDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
|
||||
class TestEncDecFrameClassificationModel(TestEncDecClassificationModel):
|
||||
@pytest.mark.parametrize(["logits_len", "labels_len"], [(20, 10), (21, 10), (19, 10), (20, 9), (20, 11)])
|
||||
@pytest.mark.unit
|
||||
def test_reshape_labels(self, frame_classification_model, logits_len, labels_len):
|
||||
model = frame_classification_model.eval()
|
||||
|
||||
logits = torch.ones(4, logits_len, 2)
|
||||
labels = torch.ones(4, labels_len)
|
||||
logits_len = torch.tensor([6, 7, 8, 9])
|
||||
labels_len = torch.tensor([5, 6, 7, 8])
|
||||
labels_new, labels_len_new = model.reshape_labels(
|
||||
logits=logits, labels=labels, logits_len=logits_len, labels_len=labels_len
|
||||
)
|
||||
assert labels_new.size(1) == logits.size(1)
|
||||
assert torch.equal(labels_len_new, torch.tensor([6, 7, 8, 9]))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_EncDecClassificationDatasetConfig_for_AudioToMultiSpeechLabelDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'tarred_audio_filepaths',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'tarred_shard_strategy',
|
||||
'shuffle_n',
|
||||
# `featurizer` is supplied at runtime
|
||||
'featurizer',
|
||||
# additional ignored arguments
|
||||
'vad_stream',
|
||||
'int_values',
|
||||
'sample_rate',
|
||||
'normalize_audio',
|
||||
'augmentor',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
'delimiter',
|
||||
'normalize_audio_db',
|
||||
'normalize_audio_db_target',
|
||||
'window_length_in_sec',
|
||||
'shift_length_in_sec',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {'trim_silence': 'trim'}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_label.AudioToMultiLabelDataset,
|
||||
configs.EncDecClassificationDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_frame_classification_model(self, frame_classification_model: EncDecFrameClassificationModel):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# generate random audio
|
||||
audio = np.random.randn(16000 * 1)
|
||||
# save the audio
|
||||
audio_path = os.path.join(temp_dir, "audio.wav")
|
||||
sf.write(audio_path, audio, 16000)
|
||||
|
||||
dummy_labels = "0 0 0 0 1 1 1 1 0 0 0 0"
|
||||
|
||||
dummy_sample = {
|
||||
"audio_filepath": audio_path,
|
||||
"offset": 0.0,
|
||||
"duration": 1.0,
|
||||
"label": dummy_labels,
|
||||
}
|
||||
|
||||
# create a manifest file
|
||||
manifest_path = os.path.join(temp_dir, "dummy_manifest.json")
|
||||
with open(manifest_path, "w") as f:
|
||||
for i in range(4):
|
||||
f.write(json.dumps(dummy_sample) + "\n")
|
||||
|
||||
dataloader_cfg = {
|
||||
"batch_size": 2,
|
||||
"manifest_filepath": manifest_path,
|
||||
"sample_rate": 16000,
|
||||
"num_workers": 0,
|
||||
"shuffle": False,
|
||||
"labels": ["0", "1"],
|
||||
}
|
||||
|
||||
trainer_cfg = {
|
||||
"max_epochs": 1,
|
||||
"devices": 1,
|
||||
"accelerator": "auto",
|
||||
}
|
||||
|
||||
optim = {
|
||||
'name': 'sgd',
|
||||
'lr': 0.01,
|
||||
'weight_decay': 0.001,
|
||||
'momentum': 0.9,
|
||||
}
|
||||
|
||||
trainer = pl.Trainer(**trainer_cfg)
|
||||
frame_classification_model.set_trainer(trainer)
|
||||
frame_classification_model.setup_optimization(DictConfig(optim))
|
||||
frame_classification_model.setup_training_data(dataloader_cfg)
|
||||
frame_classification_model.setup_validation_data(dataloader_cfg)
|
||||
|
||||
trainer.fit(frame_classification_model)
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from lightning.pytorch import Trainer
|
||||
|
||||
from nemo.collections.asr.models import EncDecCTCModelBPE
|
||||
from nemo.collections.asr.parts import context_biasing
|
||||
from nemo.collections.asr.parts.context_biasing.ctc_based_word_spotter import WSHyp
|
||||
from nemo.collections.asr.parts.utils import rnnt_utils
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def conformer_ctc_bpe_model():
|
||||
model = EncDecCTCModelBPE.from_pretrained(model_name="stt_en_conformer_ctc_small")
|
||||
model.set_trainer(Trainer(devices=1, accelerator="cpu"))
|
||||
model = model.eval()
|
||||
return model
|
||||
|
||||
|
||||
class TestContextGraphCTC:
|
||||
@pytest.mark.unit
|
||||
def test_graph_building(self):
|
||||
context_biasing_list = [["gpu", [['▁g', 'p', 'u'], ['▁g', '▁p', '▁u']]]]
|
||||
context_graph = context_biasing.ContextGraphCTC(blank_id=1024)
|
||||
context_graph.add_to_graph(context_biasing_list)
|
||||
assert context_graph.num_nodes == 8
|
||||
assert context_graph.blank_token == 1024
|
||||
assert not context_graph.root.next['▁g'].is_end
|
||||
assert context_graph.root.next['▁g'].next['p'].next['u'].is_end
|
||||
assert context_graph.root.next['▁g'].next['p'].next['u'].word == 'gpu'
|
||||
assert context_graph.root.next['▁g'].next['▁p'].next['▁u'].is_end
|
||||
assert context_graph.root.next['▁g'].next['▁p'].next['▁u'].word == 'gpu'
|
||||
|
||||
|
||||
class TestCTCWordSpotter:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
def test_run_word_spotter(self, test_data_dir, conformer_ctc_bpe_model):
|
||||
asr_model = conformer_ctc_bpe_model
|
||||
audio_file_path = os.path.join(test_data_dir, "asr/test/an4/wav/cen3-mjwl-b.wav")
|
||||
target_text = "nineteen"
|
||||
target_tokenization = asr_model.tokenizer.text_to_ids(target_text)
|
||||
ctc_logprobs = (
|
||||
asr_model.transcribe([audio_file_path], batch_size=1, return_hypotheses=True)[0].alignments.cpu().numpy()
|
||||
)
|
||||
context_biasing_list = [[target_text, [target_tokenization]]]
|
||||
context_graph = context_biasing.ContextGraphCTC(blank_id=asr_model.decoding.blank_id)
|
||||
context_graph.add_to_graph(context_biasing_list)
|
||||
|
||||
# without context biasing
|
||||
ws_results = context_biasing.run_word_spotter(
|
||||
ctc_logprobs,
|
||||
context_graph,
|
||||
asr_model,
|
||||
blank_idx=asr_model.decoding.blank_id,
|
||||
beam_threshold=5.0,
|
||||
cb_weight=0.0,
|
||||
ctc_ali_token_weight=0.6,
|
||||
)
|
||||
assert len(ws_results) == 0
|
||||
|
||||
# with context biasing
|
||||
ws_results = context_biasing.run_word_spotter(
|
||||
ctc_logprobs,
|
||||
context_graph,
|
||||
asr_model,
|
||||
blank_idx=asr_model.decoding.blank_id,
|
||||
beam_threshold=5.0,
|
||||
cb_weight=3.0,
|
||||
ctc_ali_token_weight=0.6,
|
||||
)
|
||||
assert len(ws_results) == 1
|
||||
assert ws_results[0].word == target_text
|
||||
assert ws_results[0].start_frame == 9
|
||||
assert ws_results[0].end_frame == 19
|
||||
torch.testing.assert_close(ws_results[0].score, 8.9967, atol=1e-3, rtol=1e-4)
|
||||
|
||||
|
||||
class TestContextBiasingUtils:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.with_downloads
|
||||
def test_merge_alignment_with_ws_hyps(self, conformer_ctc_bpe_model):
|
||||
asr_model = conformer_ctc_bpe_model
|
||||
blank_idx = asr_model.decoding.blank_id
|
||||
ws_results = [WSHyp(word="gpu", score=6.0, start_frame=0, end_frame=2)]
|
||||
|
||||
# ctc argmax predictions
|
||||
preds = np.array([120, 29, blank_idx, blank_idx])
|
||||
pred_text, raw_text = context_biasing.merge_alignment_with_ws_hyps(
|
||||
preds,
|
||||
asr_model,
|
||||
ws_results,
|
||||
decoder_type="ctc",
|
||||
blank_idx=blank_idx,
|
||||
)
|
||||
assert raw_text == "gp"
|
||||
assert pred_text == "gpu"
|
||||
|
||||
# rnnt token predictions
|
||||
preds = rnnt_utils.Hypothesis(
|
||||
y_sequence=torch.tensor([120, 29]),
|
||||
score=0.0,
|
||||
timestamp=torch.tensor([0, 1, 2, 3]),
|
||||
)
|
||||
pred_text, raw_text = context_biasing.merge_alignment_with_ws_hyps(
|
||||
preds,
|
||||
asr_model,
|
||||
ws_results,
|
||||
decoder_type="rnnt",
|
||||
blank_idx=blank_idx,
|
||||
)
|
||||
assert raw_text == "gp"
|
||||
assert pred_text == "gpu"
|
||||
|
||||
# rnnt empty token predictions
|
||||
preds = rnnt_utils.Hypothesis(
|
||||
y_sequence=[],
|
||||
score=0.0,
|
||||
timestamp=[],
|
||||
)
|
||||
pred_text, raw_text = context_biasing.merge_alignment_with_ws_hyps(
|
||||
preds,
|
||||
asr_model,
|
||||
ws_results,
|
||||
decoder_type="rnnt",
|
||||
blank_idx=blank_idx,
|
||||
)
|
||||
assert raw_text == ""
|
||||
assert pred_text == "gpu"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_compute_fscore(self):
|
||||
recog_manifest = """{"audio_filepath": "test.wav", "duration": 1.0, "text": "a new gpu for nvidia", "pred_text": "a new gpu for invidia"}\n"""
|
||||
context_words = ["gpu", "cpu", "nvidia"]
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
f.write(recog_manifest)
|
||||
f.seek(0)
|
||||
fscore_stats = context_biasing.compute_fscore(f.name, context_words)
|
||||
assert (round(fscore_stats[0], 4), round(fscore_stats[1], 4), round(fscore_stats[2], 4)) == (
|
||||
1.0,
|
||||
0.5,
|
||||
0.6667,
|
||||
)
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (c) 2020, 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
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data import audio_to_text
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.asr.models import configs
|
||||
from nemo.collections.asr.models.ctc_bpe_models import EncDecCTCModelBPE
|
||||
from nemo.collections.asr.parts.submodules import ctc_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCBPEDecoding, CTCBPEDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.utils.config_utils import assert_dataclass_signature_match
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def asr_model(test_data_dir):
|
||||
preprocessor = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 1024,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 1024,
|
||||
'num_classes': -1,
|
||||
'vocabulary': None,
|
||||
}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecCTCModelBPE(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecCTCModel:
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, asr_model):
|
||||
asr_model.train()
|
||||
# TODO: make proper config and assert correct number of weights
|
||||
# Check to/from config_dict:
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecCTCModelBPE.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecCTCModelBPE)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, asr_model):
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _, _ = asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
print(len(logprobs_ins))
|
||||
logprobs_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _, _ = asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logprobs_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, asr_model):
|
||||
asr_model = asr_model.eval()
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=1, with_data=True)
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=asr_model.tokenizer, return_cuts=True)
|
||||
batch = dataset[cuts]
|
||||
outputs = asr_model.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][0], MonoCut)
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact(self, asr_model):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
save_path = os.path.join(tmpdir, 'ctc_bpe.nemo')
|
||||
asr_model.train()
|
||||
asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecCTCModelBPE.restore_from(save_path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_spe(self, asr_model, test_data_dir):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=tokenizer_dir, new_tokenizer_type='bpe')
|
||||
|
||||
save_path = os.path.join(tmpdir, 'ctc_bpe.nemo')
|
||||
asr_model.train()
|
||||
asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecCTCModelBPE.restore_from(save_path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.SentencePieceTokenizer)
|
||||
assert new_model.model_path.endswith('_tokenizer.model')
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
assert new_model.spe_vocab_path.endswith('_tokenizer.vocab')
|
||||
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 128
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_agg(self, asr_model, test_data_dir):
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
tok_en = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
# the below is really an english tokenizer but we pretend it is spanish
|
||||
tok_es = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
tcfg = DictConfig({"type": "agg", "langs": {"en": tok_en, "es": tok_es}})
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=tcfg, new_tokenizer_type="agg")
|
||||
|
||||
save_path = os.path.join(tmpdir, "ctc_agg.nemo")
|
||||
asr_model.train()
|
||||
asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecCTCModelBPE.restore_from(save_path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.AggregateTokenizer)
|
||||
|
||||
# should be double
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 264
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 264
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, test_data_dir, asr_model):
|
||||
old_vocab = copy.deepcopy(asr_model.decoder.vocabulary)
|
||||
|
||||
with tempfile.TemporaryDirectory() as save_dir:
|
||||
save_path = os.path.join(save_dir, 'temp.nemo')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_tmpdir_path = tmpdir
|
||||
|
||||
old_tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
new_tokenizer_dir = os.path.join(tmpdir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
shutil.copy2(old_tokenizer_dir, new_tokenizer_dir)
|
||||
|
||||
nw1 = asr_model.num_weights
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
# No change
|
||||
assert nw1 == asr_model.num_weights
|
||||
|
||||
with open(os.path.join(new_tokenizer_dir, 'vocab.txt'), 'a+') as f:
|
||||
f.write("!\n")
|
||||
f.write('$\n')
|
||||
f.write('@\n')
|
||||
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
# fully connected + bias
|
||||
assert asr_model.num_weights == nw1 + 3 * (asr_model.decoder._feat_in + 1)
|
||||
|
||||
new_vocab = copy.deepcopy(asr_model.decoder.vocabulary)
|
||||
assert len(old_vocab) != len(new_vocab)
|
||||
|
||||
# save the model (after change of vocabulary)
|
||||
asr_model.save_to(save_path)
|
||||
assert os.path.exists(save_path)
|
||||
# delete copied version of the vocabulary from nested tmpdir (by scope)
|
||||
|
||||
# assert copied vocab no longer exists
|
||||
assert not os.path.exists(os.path.join(old_tmpdir_path, 'tokenizer', 'vocab.txt'))
|
||||
|
||||
# make a copy of the tokenizer before renaming
|
||||
try:
|
||||
os.rename(old_tokenizer_dir, old_tokenizer_dir + '.bkp')
|
||||
assert not os.path.exists(old_tokenizer_dir)
|
||||
|
||||
# restore model from .nemo
|
||||
asr_model2 = EncDecCTCModelBPE.restore_from(save_path)
|
||||
assert isinstance(asr_model2, EncDecCTCModelBPE)
|
||||
|
||||
# Check if vocabulary size is same
|
||||
assert asr_model.tokenizer.tokenizer.vocab_size == asr_model2.tokenizer.tokenizer.vocab_size
|
||||
|
||||
# Make a copy of the tokenizer
|
||||
new_tokenizer_dir = os.path.join(save_dir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
new_tokenizer_path = os.path.join(new_tokenizer_dir, 'vocab.txt')
|
||||
with open(new_tokenizer_path, 'w') as f:
|
||||
for v in asr_model2.tokenizer.tokenizer.get_vocab():
|
||||
f.write(f"{v}\n")
|
||||
|
||||
# Add some new tokens too
|
||||
f.write("^\n")
|
||||
f.write("^^\n")
|
||||
f.write("^^^\n")
|
||||
|
||||
assert os.path.exists(new_tokenizer_path)
|
||||
|
||||
# change vocabulary
|
||||
asr_model2.change_vocabulary(new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
assert asr_model.tokenizer.vocab_size != asr_model2.tokenizer.vocab_size
|
||||
|
||||
new_save_path = os.path.join(save_dir, 'temp2.nemo')
|
||||
asr_model2.save_to(new_save_path)
|
||||
|
||||
asr_model3 = EncDecCTCModelBPE.restore_from(new_save_path)
|
||||
assert isinstance(asr_model3, EncDecCTCModelBPE)
|
||||
|
||||
# Check if vocabulary size is same
|
||||
assert asr_model2.tokenizer.tokenizer.vocab_size == asr_model3.tokenizer.tokenizer.vocab_size
|
||||
assert asr_model2.vocab_path != asr_model3.vocab_path
|
||||
|
||||
# Model PT level checks
|
||||
assert len(asr_model2.artifacts) == 1
|
||||
|
||||
finally:
|
||||
os.rename(old_tokenizer_dir + '.bkp', old_tokenizer_dir)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, asr_model):
|
||||
assert asr_model.decoding is not None
|
||||
assert isinstance(asr_model.decoding, CTCBPEDecoding)
|
||||
assert asr_model.decoding.cfg.strategy == "greedy_batch"
|
||||
assert asr_model.decoding.preserve_alignments is False
|
||||
assert asr_model.decoding.compute_timestamps is False
|
||||
|
||||
cfg = CTCBPEDecodingConfig(preserve_alignments=True, compute_timestamps=True)
|
||||
asr_model.change_decoding_strategy(cfg)
|
||||
|
||||
assert asr_model.decoding.preserve_alignments is True
|
||||
assert asr_model.decoding.compute_timestamps is True
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamCTCInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'pyctcdecode'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamCTCInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "pyctcdecode"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'flashlight'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamCTCInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "flashlight"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'wfst'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.WfstCTCInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "riva"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASRDatasetConfig_for_AudioToBPEDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'tarred_audio_filepaths',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'tarred_shard_strategy',
|
||||
'shard_manifests',
|
||||
'shuffle_n',
|
||||
'parser',
|
||||
'normalize',
|
||||
'unk_index',
|
||||
'pad_id',
|
||||
'bos_id',
|
||||
'eos_id',
|
||||
'blank_index',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
'channel_selector',
|
||||
'use_lhotse',
|
||||
'tarred_random_access',
|
||||
'use_bucketing',
|
||||
'batch_duration',
|
||||
'quadratic_duration',
|
||||
'bucket_batch_size',
|
||||
'bucket_duration_bins',
|
||||
'num_buckets',
|
||||
'pin_memory',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {'trim_silence': 'trim', 'labels': 'tokenizer'}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_text.AudioToBPEDataset,
|
||||
configs.ASRDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASRDatasetConfig_for_TarredAudioToBPEDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'parser',
|
||||
'normalize',
|
||||
'unk_index',
|
||||
'pad_id',
|
||||
'bos_id',
|
||||
'eos_id',
|
||||
'blank_index',
|
||||
'global_rank',
|
||||
'world_size',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
'max_utts',
|
||||
'use_lhotse',
|
||||
'tarred_random_access',
|
||||
'use_bucketing',
|
||||
'batch_duration',
|
||||
'quadratic_duration',
|
||||
'bucket_batch_size',
|
||||
'bucket_duration_bins',
|
||||
'num_buckets',
|
||||
'pin_memory',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {
|
||||
'trim_silence': 'trim',
|
||||
'tarred_audio_filepaths': 'audio_tar_filepaths',
|
||||
'tarred_shard_strategy': 'shard_strategy',
|
||||
'shuffle_n': 'shuffle',
|
||||
'labels': 'tokenizer',
|
||||
}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_text.TarredAudioToBPEDataset,
|
||||
configs.ASRDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
@@ -0,0 +1,365 @@
|
||||
# Copyright (c) 2020, 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 pytest
|
||||
import torch
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
from omegaconf import DictConfig, OmegaConf, open_dict
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.data import audio_to_text
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.asr.models import EncDecCTCModel, configs
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecoding, CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common.parts.preprocessing.parsers import make_parser
|
||||
from nemo.utils.config_utils import assert_dataclass_signature_match, update_model_config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def asr_model():
|
||||
preprocessor = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 1024,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 1024,
|
||||
'num_classes': 28,
|
||||
'vocabulary': [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
],
|
||||
}
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
|
||||
model_instance = EncDecCTCModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecCTCModel:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, asr_model):
|
||||
asr_model.train()
|
||||
# TODO: make proper config and assert correct number of weights
|
||||
# Check to/from config_dict:
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecCTCModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecCTCModel)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, asr_model):
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _, _ = asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
print(len(logprobs_ins))
|
||||
logprobs_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _, _ = asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logprobs_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, asr_model):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
asr_model = asr_model.eval()
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=1, with_data=True)
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=make_parser(labels=token_list), return_cuts=True)
|
||||
batch = dataset[cuts]
|
||||
outputs = asr_model.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][0], MonoCut)
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, asr_model):
|
||||
old_vocab = copy.deepcopy(asr_model.decoder.vocabulary)
|
||||
nw1 = asr_model.num_weights
|
||||
asr_model.change_vocabulary(new_vocabulary=old_vocab)
|
||||
# No change
|
||||
assert nw1 == asr_model.num_weights
|
||||
new_vocab = copy.deepcopy(old_vocab)
|
||||
new_vocab.append('!')
|
||||
new_vocab.append('$')
|
||||
new_vocab.append('@')
|
||||
asr_model.change_vocabulary(new_vocabulary=new_vocab)
|
||||
# fully connected + bias
|
||||
assert asr_model.num_weights == nw1 + 3 * (asr_model.decoder._feat_in + 1)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, asr_model):
|
||||
assert asr_model.decoding is not None
|
||||
assert isinstance(asr_model.decoding, CTCDecoding)
|
||||
assert asr_model.decoding.cfg.strategy == "greedy_batch"
|
||||
assert asr_model.decoding.preserve_alignments is False
|
||||
assert asr_model.decoding.compute_timestamps is False
|
||||
|
||||
cfg = CTCDecodingConfig(preserve_alignments=True, compute_timestamps=True)
|
||||
asr_model.change_decoding_strategy(cfg)
|
||||
|
||||
assert asr_model.decoding.preserve_alignments is True
|
||||
assert asr_model.decoding.compute_timestamps is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_change_conv_asr_se_context_window(self, asr_model):
|
||||
old_cfg = copy.deepcopy(asr_model.cfg)
|
||||
asr_model.change_conv_asr_se_context_window(context_window=32) # 32 * 0.01s context
|
||||
new_config = asr_model.cfg
|
||||
|
||||
assert old_cfg.encoder.jasper[0].se_context_size == -1
|
||||
assert new_config.encoder.jasper[0].se_context_size == 32
|
||||
|
||||
for name, m in asr_model.encoder.named_modules():
|
||||
if type(m).__class__.__name__ == 'SqueezeExcite':
|
||||
assert m.context_window == 32
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_change_conv_asr_se_context_window_no_config_update(self, asr_model):
|
||||
old_cfg = copy.deepcopy(asr_model.cfg)
|
||||
asr_model.change_conv_asr_se_context_window(context_window=32, update_config=False) # 32 * 0.01s context
|
||||
new_config = asr_model.cfg
|
||||
|
||||
assert old_cfg.encoder.jasper[0].se_context_size == -1
|
||||
assert new_config.encoder.jasper[0].se_context_size == -1 # no change
|
||||
|
||||
for name, m in asr_model.encoder.named_modules():
|
||||
if type(m).__class__.__name__ == 'SqueezeExcite':
|
||||
assert m.context_window == 32
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_dataclass_instantiation(self, asr_model):
|
||||
model_cfg = configs.EncDecCTCModelConfig()
|
||||
|
||||
# Update mandatory values
|
||||
vocabulary = asr_model.decoder.vocabulary
|
||||
model_cfg.model.labels = vocabulary
|
||||
|
||||
# Update encoder
|
||||
model_cfg.model.encoder.activation = 'relu'
|
||||
model_cfg.model.encoder.feat_in = 64
|
||||
model_cfg.model.encoder.jasper = [
|
||||
nemo_asr.modules.conv_asr.JasperEncoderConfig(
|
||||
filters=1024,
|
||||
repeat=1,
|
||||
kernel=[1],
|
||||
stride=[1],
|
||||
dilation=[1],
|
||||
dropout=0.0,
|
||||
residual=False,
|
||||
se=True,
|
||||
se_context_size=-1,
|
||||
)
|
||||
]
|
||||
|
||||
# Update decoder
|
||||
model_cfg.model.decoder.feat_in = 1024
|
||||
model_cfg.model.decoder.num_classes = 28
|
||||
model_cfg.model.decoder.vocabulary = vocabulary
|
||||
|
||||
# Construct the model
|
||||
asr_cfg = OmegaConf.create({'model': asr_model.cfg})
|
||||
model_cfg_v1 = update_model_config(model_cfg, asr_cfg)
|
||||
new_model = EncDecCTCModel(cfg=model_cfg_v1.model)
|
||||
|
||||
assert new_model.num_weights == asr_model.num_weights
|
||||
# trainer and exp manager should be there
|
||||
# assert 'trainer' in model_cfg_v1
|
||||
# assert 'exp_manager' in model_cfg_v1
|
||||
# datasets and optim/sched should not be there after ModelPT.update_model_dataclass()
|
||||
assert 'train_ds' not in model_cfg_v1.model
|
||||
assert 'validation_ds' not in model_cfg_v1.model
|
||||
assert 'test_ds' not in model_cfg_v1.model
|
||||
assert 'optim' not in model_cfg_v1.model
|
||||
|
||||
# Construct the model, without dropping additional keys
|
||||
asr_cfg = OmegaConf.create({'model': asr_model.cfg})
|
||||
model_cfg_v2 = update_model_config(model_cfg, asr_cfg, drop_missing_subconfigs=False)
|
||||
|
||||
# Assert all components are in config
|
||||
# assert 'trainer' in model_cfg_v2
|
||||
# assert 'exp_manager' in model_cfg_v2
|
||||
assert 'train_ds' in model_cfg_v2.model
|
||||
assert 'validation_ds' in model_cfg_v2.model
|
||||
assert 'test_ds' in model_cfg_v2.model
|
||||
assert 'optim' in model_cfg_v2.model
|
||||
|
||||
# Remove extra components (optim and sched can be kept without issue)
|
||||
with open_dict(model_cfg_v2.model):
|
||||
model_cfg_v2.model.pop('train_ds')
|
||||
model_cfg_v2.model.pop('validation_ds')
|
||||
model_cfg_v2.model.pop('test_ds')
|
||||
|
||||
new_model = EncDecCTCModel(cfg=model_cfg_v2.model)
|
||||
|
||||
assert new_model.num_weights == asr_model.num_weights
|
||||
# trainer and exp manager should be there
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASRDatasetConfig_for_AudioToCharDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'tarred_audio_filepaths',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'tarred_shard_strategy',
|
||||
'shard_manifests',
|
||||
'shuffle_n',
|
||||
'use_start_end_token',
|
||||
'use_start_end_token',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
'channel_selector',
|
||||
'use_lhotse',
|
||||
'tarred_random_access',
|
||||
'use_bucketing',
|
||||
'batch_duration',
|
||||
'quadratic_duration',
|
||||
'bucket_batch_size',
|
||||
'bucket_duration_bins',
|
||||
'num_buckets',
|
||||
'pin_memory',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {'trim_silence': 'trim'}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_text.AudioToCharDataset,
|
||||
configs.ASRDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ASRDatasetConfig_for_TarredAudioToCharDataset(self):
|
||||
# ignore some additional arguments as dataclass is generic
|
||||
IGNORE_ARGS = [
|
||||
'is_tarred',
|
||||
'num_workers',
|
||||
'batch_size',
|
||||
'shuffle',
|
||||
'pin_memory',
|
||||
'drop_last',
|
||||
'global_rank',
|
||||
'world_size',
|
||||
'use_start_end_token',
|
||||
'bucketing_batch_size',
|
||||
'bucketing_strategy',
|
||||
'bucketing_weights',
|
||||
'max_utts',
|
||||
'use_lhotse',
|
||||
'tarred_random_access',
|
||||
'use_bucketing',
|
||||
'batch_duration',
|
||||
'quadratic_duration',
|
||||
'bucket_batch_size',
|
||||
'bucket_duration_bins',
|
||||
'num_buckets',
|
||||
'pin_memory',
|
||||
]
|
||||
|
||||
REMAP_ARGS = {
|
||||
'trim_silence': 'trim',
|
||||
'tarred_audio_filepaths': 'audio_tar_filepaths',
|
||||
'tarred_shard_strategy': 'shard_strategy',
|
||||
'shuffle_n': 'shuffle',
|
||||
}
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
audio_to_text.TarredAudioToCharDataset,
|
||||
configs.ASRDatasetConfig,
|
||||
ignore_args=IGNORE_ARGS,
|
||||
remap_args=REMAP_ARGS,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
@@ -0,0 +1,866 @@
|
||||
# Copyright (c) 2020, 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 filecmp
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch.cuda
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from nemo.collections.asr.data import audio_to_text_dataset
|
||||
from nemo.collections.asr.data.audio_to_text import (
|
||||
DataStoreObject,
|
||||
TarredAudioToBPEDataset,
|
||||
TarredAudioToCharDataset,
|
||||
cache_datastore_manifests,
|
||||
)
|
||||
from nemo.collections.asr.data.audio_to_text_dali import (
|
||||
__DALI_MINIMUM_VERSION__,
|
||||
AudioToBPEDALIDataset,
|
||||
AudioToCharDALIDataset,
|
||||
is_dali_supported,
|
||||
)
|
||||
from nemo.collections.asr.data.audio_to_text_dataset import inject_dataloader_value_from_model_config
|
||||
from nemo.collections.asr.data.feature_to_text import FeatureToBPEDataset, FeatureToCharDataset
|
||||
from nemo.collections.asr.models.ctc_models import EncDecCTCModel
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import write_manifest
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.collections.common.data.lhotse import get_lhotse_dataloader_from_config
|
||||
from nemo.utils import logging
|
||||
|
||||
try:
|
||||
HAVE_DALI = is_dali_supported(__DALI_MINIMUM_VERSION__)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
HAVE_DALI = False
|
||||
|
||||
|
||||
def decode_chars(tokens, token_length, mapping):
|
||||
text = []
|
||||
tokens = tokens.cpu().numpy()
|
||||
for idx in tokens:
|
||||
text_token = mapping[idx]
|
||||
text.append(text_token)
|
||||
|
||||
text = text[:token_length]
|
||||
text = ''.join(text)
|
||||
return text
|
||||
|
||||
|
||||
def decode_subwords(tokens, token_length, tokenizer: tokenizers.TokenizerSpec):
|
||||
tokens = tokens.cpu().numpy()
|
||||
tokens = tokens[:token_length]
|
||||
text = tokenizer.ids_to_text(tokens)
|
||||
return text
|
||||
|
||||
|
||||
class TestASRDatasets:
|
||||
labels = [
|
||||
" ",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"'",
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tarred_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/tarred_audio_manifest.json'))
|
||||
|
||||
# Test braceexpand loading
|
||||
tarpath = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/audio_{0..1}.tar'))
|
||||
ds_braceexpand = TarredAudioToCharDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, sample_rate=16000
|
||||
)
|
||||
assert len(ds_braceexpand) == 32
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
# Test loading via list
|
||||
tarpath = [os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{i}.tar')) for i in range(2)]
|
||||
ds_list_load = TarredAudioToCharDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, sample_rate=16000
|
||||
)
|
||||
count = 0
|
||||
for _ in ds_list_load:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tarred_dataset_filter(self, test_data_dir):
|
||||
"""
|
||||
Checks for
|
||||
1. file count when manifest len is less than tarred dataset
|
||||
2. Ignoring files in manifest that are not in tarred balls
|
||||
|
||||
"""
|
||||
manifest_path = os.path.abspath(
|
||||
os.path.join(test_data_dir, 'asr/tarred_an4/tarred_duplicate_audio_manifest.json')
|
||||
)
|
||||
|
||||
# Test braceexpand loading
|
||||
tarpath = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/audio_{0..1}.tar'))
|
||||
ds_braceexpand = TarredAudioToCharDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, sample_rate=16000
|
||||
)
|
||||
assert len(ds_braceexpand) == 6
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 5 # file ending with sub is not part of tar ball
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mismatch_in_model_dataloader_config(self, caplog):
|
||||
logging._logger.propagate = True
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
model_cfg = OmegaConf.create(dict(labels=OmegaConf.create(["a", "b", "c"])))
|
||||
dataloader_cfg = OmegaConf.create(dict(labels=copy.deepcopy(self.labels)))
|
||||
|
||||
inject_dataloader_value_from_model_config(model_cfg, dataloader_cfg, key='labels')
|
||||
|
||||
assert (
|
||||
"""`labels` is explicitly provided to the data loader, and is different from the `labels` provided at the model level config."""
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
logging._logger.propagate = False
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_tarred_bpe_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/tarred_audio_manifest.json'))
|
||||
|
||||
tokenizer_path = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
tokenizer = tokenizers.AutoTokenizer(pretrained_model_name='bert-base-cased', vocab_file=tokenizer_path)
|
||||
|
||||
# Test braceexpand loading
|
||||
tarpath = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/audio_{0..1}.tar'))
|
||||
ds_braceexpand = TarredAudioToBPEDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, tokenizer=tokenizer, sample_rate=16000
|
||||
)
|
||||
assert len(ds_braceexpand) == 32
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
# Test loading via list
|
||||
tarpath = [os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{i}.tar')) for i in range(2)]
|
||||
ds_list_load = TarredAudioToBPEDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, tokenizer=tokenizer, sample_rate=16000
|
||||
)
|
||||
count = 0
|
||||
for _ in ds_list_load:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
@pytest.mark.skipif(not HAVE_DALI, reason="NVIDIA DALI is not installed or incompatible version")
|
||||
@pytest.mark.unit
|
||||
def test_dali_char_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/an4_val.json'))
|
||||
|
||||
num_samples = 10
|
||||
batch_size = 2
|
||||
device = 'gpu' if torch.cuda.is_available() else 'cpu'
|
||||
texts = []
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as m:
|
||||
for ix, line in enumerate(m):
|
||||
if ix >= num_samples:
|
||||
break
|
||||
|
||||
line = line.replace("tests/data/", "tests/.data/").replace("\n", "")
|
||||
f.write(f"{line}\n")
|
||||
|
||||
data = json.loads(line)
|
||||
texts.append(data['text'])
|
||||
|
||||
f.seek(0)
|
||||
|
||||
dataset = AudioToCharDALIDataset(
|
||||
manifest_filepath=f.name,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
labels=self.labels,
|
||||
max_duration=16.0,
|
||||
parser='en',
|
||||
shuffle=False,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
original_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_chars(transcript, transcripts_length, mapping=self.labels)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
original_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
# Assert transcripts are correct
|
||||
for text, og_transcript in zip(texts, original_transcripts):
|
||||
assert text == og_transcript
|
||||
|
||||
# Repeat, now with shuffle enabled
|
||||
f.seek(0)
|
||||
|
||||
dataset = AudioToCharDALIDataset(
|
||||
manifest_filepath=f.name,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
labels=self.labels,
|
||||
max_duration=16.0,
|
||||
parser='en',
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
shuffled_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_chars(transcript, transcripts_length, mapping=self.labels)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
shuffled_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
samples_changed = 0
|
||||
for orig, shuffled in zip(original_transcripts, shuffled_transcripts):
|
||||
if orig != shuffled:
|
||||
samples_changed += 1
|
||||
assert samples_changed > 1 # assume after shuffling at least 1 sample was displaced
|
||||
|
||||
for og_transcript, shuffled_transcript in zip(sorted(original_transcripts), sorted(shuffled_transcripts)):
|
||||
assert og_transcript == shuffled_transcript
|
||||
|
||||
@pytest.mark.skipif(not HAVE_DALI, reason="NVIDIA DALI is not installed or incompatible version")
|
||||
@pytest.mark.unit
|
||||
def test_dali_bpe_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/an4_val.json'))
|
||||
|
||||
num_samples = 10
|
||||
batch_size = 2
|
||||
device = 'gpu' if torch.cuda.is_available() else 'cpu'
|
||||
texts = []
|
||||
|
||||
tokenizer_path = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
tokenizer = tokenizers.AutoTokenizer(pretrained_model_name='bert-base-cased', vocab_file=tokenizer_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as m:
|
||||
for ix, line in enumerate(m):
|
||||
if ix >= num_samples:
|
||||
break
|
||||
|
||||
line = line.replace("tests/data/", "tests/.data/").replace("\n", "")
|
||||
f.write(f"{line}\n")
|
||||
|
||||
data = json.loads(line)
|
||||
texts.append(data['text'])
|
||||
|
||||
f.seek(0)
|
||||
|
||||
dataset = AudioToBPEDALIDataset(
|
||||
manifest_filepath=f.name,
|
||||
tokenizer=tokenizer,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
max_duration=16.0,
|
||||
shuffle=False,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
original_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_subwords(transcript, transcripts_length, tokenizer=tokenizer)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
original_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
# Assert transcripts are correct
|
||||
for text, og_transcript in zip(texts, original_transcripts):
|
||||
assert text == og_transcript
|
||||
|
||||
# Repeat, now with shuffle enabled
|
||||
f.seek(0)
|
||||
|
||||
dataset = AudioToBPEDALIDataset(
|
||||
manifest_filepath=f.name,
|
||||
tokenizer=tokenizer,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
max_duration=16.0,
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
shuffled_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_subwords(transcript, transcripts_length, tokenizer=tokenizer)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
shuffled_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
samples_changed = 0
|
||||
for orig, shuffled in zip(original_transcripts, shuffled_transcripts):
|
||||
if orig != shuffled:
|
||||
samples_changed += 1
|
||||
assert samples_changed > 1 # assume after shuffling at least 1 sample was displaced
|
||||
|
||||
for og_transcript, shuffled_transcript in zip(sorted(original_transcripts), sorted(shuffled_transcripts)):
|
||||
assert og_transcript == shuffled_transcript
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="DALI ASR Dataset's preprocessor is not patched with padding inconsistency fix (PR #13827)"
|
||||
)
|
||||
@pytest.mark.skipif(not HAVE_DALI, reason="NVIDIA DALI is not installed or incompatible version")
|
||||
@pytest.mark.unit
|
||||
def test_dali_char_vs_ref_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/an4_val.json'))
|
||||
|
||||
num_samples = 10
|
||||
batch_size = 1
|
||||
texts = []
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
with open(manifest_path, 'r') as m:
|
||||
for ix, line in enumerate(m):
|
||||
if ix >= num_samples:
|
||||
break
|
||||
|
||||
line = line.replace("tests/data/", "tests/.data/").replace("\n", "")
|
||||
f.write(f"{line}\n")
|
||||
|
||||
data = json.loads(line)
|
||||
texts.append(data['text'])
|
||||
|
||||
f.seek(0)
|
||||
|
||||
preprocessor = {
|
||||
'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
'dither': 0.0,
|
||||
}
|
||||
preprocessor_cfg = DictConfig(preprocessor)
|
||||
|
||||
dataset_cfg = {
|
||||
'manifest_filepath': f.name,
|
||||
'sample_rate': 16000,
|
||||
'labels': self.labels,
|
||||
'batch_size': batch_size,
|
||||
'trim_silence': False,
|
||||
'max_duration': 16.7,
|
||||
'shuffle': False,
|
||||
'is_tarred': False,
|
||||
}
|
||||
dali_dataset = audio_to_text_dataset.get_dali_char_dataset(
|
||||
config=dataset_cfg,
|
||||
shuffle=False,
|
||||
device_id=0,
|
||||
global_rank=0,
|
||||
world_size=1,
|
||||
preprocessor_cfg=preprocessor_cfg,
|
||||
)
|
||||
ref_dataset = audio_to_text_dataset.get_char_dataset(
|
||||
config=dataset_cfg,
|
||||
)
|
||||
ref_dataloader = DataLoader(
|
||||
dataset=ref_dataset,
|
||||
batch_size=batch_size,
|
||||
collate_fn=ref_dataset.collate_fn,
|
||||
drop_last=False,
|
||||
shuffle=False,
|
||||
num_workers=0,
|
||||
pin_memory=False,
|
||||
)
|
||||
ref_preprocessor = EncDecCTCModel.from_config_dict(preprocessor_cfg)
|
||||
|
||||
for ref_data, dali_data in zip(ref_dataloader, dali_dataset):
|
||||
ref_audio, ref_audio_len, _, _ = ref_data
|
||||
ref_features, ref_features_len = ref_preprocessor(input_signal=ref_audio, length=ref_audio_len)
|
||||
|
||||
dali_features, dali_features_len, _, _ = dali_data
|
||||
|
||||
a = ref_features.cpu().numpy()[:, :, :ref_features_len]
|
||||
b = dali_features.cpu().numpy()[:, :, :dali_features_len]
|
||||
|
||||
err = np.abs(a - b)
|
||||
assert np.mean(err) < 0.0001
|
||||
assert np.max(err) < 0.01
|
||||
|
||||
@pytest.mark.skipif(not HAVE_DALI, reason="NVIDIA DALI is not installed or incompatible version")
|
||||
@pytest.mark.unit
|
||||
def test_tarred_dali_char_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/tarred_audio_manifest.json'))
|
||||
audio_tar_filepaths = [
|
||||
os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{idx}.tar')) for idx in range(2)
|
||||
]
|
||||
audio_tar_index_filepaths = [
|
||||
os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/dali_index/audio_{idx}.index'))
|
||||
for idx in range(2)
|
||||
]
|
||||
|
||||
batch_size = 8
|
||||
device = 'gpu' if torch.cuda.is_available() else 'cpu'
|
||||
texts = []
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
num_samples = 0
|
||||
with open(manifest_path, 'r') as m:
|
||||
num_samples = len(m.readlines())
|
||||
|
||||
dataset = AudioToCharDALIDataset(
|
||||
manifest_filepath=manifest_path,
|
||||
audio_tar_filepaths=audio_tar_filepaths,
|
||||
audio_tar_index_filepaths=audio_tar_index_filepaths,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
labels=self.labels,
|
||||
max_duration=16.0,
|
||||
parser='en',
|
||||
shuffle=False,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
original_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_chars(transcript, transcripts_length, mapping=self.labels)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
original_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
# Assert transcripts are correct
|
||||
for text, og_transcript in zip(texts, original_transcripts):
|
||||
assert text == og_transcript
|
||||
|
||||
dataset = AudioToCharDALIDataset(
|
||||
manifest_filepath=manifest_path, # f.name,
|
||||
audio_tar_filepaths=audio_tar_filepaths,
|
||||
audio_tar_index_filepaths=audio_tar_index_filepaths,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
labels=self.labels,
|
||||
max_duration=16.0,
|
||||
parser='en',
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
assert len(dataset) == (num_samples // batch_size) # num batches
|
||||
count = 0
|
||||
shuffled_transcripts = []
|
||||
for batch in dataset:
|
||||
transcripts = batch[2] # transcript index in DALIOutputs
|
||||
transcripts_lengths = batch[3] # transcript length index in DALIOutputs
|
||||
transcripts = [
|
||||
decode_chars(transcript, transcripts_length, mapping=self.labels)
|
||||
for transcript, transcripts_length in zip(transcripts, transcripts_lengths)
|
||||
]
|
||||
shuffled_transcripts.extend(transcripts)
|
||||
count += len(transcripts)
|
||||
assert count == num_samples
|
||||
|
||||
samples_changed = 0
|
||||
for orig, shuffled in zip(original_transcripts, shuffled_transcripts):
|
||||
if orig != shuffled:
|
||||
samples_changed += 1
|
||||
assert samples_changed > 1 # assume after shuffling at least 1 sample was displaced
|
||||
|
||||
for og_transcript, shuffled_transcript in zip(sorted(original_transcripts), sorted(shuffled_transcripts)):
|
||||
assert og_transcript == shuffled_transcript
|
||||
|
||||
@pytest.mark.skipif(not HAVE_DALI, reason="NVIDIA DALI is not installed or incompatible version")
|
||||
@pytest.mark.unit
|
||||
def test_dali_tarred_char_vs_ref_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/tarred_audio_manifest.json'))
|
||||
audio_tar_filepaths = [
|
||||
os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{idx}.tar')) for idx in range(2)
|
||||
]
|
||||
audio_tar_index_filepaths = [
|
||||
os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/dali_index/audio_{idx}.index'))
|
||||
for idx in range(2)
|
||||
]
|
||||
|
||||
batch_size = 8
|
||||
texts = []
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as f:
|
||||
num_samples = 0
|
||||
with open(manifest_path, 'r') as m:
|
||||
for ix, line in enumerate(m):
|
||||
data = json.loads(line)
|
||||
texts.append(data['text'])
|
||||
num_samples = ix
|
||||
|
||||
preprocessor = {
|
||||
'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
'dither': 0.0,
|
||||
}
|
||||
preprocessor_cfg = DictConfig(preprocessor)
|
||||
|
||||
dataset_cfg = {
|
||||
'manifest_filepath': f.name,
|
||||
'tarred_audio_filepaths': audio_tar_filepaths,
|
||||
'tarred_audio_index_filepaths': audio_tar_index_filepaths,
|
||||
'sample_rate': 16000,
|
||||
'labels': self.labels,
|
||||
'batch_size': batch_size,
|
||||
'trim_silence': False,
|
||||
'max_duration': 16.7,
|
||||
'shuffle': False,
|
||||
'is_tarred': False,
|
||||
}
|
||||
dali_dataset = audio_to_text_dataset.get_dali_char_dataset(
|
||||
config=dataset_cfg,
|
||||
shuffle=False,
|
||||
device_id=0,
|
||||
global_rank=0,
|
||||
world_size=1,
|
||||
preprocessor_cfg=preprocessor_cfg,
|
||||
)
|
||||
ref_dataset = audio_to_text_dataset.get_tarred_dataset(
|
||||
config=dataset_cfg, shuffle_n=0, global_rank=0, world_size=1
|
||||
)
|
||||
ref_dataloader = DataLoader(
|
||||
dataset=ref_dataset,
|
||||
batch_size=batch_size,
|
||||
collate_fn=ref_dataset.collate_fn,
|
||||
drop_last=False,
|
||||
shuffle=False,
|
||||
num_workers=0,
|
||||
pin_memory=False,
|
||||
)
|
||||
ref_preprocessor = EncDecCTCModel.from_config_dict(preprocessor_cfg)
|
||||
|
||||
for ref_data, dali_data in zip(ref_dataloader, dali_dataset):
|
||||
ref_audio, ref_audio_len, _, _ = ref_data
|
||||
ref_features, ref_features_len = ref_preprocessor(input_signal=ref_audio, length=ref_audio_len)
|
||||
|
||||
dali_features, dali_features_len, _, _ = dali_data
|
||||
|
||||
a = ref_features.cpu().numpy()[:, :, :ref_features_len]
|
||||
b = dali_features.cpu().numpy()[:, :, :dali_features_len]
|
||||
|
||||
err = np.abs(a - b)
|
||||
assert np.mean(err) < 0.0001
|
||||
assert np.max(err) < 0.01
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feature_to_text_char_dataset(self):
|
||||
num_samples = 5
|
||||
golden_feat_shape = (80, 5)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
for i in range(num_samples):
|
||||
feat_file = os.path.join(tmpdir, f"feat_{i}.pt")
|
||||
torch.save(torch.randn(80, 5), feat_file)
|
||||
entry = {'audio_filepath': "", 'feature_file': feat_file, 'duration': 100000, "text": "a b c"}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = FeatureToCharDataset(manifest_path, labels=self.labels)
|
||||
cnt = 0
|
||||
for item in dataset:
|
||||
cnt += 1
|
||||
feat = item[0]
|
||||
token_len = item[3]
|
||||
assert feat.shape == golden_feat_shape
|
||||
assert torch.equal(token_len, torch.tensor(5))
|
||||
assert cnt == num_samples
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feature_to_text_bpe_dataset(self, test_data_dir):
|
||||
num_samples = 5
|
||||
golden_feat_shape = (80, 5)
|
||||
tokenizer_path = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
tokenizer = tokenizers.AutoTokenizer(pretrained_model_name='bert-base-cased', vocab_file=tokenizer_path)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
for i in range(num_samples):
|
||||
feat_file = os.path.join(tmpdir, f"feat_{i}.pt")
|
||||
torch.save(torch.randn(80, 5), feat_file)
|
||||
entry = {'audio_filepath': "", 'feature_file': feat_file, 'duration': 100000, "text": "a b c"}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = FeatureToBPEDataset(manifest_path, tokenizer=tokenizer)
|
||||
cnt = 0
|
||||
for item in dataset:
|
||||
cnt += 1
|
||||
feat = item[0]
|
||||
token_len = item[3]
|
||||
assert feat.shape == golden_feat_shape
|
||||
assert torch.equal(token_len, torch.tensor(5))
|
||||
assert cnt == num_samples
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feature_with_rttm_to_text_char_dataset(self):
|
||||
num_samples = 2
|
||||
golden_feat_shape = (80, 10)
|
||||
sample = torch.ones(80, 10)
|
||||
masked_sample = sample * FeatureToCharDataset.ZERO_LEVEL_SPEC_DB_VAL
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
feat_file = os.path.join(tmpdir, f"feat_0.pt")
|
||||
torch.save(sample, feat_file)
|
||||
|
||||
rttm_file = os.path.join(tmpdir, f"rttm_0.rttm")
|
||||
with open(rttm_file, "w") as fout:
|
||||
fout.write(f"SPEAKER <NA> 1 0 1 <NA> <NA> speech <NA> <NA>\n")
|
||||
|
||||
entry = {
|
||||
'audio_filepath': "",
|
||||
'feature_file': feat_file,
|
||||
'rttm_file': rttm_file,
|
||||
'duration': 100000,
|
||||
"text": "a b c",
|
||||
}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
# second sample where all frames are not masked
|
||||
feat_file = os.path.join(tmpdir, f"feat_1.pt")
|
||||
torch.save(sample, feat_file)
|
||||
|
||||
rttm_file = os.path.join(tmpdir, f"rttm_1.rttm")
|
||||
with open(rttm_file, "w") as fout:
|
||||
fout.write(f"SPEAKER <NA> 1 0 0 <NA> <NA> speech <NA> <NA>\n")
|
||||
|
||||
entry = {
|
||||
'audio_filepath': "",
|
||||
'feature_file': feat_file,
|
||||
'rttm_file': rttm_file,
|
||||
'duration': 100000,
|
||||
"text": "a b c",
|
||||
}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = FeatureToCharDataset(manifest_path, labels=self.labels, normalize=None, use_rttm=True)
|
||||
cnt = 0
|
||||
for item in dataset:
|
||||
cnt += 1
|
||||
feat = item[0]
|
||||
token_len = item[3]
|
||||
assert feat.shape == golden_feat_shape
|
||||
assert torch.equal(token_len, torch.tensor(5))
|
||||
|
||||
if cnt == 1:
|
||||
assert torch.equal(feat, sample)
|
||||
else:
|
||||
assert torch.equal(feat, masked_sample)
|
||||
|
||||
assert cnt == num_samples
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feature_with_rttm_to_text_bpe_dataset(self, test_data_dir):
|
||||
tokenizer_path = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
tokenizer = tokenizers.AutoTokenizer(pretrained_model_name='bert-base-cased', vocab_file=tokenizer_path)
|
||||
num_samples = 2
|
||||
golden_feat_shape = (80, 10)
|
||||
sample = torch.ones(80, 10)
|
||||
masked_sample = sample * FeatureToCharDataset.ZERO_LEVEL_SPEC_DB_VAL
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
feat_file = os.path.join(tmpdir, f"feat_0.pt")
|
||||
torch.save(sample, feat_file)
|
||||
|
||||
rttm_file = os.path.join(tmpdir, f"rttm_0.rttm")
|
||||
with open(rttm_file, "w") as fout:
|
||||
fout.write(f"SPEAKER <NA> 1 0 1 <NA> <NA> speech <NA> <NA>\n")
|
||||
|
||||
entry = {
|
||||
'audio_filepath': "",
|
||||
'feature_file': feat_file,
|
||||
'rttm_file': rttm_file,
|
||||
'duration': 100000,
|
||||
"text": "a b c",
|
||||
}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
# second sample where all frames are not masked
|
||||
feat_file = os.path.join(tmpdir, f"feat_1.pt")
|
||||
torch.save(sample, feat_file)
|
||||
|
||||
rttm_file = os.path.join(tmpdir, f"rttm_1.rttm")
|
||||
with open(rttm_file, "w") as fout:
|
||||
fout.write(f"SPEAKER <NA> 1 0 0 <NA> <NA> speech <NA> <NA>\n")
|
||||
|
||||
entry = {
|
||||
'audio_filepath': "",
|
||||
'feature_file': feat_file,
|
||||
'rttm_file': rttm_file,
|
||||
'duration': 100000,
|
||||
"text": "a b c",
|
||||
}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = FeatureToBPEDataset(manifest_path, tokenizer=tokenizer, normalize=None, use_rttm=True)
|
||||
cnt = 0
|
||||
for item in dataset:
|
||||
cnt += 1
|
||||
feat = item[0]
|
||||
token_len = item[3]
|
||||
assert feat.shape == golden_feat_shape
|
||||
assert torch.equal(token_len, torch.tensor(5))
|
||||
|
||||
if cnt == 1:
|
||||
assert torch.equal(feat, sample)
|
||||
else:
|
||||
assert torch.equal(feat, masked_sample)
|
||||
|
||||
assert cnt == num_samples
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize('cache_audio', [False, True])
|
||||
def test_cache_datastore_manifests(self, cache_audio: bool):
|
||||
"""Test caching of manifest and audio files."""
|
||||
# Data setup
|
||||
random_seed = 42
|
||||
sample_rate = 16000
|
||||
num_examples = 10
|
||||
num_manifests = 2
|
||||
data_duration = 1.0
|
||||
|
||||
# Generate random signals
|
||||
_rng = np.random.default_rng(seed=random_seed)
|
||||
|
||||
# Input and target signals have the same duration
|
||||
data_duration_samples = int(data_duration * sample_rate)
|
||||
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
test_store_dir = os.path.join(test_dir, 'store')
|
||||
os.mkdir(test_store_dir)
|
||||
|
||||
# Prepare metadata and audio files
|
||||
manifest_filepaths = []
|
||||
audio_files = []
|
||||
for m in range(num_manifests):
|
||||
manifest_dir = os.path.join(test_store_dir, f'manifest_{m}')
|
||||
os.mkdir(manifest_dir)
|
||||
manifest_filepath = os.path.join(manifest_dir, 'manifest.json')
|
||||
|
||||
metadata = []
|
||||
data = _rng.uniform(low=-0.5, high=0.5, size=(data_duration_samples, num_examples))
|
||||
for n in range(num_examples):
|
||||
audio_filepath = f'manifest_{m}_audio_{n:02d}.wav'
|
||||
audio_file = os.path.join(manifest_dir, audio_filepath)
|
||||
# Write audio file
|
||||
sf.write(audio_file, data[:, n], sample_rate, 'float')
|
||||
# Update metadata
|
||||
metadata.append(
|
||||
{
|
||||
'audio_filepath': audio_filepath,
|
||||
'duration': data_duration,
|
||||
'text': f'text for example {n:02d}',
|
||||
}
|
||||
)
|
||||
# Update audio files
|
||||
audio_files.append(audio_file)
|
||||
|
||||
# Save manifest
|
||||
write_manifest(manifest_filepath, metadata)
|
||||
manifest_filepaths.append(manifest_filepath)
|
||||
|
||||
# Cache location
|
||||
test_cache_dir = os.path.join(test_dir, 'cache')
|
||||
|
||||
# Instead of using AIS, copy object from store dir to cache dir
|
||||
def fake_get(self):
|
||||
# Object path relative to store path
|
||||
object_path = os.path.relpath(self.store_path, start=test_store_dir)
|
||||
# Copy to fake local path
|
||||
self._local_path = os.path.join(test_cache_dir, object_path)
|
||||
os.makedirs(os.path.dirname(self.local_path), exist_ok=True)
|
||||
shutil.copy(self.store_path, self.local_path)
|
||||
# Return path as in the original get
|
||||
return self.local_path
|
||||
|
||||
with (
|
||||
mock.patch('nemo.collections.asr.data.audio_to_text.is_datastore_path', lambda x: True),
|
||||
mock.patch.object(DataStoreObject, 'get', fake_get),
|
||||
):
|
||||
# Use a single worker for this test to avoid failure with mock & multiprocessing (#5607)
|
||||
cache_datastore_manifests(manifest_filepaths, cache_audio=cache_audio, num_workers=1)
|
||||
|
||||
# Manifests need to be compared
|
||||
store_files_to_compare = manifest_filepaths
|
||||
if cache_audio:
|
||||
# Audio needs to be compared
|
||||
store_files_to_compare += audio_files
|
||||
|
||||
# Compare files
|
||||
for f_store in store_files_to_compare:
|
||||
f_cache = os.path.join(test_cache_dir, os.path.relpath(f_store, test_store_dir))
|
||||
assert filecmp.cmp(f_store, f_cache, shallow=False), f'Files {f_store} and {f_cache} do not match.'
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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 typing import List
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from nemo.collections.asr.parts.utils.eou_utils import EOUResult, cal_eou_metrics_from_frame_labels
|
||||
|
||||
|
||||
def make_eou_frame_labels(duration: float, eou_time: float, frame_len_in_secs: float = 0.08) -> List[float]:
|
||||
"""
|
||||
Make EOU frame labels.
|
||||
Args:
|
||||
duration (float): Duration of the audio in seconds.
|
||||
eou_time (float): Time of the EOU in seconds.
|
||||
frame_len_in_secs (float): Length of each frame in seconds.
|
||||
Returns:
|
||||
List[float]: List of EOU frame labels.
|
||||
"""
|
||||
if eou_time < 0 or eou_time > duration:
|
||||
raise ValueError(f"EOU time ({eou_time}) is out of range for duration ({duration}).")
|
||||
|
||||
labels = [0] * int(np.ceil(duration / frame_len_in_secs) + 1)
|
||||
labels[int(np.ceil(eou_time / frame_len_in_secs))] = 1
|
||||
return labels
|
||||
|
||||
|
||||
class TestEOUMetrics:
|
||||
@pytest.mark.unit
|
||||
def test_cal_eou_metrics_from_frame_labels(self):
|
||||
duration = 1.6
|
||||
eou_time = 0.64
|
||||
frame_len_in_secs = 0.08
|
||||
ref_labels = make_eou_frame_labels(duration, eou_time, frame_len_in_secs)
|
||||
|
||||
# Test case 1: Early cutoff
|
||||
pred_eou_time = 0.32
|
||||
preds = make_eou_frame_labels(duration, pred_eou_time, frame_len_in_secs)
|
||||
eou_metrics: EOUResult = cal_eou_metrics_from_frame_labels(
|
||||
prediction=preds, reference=ref_labels, frame_len_in_secs=frame_len_in_secs
|
||||
)
|
||||
assert eou_metrics.true_positives == 0
|
||||
assert eou_metrics.false_positives == 1
|
||||
assert eou_metrics.false_negatives == 0
|
||||
assert eou_metrics.num_utterances == 1
|
||||
assert eou_metrics.num_predictions == 1
|
||||
assert eou_metrics.missing == 0
|
||||
assert eou_metrics.latency == []
|
||||
assert np.isclose(eou_metrics.early_cutoff, [0.32])
|
||||
|
||||
# Test case 2: Latency
|
||||
pred_eou_time = 0.96
|
||||
preds = make_eou_frame_labels(duration, pred_eou_time, frame_len_in_secs)
|
||||
eou_metrics: EOUResult = cal_eou_metrics_from_frame_labels(
|
||||
prediction=preds, reference=ref_labels, frame_len_in_secs=frame_len_in_secs
|
||||
)
|
||||
assert eou_metrics.true_positives == 0
|
||||
assert eou_metrics.false_positives == 0
|
||||
assert eou_metrics.false_negatives == 1
|
||||
assert eou_metrics.num_utterances == 1
|
||||
assert eou_metrics.num_predictions == 1
|
||||
assert eou_metrics.missing == 0
|
||||
assert np.isclose(eou_metrics.latency, [0.32])
|
||||
assert eou_metrics.early_cutoff == []
|
||||
|
||||
# Test case 3: miss detection
|
||||
preds = [0] * len(ref_labels)
|
||||
eou_metrics: EOUResult = cal_eou_metrics_from_frame_labels(
|
||||
prediction=preds, reference=ref_labels, frame_len_in_secs=frame_len_in_secs
|
||||
)
|
||||
assert eou_metrics.true_positives == 0
|
||||
assert eou_metrics.false_positives == 0
|
||||
assert eou_metrics.false_negatives == 1
|
||||
assert eou_metrics.num_utterances == 1
|
||||
assert eou_metrics.num_predictions == 0
|
||||
assert eou_metrics.missing == 1
|
||||
assert eou_metrics.latency == []
|
||||
assert eou_metrics.early_cutoff == []
|
||||
@@ -0,0 +1,628 @@
|
||||
# Copyright (c) 2020, 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
|
||||
import tempfile
|
||||
|
||||
import onnx
|
||||
import pytest
|
||||
import torch.cuda
|
||||
from omegaconf import DictConfig, ListConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.models import (
|
||||
EncDecClassificationModel,
|
||||
EncDecCTCModel,
|
||||
EncDecRNNTModel,
|
||||
EncDecSpeakerLabelModel,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils import asr_module_utils
|
||||
from nemo.collections.common.parts.adapter_modules import LinearAdapterConfig
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
class TestExportable:
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecCTCModel_export_to_onnx(self):
|
||||
model_config = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(self.preprocessor),
|
||||
'encoder': DictConfig(self.encoder_dict),
|
||||
'decoder': DictConfig(self.decoder_dict),
|
||||
}
|
||||
)
|
||||
model = EncDecCTCModel(cfg=model_config).cuda()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, 'qn.onnx')
|
||||
model.export(
|
||||
output=filename,
|
||||
check_trace=True,
|
||||
)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.output[0].name == 'logprobs'
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecClassificationModel_export_to_onnx(self, speech_classification_model):
|
||||
model = speech_classification_model.cuda()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, 'edc.onnx')
|
||||
model.export(
|
||||
output=filename,
|
||||
check_trace=True,
|
||||
)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.output[0].name == 'logits'
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecSpeakerLabelModel_export_to_onnx(self, speaker_label_model):
|
||||
model = speaker_label_model.cuda()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, 'sl.onnx')
|
||||
model.export(output=filename)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.output[0].name == 'logits'
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecCitrinetModel_export_to_onnx(self, citrinet_model):
|
||||
model = citrinet_model.cuda()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, 'citri.onnx')
|
||||
model.export(output=filename)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.input[1].name == 'length'
|
||||
assert onnx_model.graph.output[0].name == 'logprobs'
|
||||
|
||||
@pytest.mark.pleasefixme
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_ConformerModel_export_to_onnx(self, conformer_model):
|
||||
model = conformer_model.cuda()
|
||||
with tempfile.TemporaryDirectory() as tmpdir, torch.cuda.amp.autocast():
|
||||
filename = os.path.join(tmpdir, 'conf.onnx')
|
||||
device = next(model.parameters()).device
|
||||
input_example = torch.randn(4, model.encoder._feat_in, 777, device=device)
|
||||
input_example_length = torch.full(size=(input_example.shape[0],), fill_value=777, device=device)
|
||||
model.export(
|
||||
output=filename,
|
||||
input_example=tuple([input_example, input_example_length]),
|
||||
check_trace=True,
|
||||
)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecCitrinetModel_limited_SE_export_to_onnx(self, citrinet_model):
|
||||
model = citrinet_model.cuda()
|
||||
asr_module_utils.change_conv_asr_se_context_window(model, context_window=24, update_config=False)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir, torch.cuda.amp.autocast():
|
||||
filename = os.path.join(tmpdir, 'citri_se.onnx')
|
||||
model.export(
|
||||
output=filename,
|
||||
check_trace=True,
|
||||
)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.input[1].name == 'length'
|
||||
assert onnx_model.graph.output[0].name == 'logprobs'
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecRNNTModel_export_to_onnx(self, citrinet_rnnt_model):
|
||||
model = citrinet_rnnt_model.cuda()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fn = 'citri_rnnt.onnx'
|
||||
filename = os.path.join(tmpdir, fn)
|
||||
files, descr = model.export(output=filename, verbose=False)
|
||||
|
||||
encoder_filename = os.path.join(tmpdir, 'encoder-' + fn)
|
||||
assert files[0] == encoder_filename
|
||||
assert os.path.exists(encoder_filename)
|
||||
onnx_model = onnx.load(encoder_filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert len(onnx_model.graph.input) == 2
|
||||
assert len(onnx_model.graph.output) == 2
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.input[1].name == 'length'
|
||||
assert onnx_model.graph.output[0].name == 'outputs'
|
||||
assert onnx_model.graph.output[1].name == 'encoded_lengths'
|
||||
|
||||
decoder_joint_filename = os.path.join(tmpdir, 'decoder_joint-' + fn)
|
||||
assert files[1] == decoder_joint_filename
|
||||
assert os.path.exists(decoder_joint_filename)
|
||||
onnx_model = onnx.load(decoder_joint_filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
|
||||
input_examples = model.decoder.input_example()
|
||||
assert type(input_examples[-1]) == tuple
|
||||
num_states = len(input_examples[-1])
|
||||
state_name = list(model.decoder.output_types.keys())[-1]
|
||||
|
||||
# enc_logits + (all decoder inputs - state tuple) + flattened state list
|
||||
assert len(onnx_model.graph.input) == (1 + (len(input_examples) - 1) + num_states)
|
||||
assert onnx_model.graph.input[0].name == 'encoder_outputs'
|
||||
assert onnx_model.graph.input[1].name == 'targets'
|
||||
assert onnx_model.graph.input[2].name == 'target_length'
|
||||
|
||||
if num_states > 0:
|
||||
for idx, ip in enumerate(onnx_model.graph.input[3:]):
|
||||
assert ip.name == "input_" + state_name + '_' + str(idx + 1)
|
||||
|
||||
assert len(onnx_model.graph.output) == (len(input_examples) - 1) + num_states
|
||||
assert onnx_model.graph.output[0].name == 'outputs'
|
||||
assert onnx_model.graph.output[1].name == 'prednet_lengths'
|
||||
|
||||
if num_states > 0:
|
||||
for idx, op in enumerate(onnx_model.graph.output[2:]):
|
||||
assert op.name == "output_" + state_name + '_' + str(idx + 1)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecRNNTModel_export_to_ts(self, citrinet_rnnt_model):
|
||||
model = citrinet_rnnt_model.cuda()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fn = 'citri_rnnt.ts'
|
||||
filename = os.path.join(tmpdir, fn)
|
||||
# Perform export + test with the input examples of the RNNT model.
|
||||
files, descr = model.export(output=filename, verbose=False, check_trace=True)
|
||||
|
||||
encoder_filename = os.path.join(tmpdir, 'encoder-' + fn)
|
||||
assert files[0] == encoder_filename
|
||||
assert os.path.exists(encoder_filename)
|
||||
|
||||
ts_encoder = torch.jit.load(encoder_filename)
|
||||
assert ts_encoder is not None
|
||||
|
||||
arguments = ts_encoder.forward.schema.arguments[1:] # First value is `self`
|
||||
assert arguments[0].name == 'audio_signal'
|
||||
assert arguments[1].name == 'length'
|
||||
|
||||
decoder_joint_filename = os.path.join(tmpdir, 'decoder_joint-' + fn)
|
||||
assert files[1] == decoder_joint_filename
|
||||
assert os.path.exists(decoder_joint_filename)
|
||||
|
||||
ts_decoder_joint = torch.jit.load(decoder_joint_filename)
|
||||
assert ts_decoder_joint is not None
|
||||
|
||||
ts_decoder_joint_args = ts_decoder_joint.forward.schema.arguments[1:] # First value is self
|
||||
|
||||
input_examples = model.decoder.input_example()
|
||||
assert type(input_examples[-1]) == tuple
|
||||
num_states = len(input_examples[-1])
|
||||
state_name = list(model.decoder.output_types.keys())[-1]
|
||||
|
||||
# enc_logits + (all decoder inputs - state tuple) + flattened state list
|
||||
assert len(ts_decoder_joint_args) == (1 + (len(input_examples) - 1) + num_states)
|
||||
assert ts_decoder_joint_args[0].name == 'encoder_outputs'
|
||||
assert ts_decoder_joint_args[1].name == 'targets'
|
||||
assert ts_decoder_joint_args[2].name == 'target_length'
|
||||
|
||||
if num_states > 0:
|
||||
for idx, ip in enumerate(ts_decoder_joint_args[3:]):
|
||||
assert ip.name == "input_" + state_name + '_' + str(idx + 1)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_EncDecCTCModel_adapted_export_to_onnx(self):
|
||||
model_config = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(self.preprocessor),
|
||||
'encoder': DictConfig(self.encoder_dict),
|
||||
'decoder': DictConfig(self.decoder_dict),
|
||||
}
|
||||
)
|
||||
|
||||
# support adapter in encoder
|
||||
model_config.encoder.cls = model_config.encoder.cls + 'Adapter' # ConvASREncoderAdapter
|
||||
|
||||
# load model
|
||||
model = EncDecCTCModel(cfg=model_config)
|
||||
|
||||
# add adapter
|
||||
adapter_cfg = OmegaConf.structured(
|
||||
LinearAdapterConfig(in_features=model_config.encoder.params.jasper[0].filters, dim=32)
|
||||
)
|
||||
model.add_adapter('temp', cfg=adapter_cfg)
|
||||
|
||||
model = model.cuda()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, 'qn.onnx')
|
||||
model.export(
|
||||
output=filename,
|
||||
check_trace=True,
|
||||
)
|
||||
onnx_model = onnx.load(filename)
|
||||
onnx.checker.check_model(onnx_model, full_check=True) # throws when failed
|
||||
assert onnx_model.graph.input[0].name == 'audio_signal'
|
||||
assert onnx_model.graph.output[0].name == 'logprobs'
|
||||
|
||||
def setup_method(self):
|
||||
self.preprocessor = {
|
||||
'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
'params': dict({}),
|
||||
}
|
||||
|
||||
self.encoder_dict = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 1024,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
self.decoder_dict = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'params': {
|
||||
'feat_in': 1024,
|
||||
'num_classes': 28,
|
||||
'vocabulary': [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def speech_classification_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 32,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASRDecoderClassification',
|
||||
'params': {
|
||||
'feat_in': 32,
|
||||
'num_classes': 30,
|
||||
},
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'labels': ListConfig(["dummy_cls_{}".format(i + 1) for i in range(30)]),
|
||||
}
|
||||
)
|
||||
model = EncDecClassificationModel(cfg=modelConfig)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def speaker_label_model():
|
||||
preprocessor = {
|
||||
'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': False,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.SpeakerDecoder',
|
||||
'feat_in': 512,
|
||||
'num_classes': 2,
|
||||
'pool_mode': 'attention',
|
||||
'emb_sizes': [1024],
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
speaker_model = EncDecSpeakerLabelModel(cfg=modelConfig)
|
||||
return speaker_model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def citrinet_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 80,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 1,
|
||||
'kernel': [5],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 5,
|
||||
'kernel': [11],
|
||||
'stride': [2],
|
||||
'dilation': [1],
|
||||
'dropout': 0.1,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
'stride_last': True,
|
||||
'residual_mode': 'stride_add',
|
||||
},
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 5,
|
||||
'kernel': [13],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.1,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': 640,
|
||||
'repeat': 1,
|
||||
'kernel': [41],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'params': {'feat_in': 640, 'num_classes': 1024, 'vocabulary': list(chr(i % 28) for i in range(0, 1024))},
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
citri_model = EncDecCTCModel(cfg=modelConfig)
|
||||
return citri_model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def citrinet_rnnt_model():
|
||||
labels = list(chr(i % 28) for i in range(0, 1024))
|
||||
model_defaults = {'enc_hidden': 640, 'pred_hidden': 256, 'joint_hidden': 320}
|
||||
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'feat_in': 80,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 1,
|
||||
'kernel': [5],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 5,
|
||||
'kernel': [11],
|
||||
'stride': [2],
|
||||
'dilation': [1],
|
||||
'dropout': 0.1,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
'stride_last': True,
|
||||
'residual_mode': 'stride_add',
|
||||
},
|
||||
{
|
||||
'filters': 512,
|
||||
'repeat': 5,
|
||||
'kernel': [13],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.1,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': 640,
|
||||
'repeat': 1,
|
||||
'kernel': [41],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': True,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {'pred_hidden': 256, 'pred_rnn_layers': 1, 'dropout': 0.0},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'fuse_loss_wer': False,
|
||||
'jointnet': {'joint_hidden': 320, 'activation': 'relu', 'dropout': 0.0},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 5}}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'labels': labels,
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'decoding': DictConfig(decoding),
|
||||
}
|
||||
)
|
||||
citri_model = EncDecRNNTModel(cfg=modelConfig)
|
||||
return citri_model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conformer_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'params': {
|
||||
'feat_in': 80,
|
||||
'feat_out': -1,
|
||||
'n_layers': 2,
|
||||
'd_model': 256,
|
||||
'subsampling': 'striding',
|
||||
'subsampling_factor': 4,
|
||||
'subsampling_conv_channels': 512,
|
||||
'reduction': None,
|
||||
'reduction_position': None,
|
||||
'reduction_factor': 1,
|
||||
'ff_expansion_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 8,
|
||||
'att_context_size': [-1, -1],
|
||||
'xscaling': True,
|
||||
'untie_biases': True,
|
||||
'pos_emb_max_len': 500,
|
||||
'conv_kernel_size': 31,
|
||||
'dropout': 0.1,
|
||||
'dropout_pre_encoder': 0.1,
|
||||
'dropout_emb': 0.0,
|
||||
'dropout_att': 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'params': {'feat_in': 256, 'num_classes': 1024, 'vocabulary': list(chr(i % 28) for i in range(0, 1024))},
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{'preprocessor': DictConfig(preprocessor), 'encoder': DictConfig(encoder), 'decoder': DictConfig(decoder)}
|
||||
)
|
||||
conformer_model = EncDecCTCModel(cfg=modelConfig)
|
||||
return conformer_model
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2020, 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 librosa
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.preprocessing.features import FilterbankFeatures
|
||||
|
||||
|
||||
class TestFilterbankFeatures:
|
||||
@pytest.mark.unit
|
||||
def test_seq_len(self):
|
||||
fb_module = FilterbankFeatures(exact_pad=False, pad_to=1)
|
||||
test_1 = torch.randn(1, 800)
|
||||
test_1_len = torch.tensor([800])
|
||||
fb_spec, fb_len = fb_module(test_1, test_1_len)
|
||||
assert fb_spec.shape[2] - 1 == fb_len[0], f"{fb_spec.shape} != {fb_len}"
|
||||
librosa_spec = librosa.stft(test_1.cpu().detach().numpy().squeeze(), n_fft=512, hop_length=160, win_length=320)
|
||||
|
||||
assert librosa_spec.shape[1] == fb_spec.shape[2], f"{librosa_spec.shape} != {fb_spec.shape}"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_random_stft_sizes(self):
|
||||
for _ in range(5):
|
||||
nfft = 2 ** np.random.randint(7, 12)
|
||||
window_size = np.random.randint(100, nfft)
|
||||
hop_size = np.random.randint(64, window_size)
|
||||
fb_module = FilterbankFeatures(
|
||||
exact_pad=False,
|
||||
pad_to=1,
|
||||
n_fft=nfft,
|
||||
n_window_size=window_size,
|
||||
n_window_stride=hop_size,
|
||||
normalize=False,
|
||||
)
|
||||
audio_length = np.random.randint(nfft, 2**16)
|
||||
test_1 = torch.randn(1, audio_length)
|
||||
test_1_len = torch.tensor([audio_length])
|
||||
fb_spec, fb_len = fb_module(test_1, test_1_len)
|
||||
assert (
|
||||
fb_spec.shape[2] - 1 == fb_len[0]
|
||||
), f"{fb_spec.shape} != {fb_len}: {nfft}, {window_size}, {hop_size}, {audio_length}"
|
||||
|
||||
librosa_spec = librosa.stft(
|
||||
test_1.cpu().detach().numpy().squeeze(), n_fft=nfft, hop_length=hop_size, win_length=window_size
|
||||
)
|
||||
|
||||
assert (
|
||||
librosa_spec.shape[1] == fb_spec.shape[2]
|
||||
), f"{librosa_spec.shape} != {fb_spec.shape}: {nfft}, {window_size}, {hop_size}, {audio_length}"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_random_stft_sizes_exact_pad(self):
|
||||
for _ in range(5):
|
||||
nfft = 2 ** np.random.randint(7, 12)
|
||||
window_size = np.random.randint(100, nfft)
|
||||
hop_size = np.random.randint(64, window_size)
|
||||
if hop_size % 2 == 1:
|
||||
hop_size = hop_size - 1
|
||||
fb_module = FilterbankFeatures(
|
||||
exact_pad=True,
|
||||
pad_to=1,
|
||||
n_fft=nfft,
|
||||
n_window_size=window_size,
|
||||
n_window_stride=hop_size,
|
||||
normalize=False,
|
||||
)
|
||||
audio_length = np.random.randint(nfft, 2**16)
|
||||
test_1 = torch.randn(1, audio_length)
|
||||
test_1_len = torch.tensor([audio_length])
|
||||
fb_spec, fb_len = fb_module(test_1, test_1_len)
|
||||
assert (
|
||||
fb_spec.shape[2] - 1 == fb_len[0]
|
||||
), f"{fb_spec.shape} != {fb_len}: {nfft}, {window_size}, {hop_size}, {audio_length}"
|
||||
|
||||
test_2 = test_1.cpu().detach().numpy().squeeze()
|
||||
test_2 = np.pad(test_2, int((nfft - hop_size) // 2), mode="reflect")
|
||||
librosa_spec = librosa.stft(
|
||||
test_2,
|
||||
n_fft=nfft,
|
||||
hop_length=hop_size,
|
||||
win_length=window_size,
|
||||
center=False,
|
||||
)
|
||||
|
||||
assert (
|
||||
fb_spec.shape[2] == librosa_spec.shape[1]
|
||||
), f"{fb_spec.shape} != {librosa_spec.shape}: {nfft}, {window_size}, {hop_size}, {audio_length}"
|
||||
|
||||
assert (
|
||||
fb_spec.shape[2] == audio_length // hop_size
|
||||
), f"{fb_spec.shape}, {nfft}, {window_size}, {hop_size}, {audio_length}, {audio_length // hop_size}"
|
||||
@@ -0,0 +1,360 @@
|
||||
# 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 os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.asr.models.hybrid_rnnt_ctc_bpe_models import EncDecHybridRNNTCTCBPEModel
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCBPEDecoding, CTCBPEDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hybrid_asr_model(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
model_defaults = {'enc_hidden': 1024, 'pred_hidden': 64}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': model_defaults['pred_hidden'],
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {
|
||||
'joint_hidden': 32,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
aux_ctc = {
|
||||
'ctc_loss_weight': 0.3,
|
||||
'use_cer': False,
|
||||
'ctc_reduction': 'mean_batch',
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 1024,
|
||||
'num_classes': -2,
|
||||
'vocabulary': None,
|
||||
},
|
||||
'decoding': DictConfig(CTCBPEDecodingConfig),
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
'aux_ctc': DictConfig(aux_ctc),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecHybridRNNTCTCBPEModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecHybridRNNTCTCBPEModel:
|
||||
@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_constructor(self, hybrid_asr_model):
|
||||
hybrid_asr_model.train()
|
||||
# TODO: make proper config and assert correct number of weights
|
||||
# Check to/from config_dict:
|
||||
confdict = hybrid_asr_model.to_config_dict()
|
||||
instance2 = EncDecHybridRNNTCTCBPEModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecHybridRNNTCTCBPEModel)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, hybrid_asr_model):
|
||||
hybrid_asr_model = hybrid_asr_model.eval()
|
||||
|
||||
hybrid_asr_model.preprocessor.featurizer.dither = 0.0
|
||||
hybrid_asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
hybrid_asr_model.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = hybrid_asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = hybrid_asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, hybrid_asr_model):
|
||||
hybrid_asr_model = hybrid_asr_model.eval()
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=1, with_data=True)
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=hybrid_asr_model.tokenizer, return_cuts=True)
|
||||
batch = dataset[cuts]
|
||||
outputs = hybrid_asr_model.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][0], MonoCut)
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact(self, hybrid_asr_model):
|
||||
hybrid_asr_model.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = os.path.join(tmp_dir, 'rnnt_bpe.nemo')
|
||||
hybrid_asr_model.save_to(path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModel.restore_from(path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model))
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_spe(self, hybrid_asr_model, test_data_dir):
|
||||
hybrid_asr_model.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
hybrid_asr_model.change_vocabulary(new_tokenizer_dir=tokenizer_dir, new_tokenizer_type='bpe')
|
||||
|
||||
save_path = os.path.join(tmpdir, 'ctc_bpe.nemo')
|
||||
hybrid_asr_model.train()
|
||||
hybrid_asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModel.restore_from(save_path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.SentencePieceTokenizer)
|
||||
assert new_model.model_path.endswith('_tokenizer.model')
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
assert new_model.spe_vocab_path.endswith('_tokenizer.vocab')
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_agg(self, hybrid_asr_model, test_data_dir):
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
tok_en = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
# the below is really an english tokenizer but we pretend it is spanish
|
||||
tok_es = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
tcfg = DictConfig({"type": "agg", "langs": {"en": tok_en, "es": tok_es}})
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
hybrid_asr_model.change_vocabulary(new_tokenizer_dir=tcfg, new_tokenizer_type="agg")
|
||||
|
||||
save_path = os.path.join(tmpdir, "ctc_agg.nemo")
|
||||
hybrid_asr_model.train()
|
||||
hybrid_asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModel.restore_from(save_path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.AggregateTokenizer)
|
||||
|
||||
# should be double
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 264
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 264
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, test_data_dir, hybrid_asr_model):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
new_tokenizer_dir = os.path.join(tmpdir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
shutil.copy2(old_tokenizer_dir, new_tokenizer_dir)
|
||||
|
||||
nw1 = hybrid_asr_model.num_weights
|
||||
hybrid_asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
# No change
|
||||
assert nw1 == hybrid_asr_model.num_weights
|
||||
|
||||
with open(os.path.join(new_tokenizer_dir, 'vocab.txt'), 'a+') as f:
|
||||
f.write("!\n")
|
||||
f.write('$\n')
|
||||
f.write('@\n')
|
||||
|
||||
hybrid_asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
|
||||
# rnn embedding + joint + bias
|
||||
pred_embedding = 3 * (hybrid_asr_model.decoder.pred_hidden)
|
||||
joint_joint = 3 * (hybrid_asr_model.joint.joint_hidden + 1)
|
||||
ctc_decoder = 3 * (hybrid_asr_model.ctc_decoder._feat_in + 1)
|
||||
assert hybrid_asr_model.num_weights == (nw1 + (pred_embedding + joint_joint) + ctc_decoder)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, hybrid_asr_model):
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'tsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "tsd"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'alsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "alsd"
|
||||
|
||||
assert hybrid_asr_model.ctc_decoding is not None
|
||||
assert isinstance(hybrid_asr_model.ctc_decoding, CTCBPEDecoding)
|
||||
assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy_batch"
|
||||
assert hybrid_asr_model.ctc_decoding.preserve_alignments is False
|
||||
assert hybrid_asr_model.ctc_decoding.compute_timestamps is False
|
||||
|
||||
cfg = CTCBPEDecodingConfig(preserve_alignments=True, compute_timestamps=True)
|
||||
hybrid_asr_model.change_decoding_strategy(cfg, decoder_type="ctc")
|
||||
|
||||
assert hybrid_asr_model.ctc_decoding.preserve_alignments is True
|
||||
assert hybrid_asr_model.ctc_decoding.compute_timestamps is True
|
||||
assert hybrid_asr_model.cur_decoder == "ctc"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_type_change(self, hybrid_asr_model):
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model.cur_decoder == 'rnnt'
|
||||
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='ctc')
|
||||
assert isinstance(hybrid_asr_model.ctc_decoding, CTCBPEDecoding)
|
||||
assert hybrid_asr_model.cur_decoder == 'ctc'
|
||||
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model.cur_decoder == 'rnnt'
|
||||
@@ -0,0 +1,566 @@
|
||||
# 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
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models.hybrid_rnnt_ctc_bpe_models_prompt import EncDecHybridRNNTCTCBPEModelWithPrompt
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCBPEDecoding, CTCBPEDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hybrid_asr_model_with_prompt(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
model_defaults = {
|
||||
'enc_hidden': 1024,
|
||||
'pred_hidden': 640,
|
||||
'initialize_prompt_feature': True, # Enable prompt feature initialization
|
||||
'prompt_dictionary': {
|
||||
'en_US': 0,
|
||||
'es_ES': 1,
|
||||
'fr_FR': 2,
|
||||
'de_DE': 3,
|
||||
},
|
||||
}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': model_defaults['pred_hidden'],
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {
|
||||
'joint_hidden': 640,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
aux_ctc = {
|
||||
'ctc_loss_weight': 0.1,
|
||||
'use_cer': False,
|
||||
'ctc_reduction': 'mean_batch',
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 1024,
|
||||
'num_classes': -2,
|
||||
'vocabulary': None,
|
||||
},
|
||||
'decoding': DictConfig(CTCBPEDecodingConfig),
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
'aux_ctc': DictConfig(aux_ctc),
|
||||
'num_prompts': 128,
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecHybridRNNTCTCBPEModelWithPrompt(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecHybridRNNTCTCBPEModelWithPrompt:
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, hybrid_asr_model_with_prompt):
|
||||
hybrid_asr_model_with_prompt.train()
|
||||
# Check to/from config_dict:
|
||||
confdict = hybrid_asr_model_with_prompt.to_config_dict()
|
||||
instance2 = EncDecHybridRNNTCTCBPEModelWithPrompt.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecHybridRNNTCTCBPEModelWithPrompt)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward_with_prompt(self, hybrid_asr_model_with_prompt):
|
||||
"""Exercise the legacy 3D one-hot ``prompt`` forward path."""
|
||||
hybrid_asr_model_with_prompt = hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.dither = 0.0
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
hybrid_asr_model_with_prompt.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
# Calculate expected timesteps dynamically for the batch
|
||||
with torch.no_grad():
|
||||
# Process the entire batch to get the actual encoded timesteps
|
||||
batch_processed, batch_processed_len = hybrid_asr_model_with_prompt.preprocessor(
|
||||
input_signal=input_signal, length=length
|
||||
)
|
||||
# Run through encoder to get actual encoded length
|
||||
encoded_sample, encoded_len_sample = hybrid_asr_model_with_prompt.encoder(
|
||||
audio_signal=batch_processed, length=batch_processed_len
|
||||
)
|
||||
# Get the maximum encoded length for creating prompt tensor
|
||||
max_encoded_timesteps = encoded_sample.shape[2] # [B, D, T] format
|
||||
|
||||
# Create prompt tensor with the correct timesteps dimension
|
||||
prompt = torch.randn(size=(4, max_encoded_timesteps, hybrid_asr_model_with_prompt.num_prompts))
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal[i : i + 1],
|
||||
input_signal_length=length[i : i + 1],
|
||||
prompt=prompt[i : i + 1],
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt=prompt
|
||||
)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, hybrid_asr_model_with_prompt):
|
||||
"""Exercise the canonical 1D ``prompt_indices`` forward path (model builds the one-hot internally)."""
|
||||
hybrid_asr_model_with_prompt = hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.dither = 0.0
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
hybrid_asr_model_with_prompt.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
# 1D per-sample language ids
|
||||
prompt_indices = torch.tensor([0, 1, 2, 3], dtype=torch.long)
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal[i : i + 1],
|
||||
input_signal_length=length[i : i + 1],
|
||||
prompt_indices=prompt_indices[i : i + 1],
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt_indices=prompt_indices
|
||||
)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward_prompt_vs_indices_equivalence(self, hybrid_asr_model_with_prompt):
|
||||
"""A 3D one-hot ``prompt`` and the matching 1D ``prompt_indices`` must produce identical output."""
|
||||
hybrid_asr_model_with_prompt = hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.dither = 0.0
|
||||
hybrid_asr_model_with_prompt.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
prompt_indices = torch.tensor([0, 1, 2, 3], dtype=torch.long)
|
||||
|
||||
# Build a 3D one-hot prompt that matches what the model would build internally.
|
||||
with torch.no_grad():
|
||||
processed, processed_len = hybrid_asr_model_with_prompt.preprocessor(
|
||||
input_signal=input_signal, length=length
|
||||
)
|
||||
encoded_sample, _ = hybrid_asr_model_with_prompt.encoder(audio_signal=processed, length=processed_len)
|
||||
time_steps = encoded_sample.shape[2] # [B, D, T]
|
||||
|
||||
num_prompts = hybrid_asr_model_with_prompt.num_prompts
|
||||
prompt_one_hot = torch.zeros(4, time_steps, num_prompts)
|
||||
prompt_one_hot.scatter_(2, prompt_indices.view(4, 1, 1).expand(-1, time_steps, -1), 1.0)
|
||||
|
||||
with torch.no_grad():
|
||||
out_from_prompt, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt=prompt_one_hot
|
||||
)
|
||||
out_from_indices, _ = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt_indices=prompt_indices
|
||||
)
|
||||
|
||||
assert out_from_prompt.shape == out_from_indices.shape
|
||||
assert torch.max(torch.abs(out_from_prompt - out_from_indices)) <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step_with_prompt(self, hybrid_asr_model_with_prompt):
|
||||
"""Exercise the legacy ``.dim() == 3`` branch of predict_step (3D one-hot prompt in the batch)."""
|
||||
hybrid_asr_model_with_prompt = hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
# Create a simple batch manually
|
||||
batch_size = 1
|
||||
seq_len = 1600
|
||||
hidden_len = 200
|
||||
num_prompts = 128
|
||||
|
||||
# Create mock batch data
|
||||
audio_signal = torch.randn(batch_size, seq_len)
|
||||
audio_lengths = torch.tensor([seq_len])
|
||||
transcript = torch.randint(0, 10, (batch_size, 10))
|
||||
transcript_lengths = torch.tensor([10])
|
||||
prompt = torch.zeros(batch_size, hidden_len, num_prompts)
|
||||
prompt[0, :, 0] = 1 # Set first prompt to 1
|
||||
|
||||
batch = (audio_signal, audio_lengths, transcript, transcript_lengths, prompt)
|
||||
|
||||
outputs = hybrid_asr_model_with_prompt.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, hybrid_asr_model_with_prompt):
|
||||
"""Exercise the canonical ``.dim() == 1`` branch of predict_step (1D prompt_indices in the batch)."""
|
||||
hybrid_asr_model_with_prompt = hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
batch_size = 1
|
||||
seq_len = 1600
|
||||
|
||||
audio_signal = torch.randn(batch_size, seq_len)
|
||||
audio_lengths = torch.tensor([seq_len])
|
||||
transcript = torch.randint(0, 10, (batch_size, 10))
|
||||
transcript_lengths = torch.tensor([10])
|
||||
# 1D tensor -> hits the prompt_indices branch in predict_step.
|
||||
prompt_indices = torch.tensor([0], dtype=torch.long)
|
||||
|
||||
batch = (audio_signal, audio_lengths, transcript, transcript_lengths, prompt_indices)
|
||||
|
||||
outputs = hybrid_asr_model_with_prompt.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact(self, hybrid_asr_model_with_prompt):
|
||||
hybrid_asr_model_with_prompt.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = os.path.join(tmp_dir, 'rnnt_bpe_prompt.nemo')
|
||||
hybrid_asr_model_with_prompt.save_to(path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModelWithPrompt.restore_from(path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model_with_prompt))
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_spe(self, hybrid_asr_model_with_prompt, test_data_dir):
|
||||
hybrid_asr_model_with_prompt.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
hybrid_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=tokenizer_dir, new_tokenizer_type='bpe')
|
||||
|
||||
save_path = os.path.join(tmpdir, 'rnnt_bpe_prompt.nemo')
|
||||
hybrid_asr_model_with_prompt.train()
|
||||
hybrid_asr_model_with_prompt.save_to(save_path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModelWithPrompt.restore_from(save_path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model_with_prompt))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.SentencePieceTokenizer)
|
||||
assert new_model.model_path.endswith('_tokenizer.model')
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
assert new_model.spe_vocab_path.endswith('_tokenizer.vocab')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_agg(self, hybrid_asr_model_with_prompt, test_data_dir):
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
tok_en = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
# the below is really an english tokenizer but we pretend it is spanish
|
||||
tok_es = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
tcfg = DictConfig({"type": "agg", "langs": {"en": tok_en, "es": tok_es}})
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
hybrid_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=tcfg, new_tokenizer_type="agg")
|
||||
|
||||
save_path = os.path.join(tmpdir, "rnnt_agg_prompt.nemo")
|
||||
hybrid_asr_model_with_prompt.train()
|
||||
hybrid_asr_model_with_prompt.save_to(save_path)
|
||||
|
||||
new_model = EncDecHybridRNNTCTCBPEModelWithPrompt.restore_from(save_path)
|
||||
assert isinstance(new_model, type(hybrid_asr_model_with_prompt))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.AggregateTokenizer)
|
||||
|
||||
# Both source tokenizers are the same 132-token vocab; the AggregateTokenizer
|
||||
# deduplicates 10 shared control tokens, so total = 132 + (132 - 10) = 254.
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 264
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 264
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, test_data_dir, hybrid_asr_model_with_prompt):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
new_tokenizer_dir = os.path.join(tmpdir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
shutil.copy2(old_tokenizer_dir, new_tokenizer_dir)
|
||||
|
||||
nw1 = hybrid_asr_model_with_prompt.num_weights
|
||||
hybrid_asr_model_with_prompt.change_vocabulary(
|
||||
new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe'
|
||||
)
|
||||
# No change
|
||||
assert nw1 == hybrid_asr_model_with_prompt.num_weights
|
||||
|
||||
with open(os.path.join(new_tokenizer_dir, 'vocab.txt'), 'a+') as f:
|
||||
f.write("!\n")
|
||||
f.write('$\n')
|
||||
f.write('@\n')
|
||||
|
||||
hybrid_asr_model_with_prompt.change_vocabulary(
|
||||
new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe'
|
||||
)
|
||||
|
||||
# rnn embedding + joint + bias
|
||||
pred_embedding = 3 * (hybrid_asr_model_with_prompt.decoder.pred_hidden)
|
||||
joint_joint = 3 * (hybrid_asr_model_with_prompt.joint.joint_hidden + 1)
|
||||
ctc_decoder = 3 * (hybrid_asr_model_with_prompt.ctc_decoder._feat_in + 1)
|
||||
assert hybrid_asr_model_with_prompt.num_weights == (nw1 + (pred_embedding + joint_joint) + ctc_decoder)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, hybrid_asr_model_with_prompt):
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'tsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.decoding.decoding.search_type == "tsd"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'alsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.decoding.decoding.search_type == "alsd"
|
||||
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding is not None
|
||||
assert isinstance(hybrid_asr_model_with_prompt.ctc_decoding, CTCBPEDecoding)
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding.cfg.strategy == "greedy_batch"
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding.preserve_alignments is False
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding.compute_timestamps is False
|
||||
|
||||
cfg = CTCBPEDecodingConfig(preserve_alignments=True, compute_timestamps=True)
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(cfg, decoder_type="ctc")
|
||||
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding.preserve_alignments is True
|
||||
assert hybrid_asr_model_with_prompt.ctc_decoding.compute_timestamps is True
|
||||
assert hybrid_asr_model_with_prompt.cur_decoder == "ctc"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_type_change(self, hybrid_asr_model_with_prompt):
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.cur_decoder == 'rnnt'
|
||||
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='ctc')
|
||||
assert isinstance(hybrid_asr_model_with_prompt.ctc_decoding, CTCBPEDecoding)
|
||||
assert hybrid_asr_model_with_prompt.cur_decoder == 'ctc'
|
||||
|
||||
hybrid_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model_with_prompt.cur_decoder == 'rnnt'
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_input_output_types_with_prompt(self, hybrid_asr_model_with_prompt):
|
||||
"""Test that input/output types include prompt-specific types."""
|
||||
input_types = hybrid_asr_model_with_prompt.input_types
|
||||
output_types = hybrid_asr_model_with_prompt.output_types
|
||||
|
||||
# Check that prompt is included in input types
|
||||
assert 'prompt' in input_types
|
||||
# Check axes - neural types use tuples with symbolic names
|
||||
prompt_axes = input_types['prompt'].axes
|
||||
assert len(prompt_axes) == 3 # Should be 3D tensor
|
||||
|
||||
# Check standard input types are present
|
||||
assert 'input_signal' in input_types
|
||||
assert 'input_signal_length' in input_types
|
||||
|
||||
# Check output types
|
||||
assert 'outputs' in output_types
|
||||
assert 'encoded_lengths' in output_types
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_prompt_feature_initialization(self, hybrid_asr_model_with_prompt):
|
||||
"""Test that prompt feature initialization works correctly."""
|
||||
# Test that the model has prompt-related attributes
|
||||
assert hasattr(hybrid_asr_model_with_prompt, 'concat')
|
||||
assert hasattr(hybrid_asr_model_with_prompt, 'num_prompts')
|
||||
assert hasattr(hybrid_asr_model_with_prompt, 'prompt_kernel')
|
||||
|
||||
# Test that concat is enabled
|
||||
assert hybrid_asr_model_with_prompt.concat == True
|
||||
|
||||
# Test prompt kernel dimensions
|
||||
expected_input_size = (
|
||||
hybrid_asr_model_with_prompt.num_prompts + hybrid_asr_model_with_prompt._cfg.model_defaults.enc_hidden
|
||||
)
|
||||
expected_output_size = hybrid_asr_model_with_prompt._cfg.model_defaults.enc_hidden
|
||||
|
||||
# Check first layer of prompt kernel
|
||||
first_layer = hybrid_asr_model_with_prompt.prompt_kernel[0]
|
||||
assert first_layer.in_features == expected_input_size
|
||||
assert first_layer.out_features == expected_output_size * 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_prompt_truncation(self, hybrid_asr_model_with_prompt):
|
||||
"""Test that prompts are properly truncated when longer than encoded sequence."""
|
||||
hybrid_asr_model_with_prompt.eval()
|
||||
|
||||
input_signal = torch.randn(size=(1, 512)) # Short signal
|
||||
length = torch.tensor([512])
|
||||
|
||||
# Create a very long prompt (longer than expected encoded length)
|
||||
long_prompt = torch.randn(size=(1, 1000, hybrid_asr_model_with_prompt.num_prompts))
|
||||
|
||||
with torch.no_grad():
|
||||
encoded, encoded_len = hybrid_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt=long_prompt
|
||||
)
|
||||
|
||||
# Should not crash and should produce valid output
|
||||
assert encoded.shape[0] == 1
|
||||
assert encoded_len.shape[0] == 1
|
||||
@@ -0,0 +1,780 @@
|
||||
# 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
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
from omegaconf import DictConfig, ListConfig
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.modules import RNNTDecoder, RNNTJoint, SampledRNNTJoint, StatelessTransducerDecoder
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecoding, CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.utils import rnnt_utils
|
||||
from nemo.collections.common.parts.preprocessing.parsers import make_parser
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
from nemo.utils.config_utils import assert_dataclass_signature_match
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hybrid_asr_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
# fmt: off
|
||||
labels = [' ', 'a', 'b', 'c', 'd', 'e', 'f',
|
||||
'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w',
|
||||
'x', 'y', 'z', "'",
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
model_defaults = {'enc_hidden': 1024, 'pred_hidden': 64}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {'pred_hidden': model_defaults['pred_hidden'], 'pred_rnn_layers': 1},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {'joint_hidden': 32, 'activation': 'relu'},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
aux_ctc = {
|
||||
'ctc_loss_weight': 0.3,
|
||||
'use_cer': False,
|
||||
'ctc_reduction': 'mean_batch',
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': 1024,
|
||||
'num_classes': len(labels),
|
||||
'vocabulary': labels,
|
||||
},
|
||||
'decoding': DictConfig(CTCDecodingConfig),
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'labels': ListConfig(labels),
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
'aux_ctc': DictConfig(aux_ctc),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecHybridRNNTCTCModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecHybridRNNTCTCModel:
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, hybrid_asr_model):
|
||||
hybrid_asr_model.train()
|
||||
# TODO: make proper config and assert correct number of weights
|
||||
# Check to/from config_dict:
|
||||
confdict = hybrid_asr_model.to_config_dict()
|
||||
instance2 = EncDecHybridRNNTCTCModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecHybridRNNTCTCModel)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, hybrid_asr_model):
|
||||
hybrid_asr_model = hybrid_asr_model.eval()
|
||||
|
||||
hybrid_asr_model.preprocessor.featurizer.dither = 0.0
|
||||
hybrid_asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
hybrid_asr_model.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = hybrid_asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logprobs_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = hybrid_asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logprobs_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logprobs_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, hybrid_asr_model):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
hybrid_asr_model = hybrid_asr_model.eval()
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=1, with_data=True)
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=make_parser(labels=token_list), return_cuts=True)
|
||||
batch = dataset[cuts]
|
||||
outputs = hybrid_asr_model.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][0], MonoCut)
|
||||
assert isinstance(outputs[0][1], rnnt_utils.Hypothesis)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, hybrid_asr_model):
|
||||
old_vocab = copy.deepcopy(hybrid_asr_model.joint.vocabulary)
|
||||
nw1 = hybrid_asr_model.num_weights
|
||||
hybrid_asr_model.change_vocabulary(new_vocabulary=old_vocab)
|
||||
# No change
|
||||
assert nw1 == hybrid_asr_model.num_weights
|
||||
new_vocab = copy.deepcopy(old_vocab)
|
||||
new_vocab.append('!')
|
||||
new_vocab.append('$')
|
||||
new_vocab.append('@')
|
||||
hybrid_asr_model.change_vocabulary(new_vocabulary=new_vocab)
|
||||
# fully connected + bias
|
||||
# rnn embedding + joint + bias
|
||||
pred_embedding = 3 * (hybrid_asr_model.decoder.pred_hidden)
|
||||
joint_joint = 3 * (hybrid_asr_model.joint.joint_hidden + 1)
|
||||
ctc_decoder = 3 * (hybrid_asr_model.ctc_decoder._feat_in + 1)
|
||||
assert hybrid_asr_model.num_weights == (nw1 + (pred_embedding + joint_joint) + ctc_decoder)
|
||||
assert hybrid_asr_model.ctc_decoder.vocabulary == hybrid_asr_model.joint.vocabulary
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, hybrid_asr_model):
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'tsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "tsd"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'alsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert hybrid_asr_model.decoding.decoding.search_type == "alsd"
|
||||
|
||||
assert hybrid_asr_model.ctc_decoding is not None
|
||||
assert isinstance(hybrid_asr_model.ctc_decoding, CTCDecoding)
|
||||
assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy_batch"
|
||||
assert hybrid_asr_model.ctc_decoding.preserve_alignments is False
|
||||
assert hybrid_asr_model.ctc_decoding.compute_timestamps is False
|
||||
|
||||
cfg = CTCDecodingConfig(preserve_alignments=True, compute_timestamps=True)
|
||||
hybrid_asr_model.change_decoding_strategy(cfg, decoder_type="ctc")
|
||||
|
||||
assert hybrid_asr_model.ctc_decoding.preserve_alignments is True
|
||||
assert hybrid_asr_model.ctc_decoding.compute_timestamps is True
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_type_change(self, hybrid_asr_model):
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model.cur_decoder == 'rnnt'
|
||||
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='ctc')
|
||||
assert isinstance(hybrid_asr_model.ctc_decoding, CTCDecoding)
|
||||
assert hybrid_asr_model.cur_decoder == 'ctc'
|
||||
|
||||
hybrid_asr_model.change_decoding_strategy(decoding_cfg=new_strategy, decoder_type='rnnt')
|
||||
assert isinstance(hybrid_asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
assert hybrid_asr_model.cur_decoder == 'rnnt'
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_GreedyRNNTInferConfig(self):
|
||||
IGNORE_ARGS = [
|
||||
'decoder_model',
|
||||
'joint_model',
|
||||
'blank_index',
|
||||
'tdt_include_duration_confidence',
|
||||
'tdt_include_token_duration',
|
||||
'boosting_tree',
|
||||
'boosting_tree_alpha',
|
||||
]
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
greedy_decode.GreedyRNNTInfer, greedy_decode.GreedyRNNTInferConfig, ignore_args=IGNORE_ARGS
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_GreedyBatchedRNNTInferConfig(self):
|
||||
IGNORE_ARGS = [
|
||||
'decoder_model',
|
||||
'joint_model',
|
||||
'blank_index',
|
||||
'exclude_blank_from_confidence',
|
||||
'tdt_include_duration_confidence',
|
||||
'tdt_include_token_duration',
|
||||
'ngram_lm_model',
|
||||
'ngram_lm_alpha',
|
||||
'boosting_tree',
|
||||
'boosting_tree_alpha',
|
||||
'fusion_models',
|
||||
'fusion_models_alpha',
|
||||
]
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
greedy_decode.GreedyBatchedRNNTInfer, greedy_decode.GreedyBatchedRNNTInferConfig, ignore_args=IGNORE_ARGS
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_BeamRNNTInferConfig(self):
|
||||
IGNORE_ARGS = [
|
||||
'decoder_model',
|
||||
'joint_model',
|
||||
'blank_index',
|
||||
'boosting_tree',
|
||||
'boosting_tree_alpha',
|
||||
'preserve_frame_confidence',
|
||||
'tdt_include_duration_confidence',
|
||||
'confidence_method_cfg',
|
||||
]
|
||||
|
||||
result = assert_dataclass_signature_match(
|
||||
beam_decode.BeamRNNTInfer, beam_decode.BeamRNNTInferConfig, ignore_args=IGNORE_ARGS
|
||||
)
|
||||
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
("greedy_class", "loop_labels"),
|
||||
[
|
||||
(greedy_decode.GreedyRNNTInfer, None),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, True),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, False),
|
||||
],
|
||||
)
|
||||
def test_greedy_decoding(self, greedy_class, loop_labels: Optional[bool]):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
additional_decoding_kwargs = {} if loop_labels is None else {"loop_labels": loop_labels}
|
||||
greedy = greedy_class(
|
||||
decoder, joint_net, blank_index=len(token_list) - 1, max_symbols_per_step=5, **additional_decoding_kwargs
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"greedy_class",
|
||||
[greedy_decode.GreedyRNNTInfer],
|
||||
)
|
||||
def test_greedy_multi_decoding(self, greedy_class):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
greedy = greedy_class(decoder, joint_net, blank_index=len(token_list) - 1, max_symbols_per_step=5)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
(partial_hyp) = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
partial_hyp = partial_hyp[0]
|
||||
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len, partial_hypotheses=partial_hyp)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
("greedy_class", "loop_labels"),
|
||||
[
|
||||
(greedy_decode.GreedyRNNTInfer, None),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, True),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("context_size", [1, 2])
|
||||
def test_greedy_decoding_stateless_decoder(self, greedy_class, loop_labels: Optional[bool], context_size: int):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1, 'context_size': context_size}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = StatelessTransducerDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
additional_decoding_kwargs = {} if loop_labels is None else {"loop_labels": loop_labels}
|
||||
greedy = greedy_class(
|
||||
decoder, joint_net, blank_index=len(token_list) - 1, max_symbols_per_step=5, **additional_decoding_kwargs
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"greedy_class",
|
||||
[greedy_decode.GreedyRNNTInfer],
|
||||
)
|
||||
def test_greedy_multi_decoding_stateless_decoder(self, greedy_class):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = StatelessTransducerDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
greedy = greedy_class(decoder, joint_net, blank_index=len(token_list) - 1, max_symbols_per_step=5)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
(partial_hyp) = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
partial_hyp = partial_hyp[0]
|
||||
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len, partial_hypotheses=partial_hyp)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
("greedy_class", "loop_labels"),
|
||||
[
|
||||
(greedy_decode.GreedyRNNTInfer, None),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, True),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, False),
|
||||
],
|
||||
)
|
||||
def test_greedy_decoding_preserve_alignment(self, greedy_class, loop_labels: Optional[bool]):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
additional_decoding_kwargs = {} if loop_labels is None else {"loop_labels": loop_labels}
|
||||
greedy = greedy_class(
|
||||
decoder,
|
||||
joint_net,
|
||||
blank_index=len(token_list) - 1,
|
||||
preserve_alignments=True,
|
||||
max_symbols_per_step=5,
|
||||
**additional_decoding_kwargs,
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
hyp = greedy(encoder_output=enc_out, encoded_lengths=enc_len)[0][0] # type: rnnt_utils.Hypothesis
|
||||
assert hyp.alignments is not None
|
||||
|
||||
for t in range(len(hyp.alignments)):
|
||||
for u in range(len(hyp.alignments[t])):
|
||||
logp, label = hyp.alignments[t][u]
|
||||
assert torch.is_tensor(logp)
|
||||
assert torch.is_tensor(label)
|
||||
|
||||
# @pytest.mark.skipif(
|
||||
# not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
# reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
# )
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "greedy"},
|
||||
{"search_type": "default", "score_norm": False, "return_best_hypothesis": False},
|
||||
{"search_type": "alsd", "alsd_max_target_len": 20, "return_best_hypothesis": False},
|
||||
{"search_type": "tsd", "tsd_max_sym_exp_per_step": 3, "return_best_hypothesis": False},
|
||||
{"search_type": "maes", "maes_num_steps": 2, "maes_expansion_beta": 2, "return_best_hypothesis": False},
|
||||
{"search_type": "maes", "maes_num_steps": 3, "maes_expansion_beta": 1, "return_best_hypothesis": False},
|
||||
],
|
||||
)
|
||||
def test_beam_decoding(self, beam_config):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
beam_size = 1 if beam_config["search_type"] == "greedy" else 2
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
beam = beam_decode.BeamRNNTInfer(
|
||||
decoder,
|
||||
joint_net,
|
||||
beam_size=beam_size,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
_ = beam(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "greedy"},
|
||||
{"search_type": "default", "score_norm": False, "return_best_hypothesis": False},
|
||||
],
|
||||
)
|
||||
def test_beam_decoding_preserve_alignments(self, beam_config):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
beam_size = 1 if beam_config["search_type"] == "greedy" else 2
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = RNNTJoint(jointnet_cfg, vocab_size, vocabulary=token_list)
|
||||
|
||||
beam = beam_decode.BeamRNNTInfer(
|
||||
decoder, joint_net, beam_size=beam_size, **beam_config, preserve_alignments=True
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
hyp = beam(encoder_output=enc_out, encoded_lengths=enc_len)[0][0] # type: rnnt_utils.Hypothesis
|
||||
|
||||
if isinstance(hyp, rnnt_utils.NBestHypotheses):
|
||||
hyp = hyp.n_best_hypotheses[0] # select top hypothesis only
|
||||
|
||||
assert hyp.alignments is not None
|
||||
|
||||
for t in range(len(hyp.alignments)):
|
||||
for u in range(len(hyp.alignments[t])):
|
||||
logp, label = hyp.alignments[t][u]
|
||||
assert torch.is_tensor(logp)
|
||||
assert torch.is_tensor(label)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
("greedy_class", "loop_labels"),
|
||||
[
|
||||
(greedy_decode.GreedyRNNTInfer, None),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, True),
|
||||
(greedy_decode.GreedyBatchedRNNTInfer, False),
|
||||
],
|
||||
)
|
||||
def test_greedy_decoding_SampledRNNTJoint(self, greedy_class, loop_labels: Optional[bool]):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = SampledRNNTJoint(jointnet_cfg, vocab_size, n_samples=2, vocabulary=token_list)
|
||||
|
||||
additional_decoding_kwargs = {} if loop_labels is None else {"loop_labels": loop_labels}
|
||||
greedy = greedy_class(
|
||||
decoder, joint_net, blank_index=len(token_list) - 1, max_symbols_per_step=5, **additional_decoding_kwargs
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"beam_config",
|
||||
[
|
||||
{"search_type": "greedy"},
|
||||
{"search_type": "default", "score_norm": False, "return_best_hypothesis": False},
|
||||
{"search_type": "alsd", "alsd_max_target_len": 20, "return_best_hypothesis": False},
|
||||
{"search_type": "tsd", "tsd_max_sym_exp_per_step": 3, "return_best_hypothesis": False},
|
||||
{"search_type": "maes", "maes_num_steps": 2, "maes_expansion_beta": 2, "return_best_hypothesis": False},
|
||||
{"search_type": "maes", "maes_num_steps": 3, "maes_expansion_beta": 1, "return_best_hypothesis": False},
|
||||
],
|
||||
)
|
||||
def test_beam_decoding_SampledRNNTJoint(self, beam_config):
|
||||
token_list = [" ", "a", "b", "c"]
|
||||
vocab_size = len(token_list)
|
||||
beam_size = 1 if beam_config["search_type"] == "greedy" else 2
|
||||
|
||||
encoder_output_size = 4
|
||||
decoder_output_size = 4
|
||||
joint_output_shape = 4
|
||||
|
||||
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
|
||||
jointnet_cfg = {
|
||||
'encoder_hidden': encoder_output_size,
|
||||
'pred_hidden': decoder_output_size,
|
||||
'joint_hidden': joint_output_shape,
|
||||
'activation': 'relu',
|
||||
}
|
||||
|
||||
decoder = RNNTDecoder(prednet_cfg, vocab_size)
|
||||
joint_net = SampledRNNTJoint(jointnet_cfg, vocab_size, n_samples=2, vocabulary=token_list)
|
||||
|
||||
beam = beam_decode.BeamRNNTInfer(
|
||||
decoder,
|
||||
joint_net,
|
||||
beam_size=beam_size,
|
||||
**beam_config,
|
||||
)
|
||||
|
||||
# (B, D, T)
|
||||
enc_out = torch.randn(1, encoder_output_size, 30)
|
||||
enc_len = torch.tensor([30], dtype=torch.int32)
|
||||
|
||||
with torch.no_grad():
|
||||
_ = beam(encoder_output=enc_out, encoded_lengths=enc_len)
|
||||
@@ -0,0 +1,269 @@
|
||||
# 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 Dict
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig, ListConfig
|
||||
|
||||
from nemo.collections.asr.models import EncDecCTCModel, EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.core.classes.mixins import AccessMixin
|
||||
|
||||
|
||||
def jasper_encoder_config(num_layers=1) -> Dict:
|
||||
return {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 4,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
]
|
||||
* num_layers,
|
||||
}
|
||||
|
||||
|
||||
def conformer_encoder_config() -> Dict:
|
||||
return {
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': 64,
|
||||
'n_layers': 8,
|
||||
'd_model': 4,
|
||||
}
|
||||
|
||||
|
||||
class TestInterCTCLoss:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"model_class",
|
||||
[EncDecCTCModel, EncDecHybridRNNTCTCModel],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"encoder_config",
|
||||
[jasper_encoder_config(num_layers=8), conformer_encoder_config()],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"apply_at_layers,loss_weights",
|
||||
[
|
||||
([2, 4], [0.1, 0.3]),
|
||||
([4], [0.3]),
|
||||
([], []),
|
||||
# errors
|
||||
([2, 4], [0.1]),
|
||||
([2], [0.1, 0.3]),
|
||||
([], [0.3]),
|
||||
],
|
||||
)
|
||||
def test_forward(self, model_class, encoder_config, apply_at_layers, loss_weights):
|
||||
preprocessor_config = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
vocabulary = [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
]
|
||||
if model_class is EncDecCTCModel:
|
||||
decoder_config = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': None,
|
||||
'num_classes': len(vocabulary),
|
||||
'vocabulary': vocabulary,
|
||||
}
|
||||
model_config = DictConfig(
|
||||
{
|
||||
'compute_eval_loss': True, # will be ignored by the model
|
||||
'preprocessor': DictConfig(preprocessor_config),
|
||||
'encoder': DictConfig(encoder_config),
|
||||
'decoder': DictConfig(decoder_config),
|
||||
}
|
||||
)
|
||||
else:
|
||||
decoder_config = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {'pred_hidden': 4, 'pred_rnn_layers': 1},
|
||||
}
|
||||
joint_config = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {'joint_hidden': 4, 'activation': 'relu'},
|
||||
}
|
||||
decoding_config = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
loss_config = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
aux_ctc_config = {
|
||||
'ctc_loss_weight': 0.3,
|
||||
'use_cer': False,
|
||||
'ctc_reduction': 'mean_batch',
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': None,
|
||||
'num_classes': len(vocabulary),
|
||||
'vocabulary': vocabulary,
|
||||
},
|
||||
'decoding': DictConfig(CTCDecodingConfig),
|
||||
}
|
||||
model_config = DictConfig(
|
||||
{
|
||||
'compute_eval_loss': True,
|
||||
'labels': ListConfig(vocabulary),
|
||||
'preprocessor': DictConfig(preprocessor_config),
|
||||
'model_defaults': DictConfig({'enc_hidden': 4, 'pred_hidden': 4}),
|
||||
'encoder': DictConfig(encoder_config),
|
||||
'decoder': DictConfig(decoder_config),
|
||||
'joint': DictConfig(joint_config),
|
||||
'decoding': DictConfig(decoding_config),
|
||||
'loss': DictConfig(loss_config),
|
||||
'aux_ctc': DictConfig(aux_ctc_config),
|
||||
}
|
||||
)
|
||||
model_config.update(
|
||||
{
|
||||
'interctc': {'loss_weights': loss_weights, 'apply_at_layers': apply_at_layers},
|
||||
'optim': {'name': 'adamw'},
|
||||
}
|
||||
)
|
||||
|
||||
class DummyDataset(torch.utils.data.Dataset):
|
||||
"""Simply returns a single set of values."""
|
||||
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
|
||||
def __len__(self):
|
||||
return 1
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self.values
|
||||
|
||||
# this sometimes results in all zeros in the output which breaks tests
|
||||
# so using this only for the ptl calls in the bottom, but using
|
||||
# processed signal directly initially to remove the chance of
|
||||
# this edge-case
|
||||
input_signal = torch.randn(size=(1, 512))
|
||||
input_length = torch.randint(low=321, high=500, size=[1])
|
||||
target = torch.randint(size=(1, input_length[0]), low=0, high=28)
|
||||
target_length = torch.tensor([input_length[0]])
|
||||
|
||||
processed_signal = torch.randn(size=([1, 64, 12]))
|
||||
processed_length = torch.tensor([8])
|
||||
|
||||
if len(apply_at_layers) != len(loss_weights):
|
||||
# has to throw an error here
|
||||
with pytest.raises(
|
||||
ValueError, match="Length of interctc.apply_at_layers has to match interctc.loss_weights"
|
||||
):
|
||||
asr_model = model_class(cfg=model_config)
|
||||
asr_model.train()
|
||||
logprobs, _, _ = asr_model.forward(input_signal=input_signal, input_signal_length=input_length)
|
||||
else:
|
||||
asr_model = model_class(cfg=model_config)
|
||||
asr_model.train()
|
||||
AccessMixin.set_access_enabled(access_enabled=True, guid=asr_model.model_guid)
|
||||
logprobs, *_ = asr_model.forward(
|
||||
processed_signal=processed_signal, processed_signal_length=processed_length
|
||||
)
|
||||
captured_tensors = asr_model.get_captured_interctc_tensors()
|
||||
AccessMixin.reset_registry(asr_model)
|
||||
assert len(captured_tensors) == len(apply_at_layers)
|
||||
for output in captured_tensors:
|
||||
# checking that values are not the same, if shape is the same
|
||||
assert output[0].shape != logprobs.shape or not torch.allclose(output[0], logprobs)
|
||||
# hybrid model returns output of encoder, so it's not expected to match
|
||||
if model_class is EncDecCTCModel:
|
||||
assert output[0].shape == logprobs.shape
|
||||
|
||||
# Explicitly pass accelerator as cpu, since default val in PTL >= 2.0 is auto and it picks cuda
|
||||
# which further causes an error in all reduce at: https://github.com/NVIDIA/NeMo/blob/v1.18.1/nemo/collections/asr/modules/conv_asr.py#L209
|
||||
trainer = pl.Trainer(max_epochs=1, accelerator='cpu')
|
||||
trainer.fit(
|
||||
asr_model,
|
||||
train_dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
val_dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
)
|
||||
required_metrics = ['final_loss'] if len(loss_weights) > 0 else []
|
||||
required_metrics += [f'inter_ctc_loss_l{idx}' for idx in apply_at_layers]
|
||||
prefix = "val_"
|
||||
required_metrics += [f'{prefix}{metric}' for metric in required_metrics]
|
||||
required_metrics += [f'{prefix}wer'] + [f'{prefix}inter_wer_l{idx}' for idx in apply_at_layers]
|
||||
for metric in required_metrics:
|
||||
if 'loss' in metric and 'val_' in metric:
|
||||
if model_config['compute_eval_loss']:
|
||||
assert metric in trainer.logged_metrics
|
||||
else:
|
||||
assert metric not in trainer.logged_metrics
|
||||
else:
|
||||
assert metric in trainer.logged_metrics
|
||||
|
||||
trainer.test(
|
||||
asr_model,
|
||||
dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
)
|
||||
required_metrics = [f'inter_ctc_loss_l{idx}' for idx in apply_at_layers]
|
||||
prefix = 'test_'
|
||||
# note that "=" is on purpose here, not "+=", since we only log test metrics
|
||||
required_metrics = [f'{prefix}{metric}' for metric in required_metrics]
|
||||
required_metrics += [f'{prefix}wer'] + [f'{prefix}inter_wer_l{idx}' for idx in apply_at_layers]
|
||||
for metric in required_metrics:
|
||||
if 'loss' in metric:
|
||||
if model_config['compute_eval_loss']:
|
||||
assert metric in trainer.logged_metrics
|
||||
else:
|
||||
assert metric not in trainer.logged_metrics
|
||||
else:
|
||||
assert metric in trainer.logged_metrics
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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 patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from lhotse import CutSet, SupervisionSegment
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tokenizer(tmp_path_factory) -> SentencePieceTokenizer:
|
||||
tmpdir = tmp_path_factory.mktemp("klingon_tokens")
|
||||
text_path = tmpdir / "text.txt"
|
||||
text_path.write_text("\n".join(map(chr, range(ord('a'), ord('z')))))
|
||||
model_path, vocab_path = create_spt_model(
|
||||
text_path, vocab_size=32, sample_size=-1, do_lower_case=False, output_dir=str(tmpdir)
|
||||
)
|
||||
return SentencePieceTokenizer(model_path)
|
||||
|
||||
|
||||
def test_lhotse_asr_dataset(tokenizer):
|
||||
# 3 cuts of duration 1s with audio and a single supervision with text 'irrelevant'
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=3, with_data=True)
|
||||
|
||||
# cuts[0] is the default case: audio + single untokenized superivision
|
||||
|
||||
# cuts[1]: audio + single pre-tokenized superivision
|
||||
cuts[1].supervisions[0].tokens = tokenizer.text_to_ids(cuts[1].supervisions[0].text)
|
||||
|
||||
# cuts[2]: audio + two supervisions
|
||||
cuts[2].supervisions = [
|
||||
SupervisionSegment(id="cuts2-sup0", recording_id=cuts[2].recording_id, start=0, duration=0.5, text="first"),
|
||||
SupervisionSegment(id="cuts2-sup1", recording_id=cuts[2].recording_id, start=0.5, duration=0.5, text="second"),
|
||||
]
|
||||
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=tokenizer)
|
||||
batch = dataset[cuts]
|
||||
|
||||
assert isinstance(batch, tuple)
|
||||
assert len(batch) == 4
|
||||
assert all(isinstance(t, torch.Tensor) for t in batch)
|
||||
|
||||
audio, audio_lens, tokens, token_lens = batch
|
||||
|
||||
assert audio.shape == (3, 16000)
|
||||
assert audio_lens.tolist() == [16000] * 3
|
||||
|
||||
assert tokens.shape == (3, 13)
|
||||
assert tokens[0].tolist() == [1, 10, 19, 19, 6, 13, 6, 23, 2, 15, 21, 0, 0]
|
||||
assert tokens[1].tolist() == tokens[0].tolist()
|
||||
assert tokens[2].tolist() == [1, 7, 10, 19, 20, 21, 1, 20, 6, 4, 16, 15, 5]
|
||||
|
||||
assert token_lens.tolist() == [11, 11, 13]
|
||||
|
||||
|
||||
def test_lhotse_asr_dataset_metadata(tokenizer):
|
||||
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=2, with_data=True)
|
||||
|
||||
cuts[0].id = "cuts0"
|
||||
cuts[1].id = "cuts1"
|
||||
cuts[0].supervisions = [
|
||||
SupervisionSegment(id="cuts0-sup0", recording_id=cuts[0].recording_id, start=0.2, duration=0.5, text="first"),
|
||||
]
|
||||
cuts[1].supervisions = [
|
||||
SupervisionSegment(id="cuts1-sup0", recording_id=cuts[1].recording_id, start=0, duration=1, text=""),
|
||||
]
|
||||
|
||||
datasets_metadata = LhotseSpeechToTextBpeDataset(tokenizer=tokenizer, return_cuts=True)
|
||||
batch = datasets_metadata[cuts]
|
||||
assert isinstance(batch, tuple)
|
||||
assert len(batch) == 5
|
||||
|
||||
_, _, _, _, cuts_metadata = batch
|
||||
|
||||
assert cuts_metadata[0].supervisions[0].text == "first"
|
||||
assert cuts_metadata[1].supervisions[0].text == ""
|
||||
assert cuts_metadata[0].id == "cuts0"
|
||||
assert cuts_metadata[1].id == "cuts1"
|
||||
|
||||
assert cuts_metadata[0].supervisions[0].duration == 0.5
|
||||
assert cuts_metadata[0].supervisions[0].start == 0.2
|
||||
|
||||
assert cuts_metadata[1].supervisions[0].duration == 1
|
||||
assert cuts_metadata[1].supervisions[0].start == 0.0
|
||||
|
||||
|
||||
def test_lhotse_asr_dataset_ais_batch_loading_enabled(tokenizer, monkeypatch):
|
||||
"""Test that USE_AIS_GET_BATCH=true passes use_batch_loader=True to AudioSamples."""
|
||||
monkeypatch.setenv("USE_AIS_GET_BATCH", "true")
|
||||
|
||||
with patch.object(AudioSamples, "__init__", return_value=None) as mock_init:
|
||||
mock_init.side_effect = lambda *args, **kwargs: None
|
||||
try:
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=tokenizer)
|
||||
except Exception:
|
||||
pass
|
||||
# Check that AudioSamples was called with use_batch_loader=True
|
||||
mock_init.assert_called_with(fault_tolerant=True, use_batch_loader=True)
|
||||
|
||||
|
||||
def test_lhotse_asr_dataset_ais_batch_loading_disabled(tokenizer, monkeypatch):
|
||||
"""Test that without USE_AIS_GET_BATCH, use_batch_loader=False is passed to AudioSamples."""
|
||||
monkeypatch.delenv("USE_AIS_GET_BATCH", raising=False)
|
||||
|
||||
with patch.object(AudioSamples, "__init__", return_value=None) as mock_init:
|
||||
mock_init.side_effect = lambda *args, **kwargs: None
|
||||
try:
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=tokenizer)
|
||||
except Exception:
|
||||
pass
|
||||
# Check that AudioSamples was called with use_batch_loader=False
|
||||
mock_init.assert_called_with(fault_tolerant=True, use_batch_loader=False)
|
||||
|
||||
|
||||
def test_lhotse_asr_dataset_ais_batch_loading_fallback(tokenizer, monkeypatch):
|
||||
"""Test fallback when Lhotse doesn't support use_batch_loader (< 1.32.0)."""
|
||||
monkeypatch.setenv("USE_AIS_GET_BATCH", "true")
|
||||
|
||||
call_args = []
|
||||
|
||||
original_init = AudioSamples.__init__
|
||||
|
||||
def mock_init(self, *args, **kwargs):
|
||||
call_args.append(kwargs.copy())
|
||||
if "use_batch_loader" in kwargs:
|
||||
raise TypeError("unexpected keyword argument 'use_batch_loader'")
|
||||
return original_init(self, *args, **kwargs)
|
||||
|
||||
with patch.object(AudioSamples, "__init__", mock_init):
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=tokenizer)
|
||||
|
||||
# First call should have use_batch_loader=True, second call should not
|
||||
assert call_args[0] == {"fault_tolerant": True, "use_batch_loader": True}
|
||||
assert call_args[1] == {"fault_tolerant": True}
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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 lhotse import CutSet, SupervisionSegment
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse_speaker import LhotseSpeechToTextSpkBpeDataset
|
||||
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tokenizer(tmp_path_factory) -> SentencePieceTokenizer:
|
||||
tmpdir = tmp_path_factory.mktemp("klingon_tokens")
|
||||
text_path = tmpdir / "text.txt"
|
||||
text_path.write_text("\n".join(map(chr, range(ord('a'), ord('z')))))
|
||||
model_path, vocab_path = create_spt_model(
|
||||
text_path, vocab_size=32, sample_size=-1, do_lower_case=False, output_dir=str(tmpdir)
|
||||
)
|
||||
return SentencePieceTokenizer(model_path)
|
||||
|
||||
|
||||
def test_lhotse_asr_speaker_dataset(tokenizer):
|
||||
# 3 cuts of duration 1s with audio and a single supervision with text 'irrelevant'
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=2, with_data=True)
|
||||
|
||||
# cuts[0] is the default case: audio + single untokenized superivision
|
||||
|
||||
# cuts[1]: audio + two supervisions
|
||||
cuts[1].supervisions = [
|
||||
SupervisionSegment(
|
||||
id="cuts1-sup0", recording_id=cuts[1].recording_id, start=0, duration=0.5, text="first", speaker="0"
|
||||
),
|
||||
SupervisionSegment(
|
||||
id="cuts1-sup1", recording_id=cuts[1].recording_id, start=0.5, duration=0.5, text="second", speaker="1"
|
||||
),
|
||||
]
|
||||
|
||||
dataset = LhotseSpeechToTextSpkBpeDataset(cfg={}, tokenizer=tokenizer)
|
||||
batch = dataset[cuts]
|
||||
|
||||
assert isinstance(batch, tuple)
|
||||
assert len(batch) == 6
|
||||
assert all(isinstance(t, torch.Tensor) for t in batch)
|
||||
|
||||
audio, audio_lens, tokens, token_lens, spk_targets, bg_spk_targets = batch
|
||||
|
||||
assert audio.shape == (2, 16000)
|
||||
assert audio_lens.tolist() == [16000] * 2
|
||||
|
||||
assert tokens.shape == (2, 11)
|
||||
assert tokens[0].tolist() == [1, 10, 19, 19, 6, 13, 6, 23, 2, 15, 21]
|
||||
assert tokens[1].tolist() == [1, 20, 6, 4, 16, 15, 5, 0, 0, 0, 0] or tokens[1].tolist() == [
|
||||
1,
|
||||
7,
|
||||
10,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
assert token_lens.tolist() == [11, 7] or token_lens.tolist() == [11, 6]
|
||||
|
||||
assert spk_targets.shape == (2, 13)
|
||||
assert spk_targets[0].long().tolist() == [1] * 13
|
||||
assert spk_targets[1].long().sum().item() in [6, 7]
|
||||
|
||||
assert bg_spk_targets.shape == (2, 13)
|
||||
assert bg_spk_targets[0].long().tolist() == [0] * 13
|
||||
assert bg_spk_targets[1].long().sum().item() in [6, 7]
|
||||
|
||||
assert (spk_targets[1] + bg_spk_targets[1]).long().tolist() == [1] * 13
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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 os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models import ASRModel, EncDecCTCModel
|
||||
|
||||
|
||||
def getattr2(object, attr):
|
||||
if not '.' in attr:
|
||||
return getattr(object, attr)
|
||||
else:
|
||||
arr = attr.split('.')
|
||||
return getattr2(getattr(object, arr[0]), '.'.join(arr[1:]))
|
||||
|
||||
|
||||
class TestASRLocalAttention:
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_forward(self):
|
||||
asr_model = ASRModel.from_pretrained("stt_en_conformer_ctc_small")
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
len = 16000 * 60 * 30 # 30 minutes, OOM without local attention
|
||||
input_signal_long = torch.randn(size=(1, len), device=asr_model.device)
|
||||
length_long = torch.tensor([len], device=asr_model.device)
|
||||
|
||||
# switch to local attn
|
||||
asr_model.change_attention_model(self_attention_model="rel_pos_local_attn", att_context_size=(64, 64))
|
||||
with torch.no_grad():
|
||||
asr_model.forward(input_signal=input_signal_long, input_signal_length=length_long)
|
||||
|
||||
# switch context size only (keep local)
|
||||
asr_model.change_attention_model(att_context_size=(192, 192))
|
||||
with torch.no_grad():
|
||||
asr_model.forward(input_signal=input_signal_long, input_signal_length=length_long)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_change_save_restore(self):
|
||||
|
||||
model = ASRModel.from_pretrained("stt_en_conformer_ctc_small")
|
||||
model.change_attention_model(self_attention_model="rel_pos_local_attn", att_context_size=(64, 64))
|
||||
attr_for_eq_check = ["encoder.self_attention_model", "encoder.att_context_size"]
|
||||
|
||||
with tempfile.TemporaryDirectory() as restore_folder:
|
||||
with tempfile.TemporaryDirectory() as save_folder:
|
||||
save_folder_path = save_folder
|
||||
# Where model will be saved
|
||||
model_save_path = os.path.join(save_folder, f"{model.__class__.__name__}.nemo")
|
||||
model.save_to(save_path=model_save_path)
|
||||
# Where model will be restored from
|
||||
model_restore_path = os.path.join(restore_folder, f"{model.__class__.__name__}.nemo")
|
||||
shutil.copy(model_save_path, model_restore_path)
|
||||
# at this point save_folder should not exist
|
||||
assert save_folder_path is not None and not os.path.exists(save_folder_path)
|
||||
assert not os.path.exists(model_save_path)
|
||||
assert os.path.exists(model_restore_path)
|
||||
# attempt to restore
|
||||
model_copy = model.__class__.restore_from(
|
||||
restore_path=model_restore_path,
|
||||
map_location=None,
|
||||
strict=True,
|
||||
return_config=False,
|
||||
override_config_path=None,
|
||||
)
|
||||
|
||||
assert model.num_weights == model_copy.num_weights
|
||||
if attr_for_eq_check is not None and len(attr_for_eq_check) > 0:
|
||||
for attr in attr_for_eq_check:
|
||||
assert getattr2(model, attr) == getattr2(model_copy, attr)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"global_tokens",
|
||||
[0, 1, 4],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"global_tokens_spacing",
|
||||
[1, 4],
|
||||
)
|
||||
def test_train(self, global_tokens, global_tokens_spacing):
|
||||
preprocessor_config = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
vocabulary = [
|
||||
' ',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
"'",
|
||||
]
|
||||
encoder_config = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': 64,
|
||||
'n_layers': 8,
|
||||
'd_model': 4,
|
||||
'self_attention_model': 'rel_pos_local_attn',
|
||||
'att_context_size': [128, 128],
|
||||
'global_tokens': global_tokens,
|
||||
'global_tokens_spacing': global_tokens_spacing,
|
||||
}
|
||||
decoder_config = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': None,
|
||||
'num_classes': len(vocabulary),
|
||||
'vocabulary': vocabulary,
|
||||
}
|
||||
model_config = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor_config),
|
||||
'encoder': DictConfig(encoder_config),
|
||||
'decoder': DictConfig(decoder_config),
|
||||
'optim': {'name': 'adamw'},
|
||||
}
|
||||
)
|
||||
|
||||
class DummyDataset(torch.utils.data.Dataset):
|
||||
"""Simply returns a single set of values."""
|
||||
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
|
||||
def __len__(self):
|
||||
return 1
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self.values
|
||||
|
||||
input_signal = torch.randn(size=(1, 960000))
|
||||
input_length = torch.tensor([960000])
|
||||
target = torch.randint(size=(1, 280), low=0, high=28)
|
||||
target_length = torch.tensor([280])
|
||||
|
||||
asr_model = EncDecCTCModel(cfg=model_config)
|
||||
asr_model.train()
|
||||
_ = asr_model.forward(input_signal=input_signal, input_signal_length=input_length)
|
||||
# Explicitly pass accelerator as cpu, since default val in PTL >= 2.0 is auto and it picks cuda
|
||||
# which further causes an error in all reduce at: https://github.com/NVIDIA/NeMo/blob/v1.18.1/nemo/collections/asr/modules/conformer_encoder.py#L462
|
||||
# and in ConvASREncoder where device is CPU
|
||||
trainer = pl.Trainer(max_epochs=1, accelerator='cpu')
|
||||
trainer.fit(
|
||||
asr_model,
|
||||
train_dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
val_dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
)
|
||||
trainer.test(
|
||||
asr_model,
|
||||
dataloaders=torch.utils.data.DataLoader(
|
||||
DummyDataset([input_signal, input_length, target, target_length]),
|
||||
collate_fn=lambda x: x[0],
|
||||
),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
||||
# Copyright (c) 2020, 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 omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr import modules
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
from nemo.utils import config_utils, logging
|
||||
|
||||
|
||||
class TestASRModulesBasicTests:
|
||||
@pytest.mark.unit
|
||||
def test_AudioToMelSpectrogramPreprocessor_config(self):
|
||||
# Test that dataclass matches signature of module
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
modules.AudioToMelSpectrogramPreprocessor,
|
||||
modules.audio_preprocessing.AudioToMelSpectrogramPreprocessorConfig,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_AudioToMelSpectrogramPreprocessor_batch(self):
|
||||
# Test 1 that should test the pure stft implementation as much as possible
|
||||
instance1 = modules.AudioToMelSpectrogramPreprocessor(normalize="per_feature", dither=0, pad_to=0)
|
||||
|
||||
# Ensure that the two functions behave similarily
|
||||
for _ in range(10):
|
||||
input_signal, length = instance1.input_example(4, 512, 321)
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
res_instance, length_instance = [], []
|
||||
for i in range(input_signal.size(0)):
|
||||
res_ins, length_ins = instance1(input_signal=input_signal[i : i + 1], length=length[i : i + 1])
|
||||
res_instance.append(res_ins)
|
||||
length_instance.append(length_ins)
|
||||
|
||||
res_instance = torch.cat(res_instance, 0)
|
||||
length_instance = torch.cat(length_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
res_batch, length_batch = instance1(input_signal=input_signal, length=length)
|
||||
|
||||
assert res_instance.shape == res_batch.shape
|
||||
assert length_instance.shape == length_batch.shape
|
||||
diff = torch.mean(torch.abs(res_instance - res_batch))
|
||||
assert diff <= 1e-3
|
||||
diff = torch.max(torch.abs(res_instance - res_batch))
|
||||
assert diff <= 1e-3
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_AudioToMelSpectrogramPreprocessor_gpu(self):
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor().to("cuda")
|
||||
input_signal, length = instance0.input_example()
|
||||
|
||||
with torch.no_grad():
|
||||
processed_signal, _ = instance0(input_signal=input_signal, length=length)
|
||||
|
||||
assert processed_signal.device == input_signal.device
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_SpectrogramAugmentationr_legacy(self):
|
||||
# Make sure constructor works
|
||||
instance1 = modules.SpectrogramAugmentation(
|
||||
freq_masks=10, time_masks=3, rect_masks=3, use_numba_spec_augment=False, use_vectorized_spec_augment=False
|
||||
)
|
||||
assert isinstance(instance1, modules.SpectrogramAugmentation)
|
||||
|
||||
# Make sure forward doesn't throw with expected input
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor(dither=0)
|
||||
input_signal, length = instance0.input_example(4, 512, 321)
|
||||
res0 = instance0(input_signal=input_signal, length=length)
|
||||
res = instance1(input_spec=res0[0], length=length)
|
||||
|
||||
assert res.shape == res0[0].shape
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_SpectrogramAugmentationr_vectorized(self):
|
||||
# Make sure constructor works
|
||||
instance1 = modules.SpectrogramAugmentation(
|
||||
freq_masks=10, time_masks=3, rect_masks=3, use_numba_spec_augment=False, use_vectorized_spec_augment=True
|
||||
)
|
||||
assert isinstance(instance1, modules.SpectrogramAugmentation)
|
||||
|
||||
# Make sure forward doesn't throw with expected input
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor(dither=0)
|
||||
input_signal, length = instance0.input_example(4, 512, 321)
|
||||
res0 = instance0(input_signal=input_signal, length=length)
|
||||
res = instance1(input_spec=res0[0], length=length)
|
||||
|
||||
assert res.shape == res0[0].shape
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_SpectrogramAugmentationr_numba_kernel(self, caplog):
|
||||
numba_utils.skip_numba_cuda_test_if_unsupported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
logging._logger.propagate = True
|
||||
original_verbosity = logging.get_verbosity()
|
||||
logging.set_verbosity(logging.DEBUG)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
# Make sure constructor works
|
||||
instance1 = modules.SpectrogramAugmentation(
|
||||
freq_masks=10, time_masks=3, rect_masks=3, use_numba_spec_augment=True, use_vectorized_spec_augment=False
|
||||
)
|
||||
assert isinstance(instance1, modules.SpectrogramAugmentation)
|
||||
|
||||
# Make sure forward doesn't throw with expected input
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor(dither=0)
|
||||
input_signal, length = instance0.input_example(8, 512, 321)
|
||||
res0 = instance0(input_signal=input_signal, length=length)
|
||||
res = instance1(input_spec=res0[0], length=length)
|
||||
|
||||
assert res.shape == res0[0].shape
|
||||
|
||||
# check tha numba kernel debug message indicates that it is available for use
|
||||
assert """Numba SpecAugment kernel is available""" in caplog.text
|
||||
|
||||
logging._logger.propagate = False
|
||||
logging.set_verbosity(original_verbosity)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_SpectrogramAugmentationr_config(self):
|
||||
# Test that dataclass matches signature of module
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
modules.SpectrogramAugmentation,
|
||||
modules.audio_preprocessing.SpectrogramAugmentationConfig,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_CropOrPadSpectrogramAugmentation(self):
|
||||
# Make sure constructor works
|
||||
audio_length = 128
|
||||
instance1 = modules.CropOrPadSpectrogramAugmentation(audio_length=audio_length)
|
||||
assert isinstance(instance1, modules.CropOrPadSpectrogramAugmentation)
|
||||
|
||||
# Make sure forward doesn't throw with expected input
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor(dither=0)
|
||||
input_signal, length = instance0.input_example(4, 512, 321)
|
||||
res0 = instance0(input_signal=input_signal, length=length)
|
||||
res, new_length = instance1(input_signal=res0[0], length=length)
|
||||
|
||||
assert res.shape == torch.Size([4, 64, audio_length])
|
||||
assert all(new_length == torch.tensor([128] * 4))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_CropOrPadSpectrogramAugmentation_config(self):
|
||||
# Test that dataclass matches signature of module
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
modules.CropOrPadSpectrogramAugmentation,
|
||||
modules.audio_preprocessing.CropOrPadSpectrogramAugmentationConfig,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_MaskedPatchAugmentation(self):
|
||||
# Make sure constructor works
|
||||
audio_length = 128
|
||||
instance1 = modules.MaskedPatchAugmentation(patch_size=16, mask_patches=0.5, freq_masks=2, freq_width=10)
|
||||
assert isinstance(instance1, modules.MaskedPatchAugmentation)
|
||||
|
||||
# Make sure forward doesn't throw with expected input
|
||||
instance0 = modules.AudioToMelSpectrogramPreprocessor(dither=0)
|
||||
input_signal, length = instance0.input_example(4, 512, 321)
|
||||
res0 = instance0(input_signal=input_signal, length=length)
|
||||
res = instance1(input_spec=res0[0], length=length)
|
||||
|
||||
assert res.shape == res0[0].shape
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_MaskedPatchAugmentation_config(self):
|
||||
# Test that dataclass matches signature of module
|
||||
result = config_utils.assert_dataclass_signature_match(
|
||||
modules.MaskedPatchAugmentation,
|
||||
modules.audio_preprocessing.MaskedPatchAugmentationConfig,
|
||||
)
|
||||
signatures_match, cls_subset, dataclass_subset = result
|
||||
|
||||
assert signatures_match
|
||||
assert cls_subset is None
|
||||
assert dataclass_subset is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_RNNTDecoder(self):
|
||||
vocab = list(range(10))
|
||||
vocab = [str(x) for x in vocab]
|
||||
vocab_size = len(vocab)
|
||||
|
||||
pred_config = OmegaConf.create(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': 32,
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
'vocab_size': vocab_size,
|
||||
'blank_as_pad': True,
|
||||
}
|
||||
)
|
||||
|
||||
prednet = modules.RNNTDecoder.from_config_dict(pred_config)
|
||||
|
||||
# num params
|
||||
pred_hidden = pred_config.prednet.pred_hidden
|
||||
embed = (vocab_size + 1) * pred_hidden # embedding with blank
|
||||
rnn = (
|
||||
2 * 4 * (pred_hidden * pred_hidden + pred_hidden)
|
||||
) # (ih + hh) * (ifco gates) * (indim * hiddendim + bias)
|
||||
assert prednet.num_weights == (embed + rnn)
|
||||
|
||||
# State initialization
|
||||
x_ = torch.zeros(4, dtype=torch.float32)
|
||||
states = prednet.initialize_state(x_)
|
||||
|
||||
for state_i in states:
|
||||
assert state_i.dtype == x_.dtype
|
||||
assert state_i.device == x_.device
|
||||
assert state_i.shape[1] == len(x_)
|
||||
|
||||
# Blank hypotheses test
|
||||
blank = vocab_size
|
||||
hyp = Hypothesis(score=0.0, y_sequence=[blank])
|
||||
cache = {}
|
||||
pred, states, _ = prednet.score_hypothesis(hyp, cache)
|
||||
|
||||
assert pred.shape == torch.Size([1, 1, pred_hidden])
|
||||
assert len(states) == 2
|
||||
for state_i in states:
|
||||
assert state_i.dtype == pred.dtype
|
||||
assert state_i.device == pred.device
|
||||
assert state_i.shape[1] == len(pred)
|
||||
|
||||
# Blank stateless predict
|
||||
g, states = prednet.predict(y=None, state=None, add_sos=False, batch_size=1)
|
||||
|
||||
assert g.shape == torch.Size([1, 1, pred_hidden])
|
||||
assert len(states) == 2
|
||||
for state_i in states:
|
||||
assert state_i.dtype == g.dtype
|
||||
assert state_i.device == g.device
|
||||
assert state_i.shape[1] == len(g)
|
||||
|
||||
# Blank stateful predict
|
||||
g, states2 = prednet.predict(y=None, state=states, add_sos=False, batch_size=1)
|
||||
|
||||
assert g.shape == torch.Size([1, 1, pred_hidden])
|
||||
assert len(states2) == 2
|
||||
for state_i, state_j in zip(states, states2):
|
||||
assert (state_i - state_j).square().sum().sqrt() > 0.0
|
||||
|
||||
# Predict with token and state
|
||||
token = torch.full([1, 1], fill_value=0, dtype=torch.long)
|
||||
g, states = prednet.predict(y=token, state=states2, add_sos=False, batch_size=None)
|
||||
|
||||
assert g.shape == torch.Size([1, 1, pred_hidden])
|
||||
assert len(states) == 2
|
||||
|
||||
# Predict with blank token and no state
|
||||
token = torch.full([1, 1], fill_value=blank, dtype=torch.long)
|
||||
g, states = prednet.predict(y=token, state=None, add_sos=False, batch_size=None)
|
||||
|
||||
assert g.shape == torch.Size([1, 1, pred_hidden])
|
||||
assert len(states) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_RNNTJoint(self):
|
||||
vocab = list(range(10))
|
||||
vocab = [str(x) for x in vocab]
|
||||
vocab_size = len(vocab)
|
||||
|
||||
batchsize = 4
|
||||
encoder_hidden = 64
|
||||
pred_hidden = 32
|
||||
joint_hidden = 16
|
||||
|
||||
joint_cfg = OmegaConf.create(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'num_classes': vocab_size,
|
||||
'vocabulary': vocab,
|
||||
'jointnet': {
|
||||
'encoder_hidden': encoder_hidden,
|
||||
'pred_hidden': pred_hidden,
|
||||
'joint_hidden': joint_hidden,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
jointnet = modules.RNNTJoint.from_config_dict(joint_cfg)
|
||||
|
||||
enc = torch.zeros(batchsize, encoder_hidden, 48) # [B, D1, T]
|
||||
dec = torch.zeros(batchsize, pred_hidden, 24) # [B, D2, U]
|
||||
|
||||
# forward call test
|
||||
out = jointnet(encoder_outputs=enc, decoder_outputs=dec)
|
||||
assert out.shape == torch.Size([batchsize, 48, 24, vocab_size + 1]) # [B, T, U, V + 1]
|
||||
|
||||
# joint() step test
|
||||
enc2 = enc.transpose(1, 2) # [B, T, D1]
|
||||
dec2 = dec.transpose(1, 2) # [B, U, D2]
|
||||
out2 = jointnet.joint(enc2, dec2) # [B, T, U, V + 1]
|
||||
assert (out - out2).abs().sum() <= 1e-5
|
||||
|
||||
# assert vocab size
|
||||
assert jointnet.num_classes_with_blank == vocab_size + 1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_HATJoint(self):
|
||||
vocab = list(range(10))
|
||||
vocab = [str(x) for x in vocab]
|
||||
vocab_size = len(vocab)
|
||||
|
||||
batchsize = 4
|
||||
encoder_hidden = 64
|
||||
pred_hidden = 32
|
||||
joint_hidden = 16
|
||||
|
||||
joint_cfg = OmegaConf.create(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.HATJoint',
|
||||
'num_classes': vocab_size,
|
||||
'vocabulary': vocab,
|
||||
'jointnet': {
|
||||
'encoder_hidden': encoder_hidden,
|
||||
'pred_hidden': pred_hidden,
|
||||
'joint_hidden': joint_hidden,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
jointnet = modules.HATJoint.from_config_dict(joint_cfg)
|
||||
|
||||
enc = torch.zeros(batchsize, encoder_hidden, 48) # [B, D1, T]
|
||||
dec = torch.zeros(batchsize, pred_hidden, 24) # [B, D2, U]
|
||||
|
||||
# forward call test
|
||||
out = jointnet(encoder_outputs=enc, decoder_outputs=dec)
|
||||
assert out.shape == torch.Size([batchsize, 48, 24, vocab_size + 1]) # [B, T, U, V + 1]
|
||||
|
||||
# joint() step test
|
||||
enc2 = enc.transpose(1, 2) # [B, T, D1]
|
||||
dec2 = dec.transpose(1, 2) # [B, U, D2]
|
||||
out2 = jointnet.joint(enc2, dec2) # [B, T, U, V + 1]
|
||||
assert (out - out2).abs().sum() <= 1e-5
|
||||
|
||||
# joint() step test for internal LM subtraction
|
||||
jointnet.return_hat_ilm = True
|
||||
hat_output = jointnet.joint(enc2, dec2) # HATJointOutput dataclass
|
||||
out3, ilm = hat_output.hat_logprobs, hat_output.ilm_logprobs # [B, T, U, V + 1] and [B, 1, U, V]
|
||||
assert (out - out3).abs().sum() <= 1e-5
|
||||
assert ilm.shape == torch.Size([batchsize, 1, 24, vocab_size]) # [B, 1, U, V] without blank simbol
|
||||
|
||||
# assert vocab size
|
||||
assert jointnet.num_classes_with_blank == vocab_size + 1
|
||||
@@ -0,0 +1,198 @@
|
||||
# Copyright (c) 2020, 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
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models.multitalker_asr_models import EncDecMultiTalkerRNNTBPEModel
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def asr_model(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
model_defaults = {'enc_hidden': 1024, 'pred_hidden': 64}
|
||||
spk_kernel_type = "ff"
|
||||
spk_kernel_layers = [0]
|
||||
add_bg_spk_kernel = True
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'n_layers': 1,
|
||||
'd_model': model_defaults['enc_hidden'], # Required by SpeakerKernelMixin
|
||||
'subsampling': 'dw_striding',
|
||||
'subsampling_factor': 2,
|
||||
'ff_expansion_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 4,
|
||||
'conv_kernel_size': 7,
|
||||
'dropout': 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': model_defaults['pred_hidden'],
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {
|
||||
'joint_hidden': 32,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
'spk_kernel_type': spk_kernel_type,
|
||||
'spk_kernel_layers': spk_kernel_layers,
|
||||
'add_bg_spk_kernel': add_bg_spk_kernel,
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecMultiTalkerRNNTBPEModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecMultiTalkerRNNTBPEModel:
|
||||
@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_constructor(self, asr_model):
|
||||
"""Test model constructor and speaker kernel initialization."""
|
||||
asr_model.train()
|
||||
|
||||
# Check that it's the correct type
|
||||
assert isinstance(asr_model, EncDecMultiTalkerRNNTBPEModel)
|
||||
|
||||
# Check speaker kernel configuration
|
||||
assert hasattr(asr_model, 'spk_kernel_type')
|
||||
assert hasattr(asr_model, 'spk_kernel_layers')
|
||||
assert hasattr(asr_model, 'add_bg_spk_kernel')
|
||||
|
||||
# Check speaker kernel initialization
|
||||
assert asr_model.spk_kernel_type == "ff"
|
||||
assert asr_model.spk_kernel_layers == [0]
|
||||
assert asr_model.add_bg_spk_kernel is True
|
||||
|
||||
# Check speaker kernels exist
|
||||
assert hasattr(asr_model, 'spk_kernels')
|
||||
if asr_model.add_bg_spk_kernel:
|
||||
assert hasattr(asr_model, 'bg_spk_kernels')
|
||||
|
||||
# Test config dict conversion
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecMultiTalkerRNNTBPEModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecMultiTalkerRNNTBPEModel)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, asr_model):
|
||||
"""Test forward pass functionality."""
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
asr_model.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
# Create mock speaker targets
|
||||
batch_size = input_signal.size(0)
|
||||
target_length = 32 # Typical encoder output length for test
|
||||
spk_targets = torch.randint(0, 2, (batch_size, target_length), dtype=torch.float32)
|
||||
bg_spk_targets = torch.randint(0, 2, (batch_size, target_length), dtype=torch.float32)
|
||||
|
||||
# Set speaker targets
|
||||
asr_model.set_speaker_targets(spk_targets, bg_spk_targets)
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
# Set individual speaker targets for each sample
|
||||
asr_model.set_speaker_targets(spk_targets[i : i + 1], bg_spk_targets[i : i + 1])
|
||||
logprobs_ins, _ = asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
asr_model.set_speaker_targets(spk_targets, bg_spk_targets)
|
||||
logprobs_batch, _ = asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-5 # Allow slightly higher tolerance for speaker processing
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_speaker_target_setting(self, asr_model):
|
||||
"""Test speaker target setting functionality."""
|
||||
batch_size = 2
|
||||
target_length = 32
|
||||
|
||||
spk_targets = torch.randint(0, 2, (batch_size, target_length), dtype=torch.float32)
|
||||
bg_spk_targets = torch.randint(0, 2, (batch_size, target_length), dtype=torch.float32)
|
||||
|
||||
# Test setting speaker targets
|
||||
asr_model.set_speaker_targets(spk_targets, bg_spk_targets)
|
||||
assert torch.equal(asr_model.spk_targets, spk_targets)
|
||||
if asr_model.add_bg_spk_kernel:
|
||||
assert torch.equal(asr_model.bg_spk_targets, bg_spk_targets)
|
||||
|
||||
# Test clearing speaker targets
|
||||
asr_model.set_speaker_targets(None, None)
|
||||
assert asr_model.spk_targets is None
|
||||
if asr_model.add_bg_spk_kernel:
|
||||
assert asr_model.bg_spk_targets is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. 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 collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from nemo.collections.asr.parts.submodules.batchnorm import (
|
||||
FusedBatchNorm1d,
|
||||
replace_bn_with_fused_bn,
|
||||
replace_bn_with_fused_bn_all,
|
||||
)
|
||||
|
||||
|
||||
class TestFusedBatchNorm1d:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self):
|
||||
num_features = 10
|
||||
fused_bn = FusedBatchNorm1d(num_features=num_features)
|
||||
assert fused_bn.weight.shape[0] == num_features
|
||||
assert fused_bn.bias.shape[0] == num_features
|
||||
# check initialization: weight is ones, bias is zeros (identity)
|
||||
assert torch.allclose(fused_bn.weight, torch.ones(num_features))
|
||||
assert torch.allclose(fused_bn.bias, torch.zeros(num_features))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_from_batchnorm(self):
|
||||
num_features = 10
|
||||
|
||||
# construct batchnorm
|
||||
bn = nn.BatchNorm1d(num_features=num_features)
|
||||
|
||||
# update bn stats
|
||||
bn.train()
|
||||
batch_size = 4
|
||||
for _ in range(10):
|
||||
_ = bn(torch.rand(batch_size, num_features))
|
||||
|
||||
# test eval mode is equivalent
|
||||
fused_bn = FusedBatchNorm1d.from_batchnorm(bn)
|
||||
bn.eval()
|
||||
|
||||
sample_2d = torch.rand(batch_size, num_features)
|
||||
assert torch.allclose(bn(sample_2d), fused_bn(sample_2d))
|
||||
|
||||
sample_3d = torch.rand(batch_size, num_features, 5)
|
||||
assert torch.allclose(bn(sample_3d), fused_bn(sample_3d))
|
||||
|
||||
|
||||
class TestReplaceBNWithFusedBN:
|
||||
@pytest.mark.unit
|
||||
def test_replace_bn_with_fused_bn(self):
|
||||
model = nn.Sequential(
|
||||
OrderedDict(
|
||||
[
|
||||
("linear1", nn.Linear(1, 10)),
|
||||
("bn1", nn.BatchNorm1d(10)),
|
||||
("relu1", nn.ReLU()),
|
||||
("linear2", nn.Linear(10, 11)),
|
||||
("bn2", nn.BatchNorm1d(11)),
|
||||
(
|
||||
"submodule1",
|
||||
nn.Sequential(OrderedDict([("linear3", nn.Linear(11, 12)), ("bn3", nn.BatchNorm1d(12))])),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
replace_bn_with_fused_bn(model, "submodule1.bn3")
|
||||
assert isinstance(model.bn1, nn.BatchNorm1d)
|
||||
assert isinstance(model.bn2, nn.BatchNorm1d)
|
||||
assert isinstance(model.submodule1.bn3, FusedBatchNorm1d)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_replace_bn_with_fused_bn_all(self):
|
||||
model = nn.Sequential(
|
||||
OrderedDict(
|
||||
[
|
||||
("linear1", nn.Linear(1, 10)),
|
||||
("bn1", nn.BatchNorm1d(10)),
|
||||
("relu1", nn.ReLU()),
|
||||
("linear2", nn.Linear(10, 11)),
|
||||
("bn2", nn.BatchNorm1d(11)),
|
||||
(
|
||||
"submodule1",
|
||||
nn.Sequential(OrderedDict([("linear3", nn.Linear(11, 12)), ("bn3", nn.BatchNorm1d(12))])),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
replace_bn_with_fused_bn_all(model)
|
||||
assert isinstance(model.bn1, FusedBatchNorm1d)
|
||||
assert isinstance(model.bn2, FusedBatchNorm1d)
|
||||
assert isinstance(model.submodule1.bn3, FusedBatchNorm1d)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2021, 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
|
||||
|
||||
import pytest
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models.classification_models import EncDecRegressionModel
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def speech_regression_model():
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': 32,
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'cls': 'nemo.collections.asr.modules.conv_asr.ConvASRDecoderClassification',
|
||||
'params': {'feat_in': 32, 'return_logits': True, 'num_classes': 1},
|
||||
}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'labels': None,
|
||||
'is_regression_task': True,
|
||||
}
|
||||
)
|
||||
model = EncDecRegressionModel(cfg=modelConfig)
|
||||
return model
|
||||
|
||||
|
||||
class TestEncDecRegressionModel:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, speech_regression_model):
|
||||
asr_model = speech_regression_model.train()
|
||||
|
||||
conv_cnt = (64 * 32 * 1 + 32) + (64 * 1 * 1 + 32) # separable kernel + bias + pointwise kernel + bias
|
||||
bn_cnt = (4 * 32) * 2 # 2 * moving averages
|
||||
dec_cnt = 32 * 1 + 1 # fc + bias
|
||||
|
||||
param_count = conv_cnt + bn_cnt + dec_cnt
|
||||
assert asr_model.num_weights == param_count
|
||||
|
||||
# Check to/from config_dict:
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecRegressionModel.from_config_dict(confdict)
|
||||
|
||||
assert isinstance(instance2, EncDecRegressionModel)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_transcription(self, speech_regression_model, test_data_dir):
|
||||
|
||||
audio_filenames = ['an22-flrp-b.wav', 'an90-fbbh-b.wav']
|
||||
audio_paths = [os.path.join(test_data_dir, "asr", "train", "an4", "wav", fp) for fp in audio_filenames]
|
||||
|
||||
model = speech_regression_model.eval()
|
||||
|
||||
# Test Top 1 classification transcription
|
||||
results = model.transcribe(audio_paths, batch_size=2)
|
||||
assert len(results) == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
# Copyright (c) 2020, 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
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.testing.dummies import DummyManifest
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import LhotseSpeechToTextBpeDataset
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from nemo.collections.asr.models.rnnt_bpe_models import EncDecRNNTBPEModel
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def asr_model(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
model_defaults = {'enc_hidden': 1024, 'pred_hidden': 64}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': model_defaults['pred_hidden'],
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {
|
||||
'joint_hidden': 32,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecRNNTBPEModel(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class NestedRNNTModel(ASRModel):
|
||||
def __init__(self, cfg: DictConfig, trainer: 'Trainer' = None):
|
||||
super().__init__(cfg=cfg, trainer=trainer)
|
||||
|
||||
if 'inner_model' in self.cfg:
|
||||
self.register_nemo_submodule(
|
||||
"inner_model", config_field="inner_model", model=EncDecRNNTBPEModel(self.cfg.inner_model)
|
||||
)
|
||||
else:
|
||||
# Restore a model from pretrained checkpoint
|
||||
self.register_nemo_submodule(
|
||||
"inner_model",
|
||||
config_field="inner_model",
|
||||
model=ASRModel.from_pretrained('stt_en_conformer_transducer_small', map_location='cpu'),
|
||||
)
|
||||
|
||||
self.linear = torch.nn.Linear(
|
||||
self.inner_model.tokenizer.vocab_size + 1, self.inner_model.tokenizer.vocab_size + 1
|
||||
)
|
||||
self.inner_model.freeze()
|
||||
|
||||
setup_training_data = lambda *args, **kwargs: None
|
||||
setup_validation_data = lambda *args, **kwargs: None
|
||||
transcribe = lambda *args, **kwargs: []
|
||||
|
||||
|
||||
class TestEncDecRNNTBPEModel:
|
||||
@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_constructor(self, asr_model):
|
||||
asr_model.train()
|
||||
# TODO: make proper config and assert correct number of weights
|
||||
# Check to/from config_dict:
|
||||
confdict = asr_model.to_config_dict()
|
||||
instance2 = EncDecRNNTBPEModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecRNNTBPEModel)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, asr_model):
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
asr_model.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = asr_model.forward(
|
||||
input_signal=input_signal[i : i + 1], input_signal_length=length[i : i + 1]
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = asr_model.forward(input_signal=input_signal, input_signal_length=length)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, asr_model):
|
||||
asr_model = asr_model.eval()
|
||||
cuts = DummyManifest(CutSet, begin_id=0, end_id=1, with_data=True)
|
||||
dataset = LhotseSpeechToTextBpeDataset(tokenizer=asr_model.tokenizer, return_cuts=True)
|
||||
batch = dataset[cuts]
|
||||
outputs = asr_model.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0]) == 2
|
||||
assert isinstance(outputs[0][0], MonoCut)
|
||||
assert isinstance(outputs[0][1], Hypothesis)
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact(self, asr_model):
|
||||
asr_model.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = os.path.join(tmp_dir, 'rnnt_bpe.nemo')
|
||||
asr_model.save_to(path)
|
||||
|
||||
new_model = EncDecRNNTBPEModel.restore_from(path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_spe(self, asr_model, test_data_dir):
|
||||
asr_model.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=tokenizer_dir, new_tokenizer_type='bpe')
|
||||
|
||||
save_path = os.path.join(tmpdir, 'ctc_bpe.nemo')
|
||||
asr_model.train()
|
||||
asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecRNNTBPEModel.restore_from(save_path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.SentencePieceTokenizer)
|
||||
assert new_model.model_path.endswith('_tokenizer.model')
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
assert new_model.spe_vocab_path.endswith('_tokenizer.vocab')
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_agg(self, asr_model, test_data_dir):
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
tok_en = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
# the below is really an english tokenizer but we pretend it is spanish
|
||||
tok_es = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
tcfg = DictConfig({"type": "agg", "langs": {"en": tok_en, "es": tok_es}})
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=tcfg, new_tokenizer_type="agg")
|
||||
|
||||
save_path = os.path.join(tmpdir, "ctc_agg.nemo")
|
||||
asr_model.train()
|
||||
asr_model.save_to(save_path)
|
||||
|
||||
new_model = EncDecRNNTBPEModel.restore_from(save_path)
|
||||
assert isinstance(new_model, type(asr_model))
|
||||
assert isinstance(new_model.tokenizer, tokenizers.AggregateTokenizer)
|
||||
|
||||
# should be double
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 264
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 264
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, test_data_dir, asr_model):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
new_tokenizer_dir = os.path.join(tmpdir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
shutil.copy2(old_tokenizer_dir, new_tokenizer_dir)
|
||||
|
||||
nw1 = asr_model.num_weights
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
# No change
|
||||
assert nw1 == asr_model.num_weights
|
||||
|
||||
with open(os.path.join(new_tokenizer_dir, 'vocab.txt'), 'a+') as f:
|
||||
f.write("!\n")
|
||||
f.write('$\n')
|
||||
f.write('@\n')
|
||||
|
||||
asr_model.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
|
||||
# rnn embedding + joint + bias
|
||||
pred_embedding = 3 * (asr_model.decoder.pred_hidden)
|
||||
joint_joint = 3 * (asr_model.joint.joint_hidden + 1)
|
||||
assert asr_model.num_weights == (nw1 + (pred_embedding + joint_joint))
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, asr_model):
|
||||
assert isinstance(asr_model.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'tsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "tsd"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'alsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
asr_model.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(asr_model.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert asr_model.decoding.decoding.search_type == "alsd"
|
||||
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
def test_save_restore_nested_model(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
model = NestedRNNTModel(cfg=DictConfig({}), trainer=None)
|
||||
path = os.path.join(tmp_dir, 'rnnt_bpe.nemo')
|
||||
model.save_to(path)
|
||||
|
||||
new_model = NestedRNNTModel.restore_from(path, map_location='cpu')
|
||||
assert model.__class__.__name__ == NestedRNNTModel.__name__
|
||||
assert new_model.__class__.__name__ == NestedRNNTModel.__name__
|
||||
assert isinstance(new_model, type(model))
|
||||
|
||||
assert new_model.inner_model.vocab_path.endswith('_vocab.txt')
|
||||
assert len(new_model.inner_model.tokenizer.tokenizer.get_vocab()) == 1024
|
||||
|
||||
# Unpack the nemo file
|
||||
NestedRNNTModel._save_restore_connector._unpack_nemo_file(path, tmp_dir)
|
||||
|
||||
# Check size of the checkpoint, which contains weights from pretrained model + linear layer
|
||||
fp_weights = os.path.join(tmp_dir, 'model_weights.ckpt')
|
||||
assert os.path.getsize(fp_weights) > 50 * (2**20) # Assert the weights are more than 50 MB
|
||||
|
||||
# Check if param after restoration is exact match
|
||||
original_state_dict = model.inner_model.state_dict()
|
||||
new_state_dict = new_model.inner_model.state_dict()
|
||||
|
||||
for (old_name, old_param), (new_name, new_param) in zip(
|
||||
original_state_dict.items(), new_state_dict.items()
|
||||
):
|
||||
assert old_name == new_name
|
||||
assert (old_param - new_param).float().abs().mean() < 1e-6
|
||||
@@ -0,0 +1,409 @@
|
||||
# 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
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models.rnnt_bpe_models_prompt import EncDecRNNTBPEModelWithPrompt
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding as beam_decode
|
||||
from nemo.collections.asr.parts.submodules import rnnt_greedy_decoding as greedy_decode
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.core.utils import numba_utils
|
||||
from nemo.core.utils.numba_utils import __NUMBA_MINIMUM_VERSION__
|
||||
|
||||
NUMBA_RNNT_LOSS_AVAILABLE = numba_utils.numba_cpu_is_supported(
|
||||
__NUMBA_MINIMUM_VERSION__
|
||||
) or numba_utils.numba_cuda_is_supported(__NUMBA_MINIMUM_VERSION__)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rnnt_asr_model_with_prompt(test_data_dir):
|
||||
preprocessor = {'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor', 'params': dict({})}
|
||||
|
||||
model_defaults = {
|
||||
'enc_hidden': 1024,
|
||||
'pred_hidden': 640,
|
||||
'initialize_prompt_feature': True, # Enable prompt feature initialization
|
||||
'num_prompts': 128,
|
||||
'prompt_dictionary': {
|
||||
'en_US': 0,
|
||||
'es_ES': 1,
|
||||
'fr_FR': 2,
|
||||
'de_DE': 3,
|
||||
},
|
||||
}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
decoder = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTDecoder',
|
||||
'prednet': {
|
||||
'pred_hidden': model_defaults['pred_hidden'],
|
||||
'pred_rnn_layers': 1,
|
||||
},
|
||||
}
|
||||
|
||||
joint = {
|
||||
'_target_': 'nemo.collections.asr.modules.RNNTJoint',
|
||||
'jointnet': {
|
||||
'joint_hidden': 640,
|
||||
'activation': 'relu',
|
||||
},
|
||||
}
|
||||
|
||||
decoding = {'strategy': 'greedy_batch', 'greedy': {'max_symbols': 30}}
|
||||
|
||||
tokenizer = {'dir': os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128"), 'type': 'wpe'}
|
||||
|
||||
loss = {'loss_name': 'default', 'warprnnt_numba_kwargs': {'fastemit_lambda': 0.001}}
|
||||
|
||||
modelConfig = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'decoder': DictConfig(decoder),
|
||||
'joint': DictConfig(joint),
|
||||
'tokenizer': DictConfig(tokenizer),
|
||||
'decoding': DictConfig(decoding),
|
||||
'loss': DictConfig(loss),
|
||||
}
|
||||
)
|
||||
|
||||
model_instance = EncDecRNNTBPEModelWithPrompt(cfg=modelConfig)
|
||||
return model_instance
|
||||
|
||||
|
||||
class TestEncDecRNNTBPEModelWithPrompt:
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, rnnt_asr_model_with_prompt):
|
||||
rnnt_asr_model_with_prompt.train()
|
||||
# Check to/from config_dict:
|
||||
confdict = rnnt_asr_model_with_prompt.to_config_dict()
|
||||
instance2 = EncDecRNNTBPEModelWithPrompt.from_config_dict(confdict)
|
||||
assert isinstance(instance2, EncDecRNNTBPEModelWithPrompt)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, rnnt_asr_model_with_prompt):
|
||||
rnnt_asr_model_with_prompt = rnnt_asr_model_with_prompt.eval()
|
||||
|
||||
rnnt_asr_model_with_prompt.preprocessor.featurizer.dither = 0.0
|
||||
rnnt_asr_model_with_prompt.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
rnnt_asr_model_with_prompt.compute_eval_loss = False
|
||||
|
||||
input_signal = torch.randn(size=(4, 512))
|
||||
length = torch.randint(low=321, high=500, size=[4])
|
||||
|
||||
# 1D prompt indices: one language id per sample in the batch.
|
||||
prompt_indices = torch.tensor([0, 1, 2, 3], dtype=torch.long)
|
||||
|
||||
with torch.no_grad():
|
||||
# batch size 1
|
||||
logprobs_instance = []
|
||||
for i in range(input_signal.size(0)):
|
||||
logprobs_ins, _ = rnnt_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal[i : i + 1],
|
||||
input_signal_length=length[i : i + 1],
|
||||
prompt_indices=prompt_indices[i : i + 1],
|
||||
)
|
||||
logprobs_instance.append(logprobs_ins)
|
||||
logits_instance = torch.cat(logprobs_instance, 0)
|
||||
|
||||
# batch size 4
|
||||
logprobs_batch, _ = rnnt_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal, input_signal_length=length, prompt_indices=prompt_indices
|
||||
)
|
||||
|
||||
assert logits_instance.shape == logprobs_batch.shape
|
||||
diff = torch.mean(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
diff = torch.max(torch.abs(logits_instance - logprobs_batch))
|
||||
assert diff <= 1e-6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_predict_step(self, rnnt_asr_model_with_prompt):
|
||||
rnnt_asr_model_with_prompt = rnnt_asr_model_with_prompt.eval()
|
||||
|
||||
# Create a simple batch manually
|
||||
batch_size = 1
|
||||
seq_len = 1600
|
||||
|
||||
# Create mock batch data
|
||||
audio_signal = torch.randn(batch_size, seq_len)
|
||||
audio_lengths = torch.tensor([seq_len])
|
||||
transcript = torch.randint(0, 10, (batch_size, 10))
|
||||
transcript_lengths = torch.tensor([10])
|
||||
prompt_indices = torch.tensor([0], dtype=torch.long) # language id 0
|
||||
|
||||
batch = (audio_signal, audio_lengths, transcript, transcript_lengths, prompt_indices)
|
||||
|
||||
outputs = rnnt_asr_model_with_prompt.predict_step(batch, 0)
|
||||
assert len(outputs) == 1
|
||||
# predict_step returns list of (sample_id, hyp_or_text) pairs
|
||||
assert len(outputs[0]) == 2
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact(self, rnnt_asr_model_with_prompt):
|
||||
rnnt_asr_model_with_prompt.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = os.path.join(tmp_dir, 'rnnt_bpe_prompt.nemo')
|
||||
rnnt_asr_model_with_prompt.save_to(path)
|
||||
|
||||
# restore_from is intentionally delegated to the parent EncDecRNNTBPEModel
|
||||
# to avoid subclass-substitution that would hang on missing prompt_dictionary.
|
||||
new_model = EncDecRNNTBPEModelWithPrompt.restore_from(path)
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 128
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_spe(self, rnnt_asr_model_with_prompt, test_data_dir):
|
||||
rnnt_asr_model_with_prompt.train()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
rnnt_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=tokenizer_dir, new_tokenizer_type='bpe')
|
||||
|
||||
save_path = os.path.join(tmpdir, 'rnnt_bpe_prompt.nemo')
|
||||
rnnt_asr_model_with_prompt.train()
|
||||
rnnt_asr_model_with_prompt.save_to(save_path)
|
||||
|
||||
new_model = EncDecRNNTBPEModelWithPrompt.restore_from(save_path)
|
||||
assert isinstance(new_model.tokenizer, tokenizers.SentencePieceTokenizer)
|
||||
assert new_model.model_path.endswith('_tokenizer.model')
|
||||
assert new_model.vocab_path.endswith('_vocab.txt')
|
||||
assert new_model.spe_vocab_path.endswith('_tokenizer.vocab')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_restore_artifact_agg(self, rnnt_asr_model_with_prompt, test_data_dir):
|
||||
tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_spe_128")
|
||||
tok_en = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
# the below is really an english tokenizer but we pretend it is spanish
|
||||
tok_es = {"dir": tokenizer_dir, "type": "wpe"}
|
||||
tcfg = DictConfig({"type": "agg", "langs": {"en": tok_en, "es": tok_es}})
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
rnnt_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=tcfg, new_tokenizer_type="agg")
|
||||
|
||||
save_path = os.path.join(tmpdir, "rnnt_agg_prompt.nemo")
|
||||
rnnt_asr_model_with_prompt.train()
|
||||
rnnt_asr_model_with_prompt.save_to(save_path)
|
||||
|
||||
new_model = EncDecRNNTBPEModelWithPrompt.restore_from(save_path)
|
||||
assert isinstance(new_model.tokenizer, tokenizers.AggregateTokenizer)
|
||||
|
||||
# Both source tokenizers are the same 132-token vocab; the AggregateTokenizer
|
||||
# deduplicates 10 shared control tokens, so total = 132 + (132 - 10) = 254.
|
||||
assert new_model.tokenizer.tokenizer.vocab_size == 264
|
||||
assert len(new_model.tokenizer.tokenizer.get_vocab()) == 264
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_vocab_change(self, test_data_dir, rnnt_asr_model_with_prompt):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_tokenizer_dir = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
new_tokenizer_dir = os.path.join(tmpdir, 'tokenizer')
|
||||
|
||||
os.makedirs(new_tokenizer_dir, exist_ok=True)
|
||||
shutil.copy2(old_tokenizer_dir, new_tokenizer_dir)
|
||||
|
||||
nw1 = rnnt_asr_model_with_prompt.num_weights
|
||||
rnnt_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
# No change
|
||||
assert nw1 == rnnt_asr_model_with_prompt.num_weights
|
||||
|
||||
with open(os.path.join(new_tokenizer_dir, 'vocab.txt'), 'a+') as f:
|
||||
f.write("!\n")
|
||||
f.write('$\n')
|
||||
f.write('@\n')
|
||||
|
||||
rnnt_asr_model_with_prompt.change_vocabulary(new_tokenizer_dir=new_tokenizer_dir, new_tokenizer_type='wpe')
|
||||
|
||||
# rnn embedding + joint + bias (no CTC decoder in RNNT-only model)
|
||||
pred_embedding = 3 * (rnnt_asr_model_with_prompt.decoder.pred_hidden)
|
||||
joint_joint = 3 * (rnnt_asr_model_with_prompt.joint.joint_hidden + 1)
|
||||
assert rnnt_asr_model_with_prompt.num_weights == (nw1 + (pred_embedding + joint_joint))
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_decoding_change(self, rnnt_asr_model_with_prompt):
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyBatchedRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'greedy'
|
||||
new_strategy.greedy = DictConfig({'max_symbols': 10})
|
||||
rnnt_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, greedy_decode.GreedyRNNTInfer)
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 1})
|
||||
rnnt_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert rnnt_asr_model_with_prompt.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'beam'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
rnnt_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert rnnt_asr_model_with_prompt.decoding.decoding.search_type == "default"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'tsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
rnnt_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert rnnt_asr_model_with_prompt.decoding.decoding.search_type == "tsd"
|
||||
|
||||
new_strategy = DictConfig({})
|
||||
new_strategy.strategy = 'alsd'
|
||||
new_strategy.beam = DictConfig({'beam_size': 2})
|
||||
rnnt_asr_model_with_prompt.change_decoding_strategy(decoding_cfg=new_strategy)
|
||||
assert isinstance(rnnt_asr_model_with_prompt.decoding.decoding, beam_decode.BeamRNNTInfer)
|
||||
assert rnnt_asr_model_with_prompt.decoding.decoding.search_type == "alsd"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_input_output_types_with_prompt(self, rnnt_asr_model_with_prompt):
|
||||
"""Test that input/output types include prompt-specific types."""
|
||||
input_types = rnnt_asr_model_with_prompt.input_types
|
||||
output_types = rnnt_asr_model_with_prompt.output_types
|
||||
|
||||
# Check that prompt_indices is included in input types (1D, per-sample language id)
|
||||
assert 'prompt_indices' in input_types
|
||||
prompt_axes = input_types['prompt_indices'].axes
|
||||
assert len(prompt_axes) == 1 # 1D tensor [B]
|
||||
|
||||
# Check standard input types are present
|
||||
assert 'input_signal' in input_types
|
||||
assert 'input_signal_length' in input_types
|
||||
|
||||
# Check output types
|
||||
assert 'outputs' in output_types
|
||||
assert 'encoded_lengths' in output_types
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_prompt_feature_initialization(self, rnnt_asr_model_with_prompt):
|
||||
"""Test that prompt feature initialization works correctly."""
|
||||
# Test that the model has prompt-related attributes
|
||||
assert hasattr(rnnt_asr_model_with_prompt, 'concat')
|
||||
assert hasattr(rnnt_asr_model_with_prompt, 'num_prompts')
|
||||
assert hasattr(rnnt_asr_model_with_prompt, 'prompt_kernel')
|
||||
|
||||
# Test that concat is enabled
|
||||
assert rnnt_asr_model_with_prompt.concat == True
|
||||
|
||||
# Test prompt kernel dimensions
|
||||
expected_input_size = (
|
||||
rnnt_asr_model_with_prompt.num_prompts + rnnt_asr_model_with_prompt._cfg.model_defaults.enc_hidden
|
||||
)
|
||||
expected_output_size = rnnt_asr_model_with_prompt._cfg.model_defaults.enc_hidden
|
||||
|
||||
# Check first layer of prompt kernel
|
||||
first_layer = rnnt_asr_model_with_prompt.prompt_kernel[0]
|
||||
assert first_layer.in_features == expected_input_size
|
||||
assert first_layer.out_features == expected_output_size * 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_set_inference_prompt(self, rnnt_asr_model_with_prompt):
|
||||
"""Test that set_inference_prompt accepts known languages and rejects unknown ones."""
|
||||
# Known language from the prompt_dictionary fixture
|
||||
rnnt_asr_model_with_prompt.set_inference_prompt('en_US')
|
||||
assert rnnt_asr_model_with_prompt._inference_prompt_index == 0
|
||||
|
||||
rnnt_asr_model_with_prompt.set_inference_prompt('de_DE')
|
||||
assert rnnt_asr_model_with_prompt._inference_prompt_index == 3
|
||||
|
||||
# Unknown language should raise
|
||||
with pytest.raises(ValueError):
|
||||
rnnt_asr_model_with_prompt.set_inference_prompt('zz_ZZ')
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not NUMBA_RNNT_LOSS_AVAILABLE,
|
||||
reason='RNNTLoss has not been compiled with appropriate numba version.',
|
||||
)
|
||||
@pytest.mark.unit
|
||||
def test_forward_different_prompts_produce_different_outputs(self, rnnt_asr_model_with_prompt):
|
||||
"""Different prompt_indices should yield different encoded outputs (prompt actually conditions)."""
|
||||
rnnt_asr_model_with_prompt = rnnt_asr_model_with_prompt.eval()
|
||||
rnnt_asr_model_with_prompt.preprocessor.featurizer.dither = 0.0
|
||||
rnnt_asr_model_with_prompt.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
input_signal = torch.randn(size=(2, 512))
|
||||
length = torch.tensor([512, 512])
|
||||
|
||||
with torch.no_grad():
|
||||
out_a, _ = rnnt_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=length,
|
||||
prompt_indices=torch.tensor([0, 0], dtype=torch.long),
|
||||
)
|
||||
out_b, _ = rnnt_asr_model_with_prompt.forward(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=length,
|
||||
prompt_indices=torch.tensor([1, 1], dtype=torch.long),
|
||||
)
|
||||
|
||||
assert out_a.shape == out_b.shape
|
||||
# The prompt projection should make outputs differ for different language ids.
|
||||
assert torch.max(torch.abs(out_a - out_b)) > 0
|
||||
@@ -0,0 +1,581 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.modules.conformer_encoder import ConformerEncoder
|
||||
from nemo.collections.asr.parts.submodules.multi_head_attention import RoPEMultiHeadAttention, RotaryPositionalEncoding
|
||||
|
||||
|
||||
def _build_encoder(
|
||||
self_attention_model='rope',
|
||||
n_layers=2,
|
||||
d_model=64,
|
||||
n_heads=4,
|
||||
use_pytorch_sdpa=False,
|
||||
use_pytorch_sdpa_backends=None,
|
||||
rotary_fraction=1.0,
|
||||
rope_base=10000.0,
|
||||
pos_emb_max_len=256,
|
||||
):
|
||||
return ConformerEncoder(
|
||||
feat_in=80,
|
||||
n_layers=n_layers,
|
||||
d_model=d_model,
|
||||
n_heads=n_heads,
|
||||
self_attention_model=self_attention_model,
|
||||
subsampling_factor=4,
|
||||
subsampling_conv_channels=32,
|
||||
pos_emb_max_len=pos_emb_max_len,
|
||||
rotary_fraction=rotary_fraction,
|
||||
rope_base=rope_base,
|
||||
use_pytorch_sdpa=use_pytorch_sdpa,
|
||||
use_pytorch_sdpa_backends=use_pytorch_sdpa_backends,
|
||||
dropout=0.0,
|
||||
dropout_att=0.0,
|
||||
dropout_emb=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
).eval()
|
||||
|
||||
|
||||
class TestRotaryPositionalEncoding:
|
||||
@pytest.mark.unit
|
||||
def test_rejects_invalid_rotary_fraction(self):
|
||||
with pytest.raises(ValueError):
|
||||
RotaryPositionalEncoding(d_k=16, rotary_fraction=0.0)
|
||||
with pytest.raises(ValueError):
|
||||
RotaryPositionalEncoding(d_k=16, rotary_fraction=1.5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_odd_effective_dim(self):
|
||||
# d_k * rotary_fraction = 16 * 0.1875 = 3, which is odd
|
||||
with pytest.raises(ValueError):
|
||||
RotaryPositionalEncoding(d_k=16, rotary_fraction=0.1875)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extend_pe_grows_buffers(self):
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=128)
|
||||
pe.extend_pe(64, device=torch.device('cpu'), dtype=torch.float32)
|
||||
assert pe.cos.shape == (64, 16)
|
||||
pe.extend_pe(128, device=torch.device('cpu'), dtype=torch.float32)
|
||||
assert pe.cos.shape == (128, 16)
|
||||
# No-op when buffer is already large enough.
|
||||
prev = pe.cos.data_ptr()
|
||||
pe.extend_pe(64, device=torch.device('cpu'), dtype=torch.float32)
|
||||
assert pe.cos.data_ptr() == prev
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward_first_token_is_identity(self):
|
||||
# Position 0 has zero phase, so cos=1, sin=0 -> rotation is identity.
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=1.0)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
q = torch.randn(2, 4, 8, 16)
|
||||
k = torch.randn(2, 4, 8, 16)
|
||||
q_rot, k_rot = pe(q, k)
|
||||
assert q_rot.shape == q.shape
|
||||
assert k_rot.shape == k.shape
|
||||
assert torch.allclose(q_rot[:, :, 0, :], q[:, :, 0, :], atol=1e-6)
|
||||
assert torch.allclose(k_rot[:, :, 0, :], k[:, :, 0, :], atol=1e-6)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_partial_rotation_leaves_tail_unchanged(self):
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=0.5)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
q = torch.randn(2, 4, 8, 16)
|
||||
k = torch.randn(2, 4, 8, 16)
|
||||
q_rot, k_rot = pe(q, k)
|
||||
# The last (d_k - d_k_rot) = 8 dims of each head must pass through untouched.
|
||||
assert torch.allclose(q_rot[..., pe.d_k_rot :], q[..., pe.d_k_rot :])
|
||||
assert torch.allclose(k_rot[..., pe.d_k_rot :], k[..., pe.d_k_rot :])
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_dot_product_translation_invariance(self):
|
||||
# The defining property of RoPE: for the same q and k content, <q_m, k_n>
|
||||
# depends only on the position difference (m - n). Pick two (m, n) pairs
|
||||
# that share the same difference and assert the dot products agree.
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=1.0)
|
||||
pe.extend_pe(64, device=torch.device('cpu'), dtype=torch.float32)
|
||||
|
||||
torch.manual_seed(0)
|
||||
q_content = torch.randn(1, 1, 1, 16)
|
||||
k_content = torch.randn(1, 1, 1, 16)
|
||||
|
||||
def dot_at(m, n):
|
||||
cos_q = pe.cos[m : m + 1].view(1, 1, 1, 16)
|
||||
sin_q = pe.sin[m : m + 1].view(1, 1, 1, 16)
|
||||
cos_k = pe.cos[n : n + 1].view(1, 1, 1, 16)
|
||||
sin_k = pe.sin[n : n + 1].view(1, 1, 1, 16)
|
||||
q_r = pe._apply_rotary(q_content, cos_q, sin_q)
|
||||
k_r = pe._apply_rotary(k_content, cos_k, sin_k)
|
||||
return (q_r * k_r).sum()
|
||||
|
||||
# Three (m, n) pairs with the same difference n - m = 3.
|
||||
d_a = dot_at(2, 5)
|
||||
d_b = dot_at(10, 13)
|
||||
d_c = dot_at(40, 43)
|
||||
assert torch.allclose(d_a, d_b, atol=1e-5)
|
||||
assert torch.allclose(d_a, d_c, atol=1e-5)
|
||||
|
||||
# Sanity: a different position difference must yield a different dot product
|
||||
# (otherwise the rotation is a no-op or degenerate).
|
||||
d_diff = dot_at(2, 7) # difference 5
|
||||
assert not torch.allclose(d_a, d_diff, atol=1e-3)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rotation_is_not_identity(self):
|
||||
# Confirm RoPE actually mutates Q/K at non-zero positions.
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=1.0)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
q = torch.randn(1, 1, 8, 16)
|
||||
k = torch.randn(1, 1, 8, 16)
|
||||
q_rot, k_rot = pe(q, k)
|
||||
# Tokens after position 0 must change.
|
||||
assert not torch.allclose(q_rot[:, :, 1:, :], q[:, :, 1:, :], atol=1e-3)
|
||||
assert not torch.allclose(k_rot[:, :, 1:, :], k[:, :, 1:, :], atol=1e-3)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_norm_preservation(self):
|
||||
# Rotation is unitary: ||q_rot[..., t, :]||_2 == ||q[..., t, :]||_2 per (batch, head, t).
|
||||
# Catches scaling bugs in _apply_rotary.
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=1.0)
|
||||
pe.extend_pe(64, device=torch.device('cpu'), dtype=torch.float32)
|
||||
q = torch.randn(2, 4, 16, 16)
|
||||
k = torch.randn(2, 4, 16, 16)
|
||||
q_rot, k_rot = pe(q, k)
|
||||
q_norm_in = torch.linalg.norm(q, dim=-1)
|
||||
q_norm_out = torch.linalg.norm(q_rot, dim=-1)
|
||||
k_norm_in = torch.linalg.norm(k, dim=-1)
|
||||
k_norm_out = torch.linalg.norm(k_rot, dim=-1)
|
||||
assert torch.allclose(q_norm_in, q_norm_out, atol=1e-5)
|
||||
assert torch.allclose(k_norm_in, k_norm_out, atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reference_equivalence(self):
|
||||
# Slow split-half RoPE reference written in explicit-2D-rotation form
|
||||
# (no _rotate_half trick, no cat-duplicated cos/sin). Same math as the
|
||||
# production code expressed via a disjoint code path, so a bug in either
|
||||
# _rotate_half or the cos/sin layout would surface here.
|
||||
d_k = 16
|
||||
pe = RotaryPositionalEncoding(d_k=d_k, rotary_fraction=1.0)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
|
||||
torch.manual_seed(0)
|
||||
q = torch.randn(1, 1, 8, d_k)
|
||||
k = torch.randn(1, 1, 8, d_k)
|
||||
q_rot, k_rot = pe(q, k)
|
||||
|
||||
d_half = d_k // 2
|
||||
positions = torch.arange(8, dtype=torch.float32)
|
||||
theta = positions[:, None] * pe.inv_freq[None, :] # (T, d_half)
|
||||
c = theta.cos()
|
||||
s = theta.sin()
|
||||
|
||||
def rope_ref(x):
|
||||
# Rotate each (x[..., i], x[..., i + d_half]) pair by angle theta[t, i].
|
||||
x_a = x[..., :d_half]
|
||||
x_b = x[..., d_half:]
|
||||
y_a = x_a * c - x_b * s
|
||||
y_b = x_a * s + x_b * c
|
||||
return torch.cat((y_a, y_b), dim=-1)
|
||||
|
||||
assert torch.allclose(q_rot, rope_ref(q), atol=1e-6)
|
||||
assert torch.allclose(k_rot, rope_ref(k), atol=1e-6)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extend_preserves_existing_positions(self):
|
||||
# Extending the cos/sin buffers must not change the values at previously
|
||||
# covered positions, otherwise streaming forward calls would silently
|
||||
# produce different rotations across the extension boundary.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=64)
|
||||
pe.extend_pe(64, device=torch.device('cpu'), dtype=torch.float32)
|
||||
cos_before = pe.cos[:64].clone()
|
||||
sin_before = pe.sin[:64].clone()
|
||||
pe.extend_pe(256, device=torch.device('cpu'), dtype=torch.float32)
|
||||
assert torch.equal(pe.cos[:64], cos_before)
|
||||
assert torch.equal(pe.sin[:64], sin_before)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_contiguous_inputs(self):
|
||||
# Real-world callers may pass non-contiguous Q/K (e.g. from .transpose()).
|
||||
# The rotation must produce the same result as on the contiguous version.
|
||||
pe = RotaryPositionalEncoding(d_k=16, rotary_fraction=1.0)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
|
||||
# Build (B, T, H, D) and transpose to (B, H, T, D) -> non-contiguous.
|
||||
q_btnd = torch.randn(2, 8, 4, 16)
|
||||
k_btnd = torch.randn(2, 8, 4, 16)
|
||||
q_nc = q_btnd.transpose(1, 2)
|
||||
k_nc = k_btnd.transpose(1, 2)
|
||||
assert not q_nc.is_contiguous() and not k_nc.is_contiguous()
|
||||
|
||||
q_rot_nc, k_rot_nc = pe(q_nc, k_nc)
|
||||
q_rot_c, k_rot_c = pe(q_nc.contiguous(), k_nc.contiguous())
|
||||
assert torch.allclose(q_rot_nc, q_rot_c, atol=1e-6)
|
||||
assert torch.allclose(k_rot_nc, k_rot_c, atol=1e-6)
|
||||
|
||||
|
||||
class TestRoPEMultiHeadAttention:
|
||||
@pytest.mark.unit
|
||||
def test_rejects_pos_enc_with_wrong_d_k(self):
|
||||
# n_feat / n_head = 64 / 4 = 16, but pos_enc was built with d_k=32.
|
||||
bad_pe = RotaryPositionalEncoding(d_k=32, max_len=64)
|
||||
with pytest.raises(ValueError):
|
||||
RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=bad_pe)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_v_unchanged_by_rotation(self):
|
||||
# Confirm the rotation hook is called only with (q, k); V must never reach
|
||||
# the positional encoder. Catches a future regression where someone adds
|
||||
# V to the rotation hook signature.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=32)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
attn = RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=pe).eval()
|
||||
|
||||
call_args = []
|
||||
original_forward = pe.forward
|
||||
|
||||
def spy(q, k):
|
||||
call_args.append((q.shape, k.shape))
|
||||
return original_forward(q, k)
|
||||
|
||||
attn.pos_enc.forward = spy
|
||||
x = torch.randn(2, 16, 64)
|
||||
with torch.no_grad():
|
||||
_ = attn(query=x, key=x, value=x, mask=None)
|
||||
assert len(call_args) == 1
|
||||
q_shape, k_shape = call_args[0]
|
||||
# Both tensors have the same length (16); the layout is (B, H, T, d_k).
|
||||
assert q_shape == (2, 4, 16, 16)
|
||||
assert k_shape == (2, 4, 16, 16)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_backward_smoke(self):
|
||||
# Forward → loss → backward → every learnable param has a non-NaN, non-zero
|
||||
# gradient. Mirrors test_transformer_encoder.py::test_backward_pass.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=32)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
attn = RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=pe).train()
|
||||
x = torch.randn(2, 8, 64, requires_grad=True)
|
||||
out = attn(query=x, key=x, value=x, mask=None)
|
||||
loss = out.sum()
|
||||
loss.backward()
|
||||
for name, param in attn.named_parameters():
|
||||
assert param.grad is not None, f"No gradient for {name}"
|
||||
assert not torch.isnan(param.grad).any(), f"NaN gradient for {name}"
|
||||
assert (param.grad != 0).any(), f"All-zero gradient for {name}"
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("backend", ['MATH', 'FLASH_ATTENTION', 'EFFICIENT_ATTENTION', 'CUDNN_ATTENTION'])
|
||||
def test_sdpa_backend_smoke_gpu(self, backend):
|
||||
# Each SDPA backend must run with RoPE pre-rotation under bf16 autocast
|
||||
# (the production training path) without falling back or crashing on
|
||||
# shape/dtype constraints. FLASH/EFFICIENT/CUDNN require fp16/bf16;
|
||||
# bf16 satisfies all four.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=32).to("cuda")
|
||||
pe.extend_pe(32, device=torch.device('cuda'), dtype=torch.float32)
|
||||
attn = (
|
||||
RoPEMultiHeadAttention(
|
||||
n_head=4,
|
||||
n_feat=64,
|
||||
dropout_rate=0.0,
|
||||
pos_enc=pe,
|
||||
use_pytorch_sdpa=True,
|
||||
use_pytorch_sdpa_backends=[backend],
|
||||
)
|
||||
.to("cuda")
|
||||
.eval()
|
||||
)
|
||||
x = torch.randn(2, 16, 64, device='cuda')
|
||||
with torch.no_grad(), torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16):
|
||||
out = attn(query=x, key=x, value=x, mask=None)
|
||||
assert out.shape == (2, 16, 64)
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
def test_autocast_gpu(self):
|
||||
# Mixed-precision forward (CUDA autocast in bf16) must produce finite output.
|
||||
# Exercises the interaction between RoPE's .to(q.dtype) cast and the
|
||||
# avoid_float16_autocast_context wrapper in the base MHA.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=32).to("cuda")
|
||||
pe.extend_pe(32, device=torch.device('cuda'), dtype=torch.float32)
|
||||
attn = RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=pe).to("cuda").eval()
|
||||
x = torch.randn(2, 16, 64, device='cuda')
|
||||
with torch.no_grad(), torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16):
|
||||
out = attn(query=x, key=x, value=x, mask=None)
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"dtype,atol",
|
||||
[(torch.float32, 1e-5), (torch.bfloat16, 5e-2), (torch.float16, 1e-2)],
|
||||
)
|
||||
def test_dtype_stability_gpu(self, dtype, atol):
|
||||
# Forward in low precision must stay close to the fp32 reference.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=32).to("cuda")
|
||||
pe.extend_pe(32, device=torch.device('cuda'), dtype=torch.float32)
|
||||
attn = RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=pe).to("cuda").eval()
|
||||
torch.manual_seed(0)
|
||||
x = torch.randn(2, 16, 64, device='cuda')
|
||||
with torch.no_grad():
|
||||
out_ref = attn(query=x, key=x, value=x, mask=None)
|
||||
|
||||
# `attn.to(dtype=...)` converts every buffer including pos_enc.cos/sin to `dtype`.
|
||||
attn_dt = attn.to(dtype=dtype)
|
||||
x_dt = x.to(dtype=dtype)
|
||||
with torch.no_grad():
|
||||
out_dt = attn_dt(query=x_dt, key=x_dt, value=x_dt, mask=None)
|
||||
assert torch.isfinite(out_dt).all()
|
||||
assert torch.allclose(out_dt.float(), out_ref, atol=atol, rtol=atol)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_streaming_matches_offline(self):
|
||||
# The load-bearing test for the cache_len offset logic. Feeding the last
|
||||
# `new_len` tokens with the first `cache_len` tokens as KV cache must
|
||||
# reproduce the corresponding slice of the offline forward, because RoPE
|
||||
# depends only on the (m - n) position difference and the cache layout
|
||||
# preserves that.
|
||||
pe = RotaryPositionalEncoding(d_k=16, max_len=64)
|
||||
pe.extend_pe(32, device=torch.device('cpu'), dtype=torch.float32)
|
||||
attn = RoPEMultiHeadAttention(n_head=4, n_feat=64, dropout_rate=0.0, pos_enc=pe).eval()
|
||||
attn.cache_drop_size = 0 # required by update_cache
|
||||
|
||||
torch.manual_seed(7)
|
||||
full_seq = torch.randn(1, 12, 64)
|
||||
cache_len = 8
|
||||
|
||||
with torch.no_grad():
|
||||
offline_out = attn(query=full_seq, key=full_seq, value=full_seq, mask=None)
|
||||
new_query = full_seq[:, cache_len:]
|
||||
cache = full_seq[:, :cache_len]
|
||||
streaming_out, _ = attn(query=new_query, key=new_query, value=new_query, mask=None, cache=cache)
|
||||
|
||||
assert torch.allclose(streaming_out, offline_out[:, cache_len:], atol=1e-5)
|
||||
|
||||
|
||||
class TestConformerEncoderRoPE:
|
||||
@pytest.mark.unit
|
||||
def test_pos_enc_shared_across_layers(self):
|
||||
# Critical: every layer must hold the same pos_enc instance so that the
|
||||
# encoder's set_max_audio_length / extend_pe grows the buffers used by
|
||||
# every layer (not just the first).
|
||||
enc = _build_encoder()
|
||||
assert all(layer.self_attn.pos_enc is enc.pos_enc for layer in enc.layers)
|
||||
# And exercising the shared-extend path: growing the buffer once must be
|
||||
# visible from every layer.
|
||||
enc.pos_enc.extend_pe(512, device=torch.device('cpu'), dtype=torch.float32)
|
||||
assert all(layer.self_attn.pos_enc.cos.size(0) >= 512 for layer in enc.layers)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sdpa_matches_manual(self):
|
||||
# CPU fp32: SDPA falls back to MATH; verify it matches the manual matmul
|
||||
# path so RoPE pre-rotation is applied consistently across both code paths.
|
||||
enc_manual = _build_encoder(use_pytorch_sdpa=False)
|
||||
enc_sdpa = _build_encoder(use_pytorch_sdpa=True)
|
||||
enc_sdpa.load_state_dict(enc_manual.state_dict(), strict=False)
|
||||
x = torch.randn(2, 80, 200)
|
||||
lens = torch.tensor([200, 150])
|
||||
with torch.no_grad():
|
||||
o_manual, _ = enc_manual(audio_signal=x, length=lens)
|
||||
o_sdpa, _ = enc_sdpa(audio_signal=x, length=lens)
|
||||
assert torch.allclose(o_manual, o_sdpa, atol=1e-4, rtol=1e-4)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("backend", ['MATH', 'EFFICIENT_ATTENTION', 'CUDNN_ATTENTION'])
|
||||
def test_sdpa_backend_matches_manual_gpu(self, backend):
|
||||
# Forward + backward parity vs the manual path under bf16 autocast.
|
||||
# RoPE applies to Q/K before the SDPA call, so each backend sees the
|
||||
# same rotated tensors and must agree on outputs and gradients within
|
||||
# bf16 tolerance. FLASH_ATTENTION is excluded because PyTorch rejects
|
||||
# any non-null `attn_mask` on the Flash kernel and the encoder always
|
||||
# emits a padding mask; CUDNN and EFFICIENT both accept bool masks.
|
||||
# The MHA-level smoke test covers FLASH with mask=None.
|
||||
enc_manual = _build_encoder(use_pytorch_sdpa=False).to("cuda")
|
||||
enc_sdpa = _build_encoder(use_pytorch_sdpa=True, use_pytorch_sdpa_backends=[backend]).to("cuda")
|
||||
enc_sdpa.load_state_dict(enc_manual.state_dict(), strict=False)
|
||||
|
||||
torch.manual_seed(0)
|
||||
x_base = torch.randn(2, 80, 200, device='cuda')
|
||||
x_manual = x_base.clone().requires_grad_(True)
|
||||
x_sdpa = x_base.clone().requires_grad_(True)
|
||||
lens = torch.tensor([200, 150], device='cuda')
|
||||
|
||||
with torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16):
|
||||
o_manual, _ = enc_manual(audio_signal=x_manual, length=lens)
|
||||
o_sdpa, _ = enc_sdpa(audio_signal=x_sdpa, length=lens)
|
||||
|
||||
# Forward parity.
|
||||
assert torch.allclose(o_manual.float(), o_sdpa.float(), atol=5e-2, rtol=5e-2)
|
||||
|
||||
# Backward parity: same loss, compare input grads and weight grads.
|
||||
o_manual.sum().backward()
|
||||
o_sdpa.sum().backward()
|
||||
assert torch.allclose(x_manual.grad.float(), x_sdpa.grad.float(), atol=5e-2, rtol=5e-2)
|
||||
for (n1, p1), (n2, p2) in zip(enc_manual.named_parameters(), enc_sdpa.named_parameters()):
|
||||
assert n1 == n2
|
||||
assert p1.grad is not None and p2.grad is not None, f"missing grad for {n1}"
|
||||
assert torch.allclose(p1.grad.float(), p2.grad.float(), atol=5e-2, rtol=5e-2), f"grad mismatch for {n1}"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_padding_does_not_leak(self):
|
||||
# Output for the valid prefix must be invariant to the values in the
|
||||
# padded suffix.
|
||||
enc = _build_encoder()
|
||||
x = torch.randn(1, 80, 200)
|
||||
valid_len = 120
|
||||
x1 = x.clone()
|
||||
x1[0, :, valid_len:] = torch.randn(80, 200 - valid_len)
|
||||
x2 = x.clone()
|
||||
x2[0, :, valid_len:] = torch.randn(80, 200 - valid_len)
|
||||
lens = torch.tensor([valid_len])
|
||||
with torch.no_grad():
|
||||
o1, _ = enc(audio_signal=x1, length=lens)
|
||||
o2, _ = enc(audio_signal=x2, length=lens)
|
||||
valid_out_len = valid_len // 4
|
||||
assert torch.allclose(o1[..., :valid_out_len], o2[..., :valid_out_len], atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_change_attention_model_to_rope(self):
|
||||
# Build a rel_pos encoder, swap to rope, run forward.
|
||||
enc = _build_encoder(self_attention_model='rel_pos')
|
||||
enc._cfg = OmegaConf.create(
|
||||
{
|
||||
'd_model': 64,
|
||||
'n_heads': 4,
|
||||
'dropout': 0.0,
|
||||
'dropout_att': 0.0,
|
||||
'dropout_emb': 0.0,
|
||||
'pos_emb_max_len': 256,
|
||||
'rope_base': 10000.0,
|
||||
'rotary_fraction': 1.0,
|
||||
}
|
||||
)
|
||||
enc.change_attention_model('rope')
|
||||
assert isinstance(enc.pos_enc, RotaryPositionalEncoding)
|
||||
assert all(layer.self_attn.pos_enc is enc.pos_enc for layer in enc.layers)
|
||||
x = torch.randn(2, 80, 200)
|
||||
lens = torch.tensor([200, 150])
|
||||
out, _ = enc(audio_signal=x, length=lens)
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_change_attention_model_preserves_use_bias_false(self):
|
||||
# Regression: the swap loop in change_attention_model was building the new
|
||||
# attention without forwarding use_bias, so a use_bias=False model silently
|
||||
# gained randomly-initialised bias parameters after a swap.
|
||||
from nemo.collections.asr.modules.conformer_encoder import ConformerEncoder
|
||||
|
||||
enc = ConformerEncoder(
|
||||
feat_in=80,
|
||||
n_layers=2,
|
||||
d_model=64,
|
||||
n_heads=4,
|
||||
self_attention_model='rel_pos',
|
||||
subsampling_factor=4,
|
||||
subsampling_conv_channels=32,
|
||||
pos_emb_max_len=256,
|
||||
use_bias=False,
|
||||
dropout=0.0,
|
||||
dropout_att=0.0,
|
||||
dropout_emb=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
).eval()
|
||||
enc._cfg = OmegaConf.create(
|
||||
{
|
||||
'd_model': 64,
|
||||
'n_heads': 4,
|
||||
'dropout': 0.0,
|
||||
'dropout_att': 0.0,
|
||||
'dropout_emb': 0.0,
|
||||
'pos_emb_max_len': 256,
|
||||
'rope_base': 10000.0,
|
||||
'rotary_fraction': 1.0,
|
||||
'use_bias': False,
|
||||
}
|
||||
)
|
||||
# Pre-condition: rel_pos attention has no biases.
|
||||
for layer in enc.layers:
|
||||
assert layer.self_attn.linear_q.bias is None
|
||||
|
||||
enc.change_attention_model('rope')
|
||||
|
||||
# Post-condition: still no biases — use_bias preserved through the swap.
|
||||
for layer in enc.layers:
|
||||
assert layer.self_attn.linear_q.bias is None
|
||||
assert layer.self_attn.linear_k.bias is None
|
||||
assert layer.self_attn.linear_v.bias is None
|
||||
assert layer.self_attn.linear_out.bias is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_change_attention_model_preserves_cfg_on_partial_update(self):
|
||||
# Regression: ASRModuleMixin.change_attention_model used to write the *raw*
|
||||
# kwargs into self.cfg.encoder, so a partial update like
|
||||
# `change_attention_model(rotary_fraction=0.5)` left
|
||||
# cfg.encoder.self_attention_model = None (corrupting the saved config) and
|
||||
# skipped writing the rope fields entirely.
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.models import EncDecCTCModel
|
||||
|
||||
encoder_cfg = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': 64,
|
||||
'n_layers': 2,
|
||||
'd_model': 64,
|
||||
'n_heads': 4,
|
||||
'self_attention_model': 'rope',
|
||||
'subsampling_factor': 4,
|
||||
'subsampling_conv_channels': 32,
|
||||
'pos_emb_max_len': 256,
|
||||
'rope_base': 10000.0,
|
||||
'rotary_fraction': 1.0,
|
||||
'dropout': 0.0,
|
||||
'dropout_att': 0.0,
|
||||
'dropout_emb': 0.0,
|
||||
'dropout_pre_encoder': 0.0,
|
||||
}
|
||||
decoder_cfg = {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': None,
|
||||
'num_classes': 28,
|
||||
'vocabulary': list("abcdefghijklmnopqrstuvwxyz '"),
|
||||
}
|
||||
preproc_cfg = {'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor'}
|
||||
model = EncDecCTCModel(
|
||||
cfg=DictConfig(
|
||||
{
|
||||
'preprocessor': preproc_cfg,
|
||||
'encoder': encoder_cfg,
|
||||
'decoder': decoder_cfg,
|
||||
'optim': {'name': 'adamw'},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Partial update: only rotary_fraction is being changed.
|
||||
model.change_attention_model(rotary_fraction=0.5)
|
||||
|
||||
# cfg.encoder must reflect the resolved values, not the None kwargs.
|
||||
assert model.cfg.encoder.self_attention_model == 'rope'
|
||||
assert model.cfg.encoder.att_context_size is not None
|
||||
assert model.cfg.encoder.rotary_fraction == 0.5
|
||||
assert model.cfg.encoder.rope_base == 10000.0
|
||||
# Live encoder agrees.
|
||||
assert model.encoder.self_attention_model == 'rope'
|
||||
assert model.encoder.pos_enc.d_k_rot == 8 # d_k=16, fraction=0.5
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.data import audio_to_text
|
||||
from nemo.collections.asr.parts.utils.asr_batching import SemiSortBatchSampler
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import write_manifest
|
||||
|
||||
|
||||
class TestASRSamplers:
|
||||
labels = [
|
||||
" ",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"'",
|
||||
]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ssb_sampler(self):
|
||||
# Generate random signals
|
||||
data_min_duration = 0.1
|
||||
data_max_duration = 16.7
|
||||
|
||||
random_seed = 42
|
||||
sample_rate = 16000
|
||||
|
||||
_rng = np.random.default_rng(seed=random_seed)
|
||||
|
||||
def generate_samples(num_examples: int) -> list:
|
||||
data_duration = np.round(_rng.uniform(low=data_min_duration, high=data_max_duration, size=num_examples), 3)
|
||||
data_duration_samples = np.floor(data_duration * sample_rate).astype(int)
|
||||
samples = []
|
||||
for data_duration_sample in data_duration_samples:
|
||||
samples.append(_rng.uniform(low=-0.5, high=0.5, size=(data_duration_sample)))
|
||||
return samples
|
||||
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
# Build metadata for manifest
|
||||
metadata = []
|
||||
|
||||
# Test size of dataloader with and without ssb
|
||||
for num_samples in np.concatenate([np.array([1, 2]), _rng.integers(3, 10, 2), _rng.integers(10, 1000, 2)]):
|
||||
samples = generate_samples(num_samples)
|
||||
|
||||
for n, sample in enumerate(samples):
|
||||
meta = dict()
|
||||
signal_filename = f'{n:04d}.wav'
|
||||
# write audio files
|
||||
sf.write(os.path.join(test_dir, signal_filename), sample, sample_rate)
|
||||
# update metadata
|
||||
meta['audio_filepath'] = os.path.join(test_dir, signal_filename)
|
||||
meta['duration'] = len(sample) / sample_rate
|
||||
meta['text'] = 'non empty'
|
||||
metadata.append(meta)
|
||||
|
||||
# Save manifest
|
||||
manifest_filepath = os.path.join(test_dir, 'manifest.json')
|
||||
write_manifest(manifest_filepath, metadata)
|
||||
|
||||
# Make dataset
|
||||
dataset = audio_to_text.AudioToCharDataset(
|
||||
manifest_filepath=manifest_filepath,
|
||||
labels=self.labels,
|
||||
sample_rate=sample_rate,
|
||||
max_duration=data_max_duration,
|
||||
min_duration=data_min_duration,
|
||||
)
|
||||
durations = [sample.duration for sample in dataset.manifest_processor.collection.data]
|
||||
|
||||
# Compare two dataloader
|
||||
for batch_size in _rng.integers(1, n + 20, 5):
|
||||
batch_size = int(batch_size)
|
||||
drop_last = True if _rng.integers(0, 2) else False
|
||||
sampler = SemiSortBatchSampler(
|
||||
global_rank=0,
|
||||
world_size=1,
|
||||
durations=durations,
|
||||
batch_size=batch_size,
|
||||
batch_shuffle=True,
|
||||
drop_last=drop_last,
|
||||
randomization_factor=0.1,
|
||||
seed=random_seed,
|
||||
)
|
||||
dataloader_with_ssb = torch.utils.data.DataLoader(
|
||||
dataset=dataset,
|
||||
batch_size=None,
|
||||
sampler=sampler,
|
||||
batch_sampler=None,
|
||||
collate_fn=lambda x: audio_to_text._speech_collate_fn(x, pad_id=0),
|
||||
)
|
||||
dataloader = torch.utils.data.DataLoader(
|
||||
dataset=dataset,
|
||||
batch_size=batch_size,
|
||||
collate_fn=lambda x: audio_to_text._speech_collate_fn(x, pad_id=0),
|
||||
drop_last=drop_last,
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
assert abs(len(dataloader) - len(dataloader_with_ssb)) == 0, (
|
||||
"Different num of batches with batch! Num of batches with ssb is "
|
||||
f"{len(dataloader_with_ssb)} and without ssb is {len(dataloader)}!"
|
||||
)
|
||||
|
||||
dataloader_with_ssb_exception, dataloader_exception = False, False
|
||||
|
||||
try:
|
||||
list(dataloader_with_ssb)
|
||||
except:
|
||||
dataloader_with_ssb_exception = True
|
||||
|
||||
try:
|
||||
list(dataloader)
|
||||
except:
|
||||
dataloader_exception = True
|
||||
|
||||
assert dataloader_with_ssb_exception == dataloader_exception
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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 pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
|
||||
class TestASRSubsamplingConvChunking:
|
||||
@pytest.mark.with_downloads()
|
||||
@pytest.mark.unit
|
||||
def test_forward(self):
|
||||
asr_model = ASRModel.from_pretrained("stt_en_fastconformer_ctc_large")
|
||||
asr_model = asr_model.eval()
|
||||
asr_model.preprocessor.featurizer.dither = 0.0
|
||||
asr_model.preprocessor.featurizer.pad_to = 0
|
||||
|
||||
len = 512
|
||||
|
||||
input_signal_batch1 = torch.randn(size=(1, len), device=asr_model.device)
|
||||
length_batch1 = torch.randint(low=321, high=500, size=[1], device=asr_model.device)
|
||||
|
||||
input_signal_batch4 = torch.randn(size=(4, len), device=asr_model.device)
|
||||
length_batch4 = torch.randint(low=321, high=500, size=[4], device=asr_model.device)
|
||||
|
||||
with torch.inference_mode():
|
||||
# regular inference
|
||||
logprobs_batch1_nosplit, _, _ = asr_model.forward(
|
||||
input_signal=input_signal_batch1, input_signal_length=length_batch1
|
||||
)
|
||||
logprobs_batch4_nosplit, _, _ = asr_model.forward(
|
||||
input_signal=input_signal_batch4, input_signal_length=length_batch4
|
||||
)
|
||||
|
||||
# force chunking to 2
|
||||
asr_model.change_subsampling_conv_chunking_factor(subsampling_conv_chunking_factor=2)
|
||||
|
||||
# chunked inference by channels as batch is 1
|
||||
logprobs_batch1_split, _, _ = asr_model.forward(
|
||||
input_signal=input_signal_batch1, input_signal_length=length_batch1
|
||||
)
|
||||
# chunked inference by batch as it is 4 [> 1]
|
||||
logprobs_batch4_split, _, _ = asr_model.forward(
|
||||
input_signal=input_signal_batch4, input_signal_length=length_batch4
|
||||
)
|
||||
|
||||
diff = torch.mean(torch.abs(logprobs_batch1_split - logprobs_batch1_nosplit))
|
||||
assert diff <= 0.2
|
||||
diff = torch.mean(torch.abs(logprobs_batch4_split - logprobs_batch4_nosplit))
|
||||
assert diff <= 0.2
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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 lightning.pytorch import Trainer
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
|
||||
from nemo.collections.asr.models import EncDecCTCModelBPE
|
||||
from nemo.collections.asr.parts.context_biasing.boosting_graph_batched import (
|
||||
BoostingTreeModelConfig,
|
||||
GPUBoostingTreeModel,
|
||||
)
|
||||
from nemo.collections.asr.parts.context_biasing.context_graph_universal import ContextGraph
|
||||
|
||||
DEVICES = [torch.device("cpu")]
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append(torch.device("cuda"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_context_graph():
|
||||
phrases = ["abc", "abd", "c"]
|
||||
phrases_ids = [[1, 2, 3], [1, 2, 4], [3]]
|
||||
scores = [0.0, 0.0, 0.0]
|
||||
context_graph = ContextGraph(context_score=1.0, depth_scaling=1.0)
|
||||
context_graph.build(token_ids=phrases_ids, phrases=phrases, scores=scores, uniform_weights=False)
|
||||
return context_graph
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_boosting_tree(test_context_graph):
|
||||
boosting_tree = GPUBoostingTreeModel.from_context_graph(
|
||||
context_graph=test_context_graph,
|
||||
vocab_size=5,
|
||||
unk_score=0.0,
|
||||
final_eos_score=0.0,
|
||||
use_triton=True,
|
||||
uniform_weights=False,
|
||||
)
|
||||
return boosting_tree
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def conformer_ctc_bpe_model():
|
||||
model = EncDecCTCModelBPE.from_pretrained(model_name="stt_en_conformer_ctc_small")
|
||||
model.set_trainer(Trainer(devices=1, accelerator="cpu"))
|
||||
model = model.eval()
|
||||
return model
|
||||
|
||||
|
||||
class TestGPUBoostingTreeModel:
|
||||
@pytest.mark.unit
|
||||
def test_building_context_graph(self, test_context_graph):
|
||||
"""Test initial python-based context graph"""
|
||||
context_graph = test_context_graph
|
||||
assert context_graph.num_nodes == 5
|
||||
# end nodes
|
||||
assert context_graph.root.next[1].next[2].next[3].is_end
|
||||
assert context_graph.root.next[1].next[2].next[4].is_end
|
||||
assert context_graph.root.next[3].is_end
|
||||
# words in the end nodes
|
||||
assert context_graph.root.next[1].next[2].next[3].phrase == "abc"
|
||||
assert context_graph.root.next[1].next[2].next[4].phrase == "abd"
|
||||
assert context_graph.root.next[3].phrase == "c"
|
||||
# fail links
|
||||
assert context_graph.root.next[1].next[2].next[3].fail.token == 3
|
||||
assert context_graph.root.next[1].next[2].next[4].fail.token == -1 # root
|
||||
assert context_graph.root.next[3].fail.token == -1 # root
|
||||
# node scores
|
||||
assert round(context_graph.root.next[1].next[2].next[3].node_score, 2) == 4.79
|
||||
assert round(context_graph.root.next[1].next[2].next[4].node_score, 2) == 4.79
|
||||
assert round(context_graph.root.next[3].node_score, 2) == 1.0
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("batch_size", [1, 3, 8])
|
||||
def test_advance_method(self, test_boosting_tree, device, batch_size):
|
||||
"""Test advance method with different batch sizes"""
|
||||
test_boosting_tree.to(device)
|
||||
# Test with initial states
|
||||
init_states = test_boosting_tree.get_init_states(batch_size=batch_size, bos=True)
|
||||
scores, next_states = test_boosting_tree.advance(init_states)
|
||||
|
||||
assert scores.shape == (batch_size, 5) # vocab_size=5
|
||||
assert next_states.shape == (batch_size, 5)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_get_final_method(self, test_boosting_tree, device):
|
||||
"""Test get_final method for EOS scoring"""
|
||||
test_boosting_tree.to(device)
|
||||
# Test with various states
|
||||
states = torch.tensor([0, 1, 2], dtype=torch.long, device=device)
|
||||
final_scores = test_boosting_tree.get_final(states)
|
||||
|
||||
assert final_scores.shape == (3,)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
def test_boosting_tree_inference(self, test_boosting_tree, device):
|
||||
"""Test boosting tree inference with predefined sentences"""
|
||||
test_boosting_tree.to(device)
|
||||
|
||||
sentences_ids = [[1, 2, 3, 2, 1], [2, 2, 1, 2, 4], [3, 1, 2, 1], []] # ['abcba', 'bbabd', 'caba', '']
|
||||
boosting_scores = test_boosting_tree(
|
||||
labels=pad_sequence([torch.LongTensor(sentence) for sentence in sentences_ids], batch_first=True).to(
|
||||
device
|
||||
),
|
||||
labels_lengths=torch.LongTensor([len(sentence) for sentence in sentences_ids]).to(device),
|
||||
bos=False,
|
||||
eos=False,
|
||||
)
|
||||
correct_answer = torch.tensor(
|
||||
[
|
||||
[1.0000, 1.6931, 2.0986, 0.0000, 1.0000],
|
||||
[0.0000, 0.0000, 1.0000, 1.6931, 2.0986],
|
||||
[1.0000, 1.0000, 1.6931, -1.6931, 0.0000],
|
||||
[0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
|
||||
],
|
||||
device=device,
|
||||
)
|
||||
assert torch.allclose(boosting_scores, correct_answer, atol=1e-4)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
|
||||
def test_triton_vs_pytorch_consistency(self, test_context_graph):
|
||||
"""Compare Triton vs PyTorch implementations"""
|
||||
device = torch.device("cuda")
|
||||
|
||||
# Create two identical models with different implementations
|
||||
boosting_tree_triton = GPUBoostingTreeModel.from_context_graph(
|
||||
context_graph=test_context_graph, vocab_size=5, use_triton=True
|
||||
).to(device)
|
||||
|
||||
boosting_tree_pytorch = GPUBoostingTreeModel.from_context_graph(
|
||||
context_graph=test_context_graph, vocab_size=5, use_triton=False
|
||||
).to(device)
|
||||
|
||||
# Test with same input
|
||||
sentences_ids = [[1, 2, 3, 2, 1], [2, 2, 1, 2, 4]]
|
||||
labels = pad_sequence([torch.LongTensor(s) for s in sentences_ids], batch_first=True).to(device)
|
||||
lengths = torch.LongTensor([len(s) for s in sentences_ids]).to(device)
|
||||
|
||||
scores_triton = boosting_tree_triton(labels=labels, labels_lengths=lengths, bos=False, eos=False)
|
||||
scores_pytorch = boosting_tree_pytorch(labels=labels, labels_lengths=lengths, bos=False, eos=False)
|
||||
|
||||
assert torch.allclose(scores_triton, scores_pytorch, atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_eos_handling(self, test_context_graph):
|
||||
"""Test EOS token handling (important for AED models)"""
|
||||
boosting_tree = GPUBoostingTreeModel.from_context_graph(
|
||||
context_graph=test_context_graph, vocab_size=5, unk_score=0.0, final_eos_score=1.0
|
||||
)
|
||||
|
||||
# Test advance with EOS
|
||||
init_states = torch.tensor([1, 2], dtype=torch.long)
|
||||
scores, next_states = boosting_tree.advance(init_states, eos_id=0)
|
||||
|
||||
# state 2 in the 1st batch should have final_eos_score value
|
||||
assert (
|
||||
round(scores[0, 0].item(), 2) == 1.69
|
||||
) # (1.69+0): 1.69 as max score for state 1 and 0 because it is not final state
|
||||
assert scores[1, 0] == 2.0 # (1+1): 1 as max score for state 2 and 1 because it is final state
|
||||
|
||||
@pytest.mark.unit
|
||||
# I need to test that the boosting tree model is built correctly from the config using model_path, key_phrases_file, key_phrases_list
|
||||
def test_boosting_tree_model_from_config(self, conformer_ctc_bpe_model, tmp_path):
|
||||
"""Test that the boosting tree model is built correctly from the config using model_path, key_phrases_file, key_phrases_list"""
|
||||
|
||||
# 1. build boosting tree model from model path
|
||||
boosting_tree_cfg = BoostingTreeModelConfig()
|
||||
phrases = ["abc", "abd", "c"]
|
||||
phrases_ids = [conformer_ctc_bpe_model.tokenizer.text_to_ids(phrase) for phrase in phrases]
|
||||
scores = [0.0, 0.0, 0.0]
|
||||
context_graph = ContextGraph(
|
||||
context_score=boosting_tree_cfg.context_score, depth_scaling=boosting_tree_cfg.depth_scaling
|
||||
)
|
||||
context_graph.build(
|
||||
token_ids=phrases_ids, phrases=phrases, scores=scores, uniform_weights=boosting_tree_cfg.uniform_weights
|
||||
)
|
||||
test_boosting_tree = GPUBoostingTreeModel.from_context_graph(
|
||||
context_graph=context_graph,
|
||||
vocab_size=conformer_ctc_bpe_model.tokenizer.vocab_size,
|
||||
unk_score=boosting_tree_cfg.unk_score,
|
||||
final_eos_score=boosting_tree_cfg.final_eos_score,
|
||||
use_triton=boosting_tree_cfg.use_triton,
|
||||
uniform_weights=boosting_tree_cfg.uniform_weights,
|
||||
)
|
||||
|
||||
test_boosting_tree.save_to(tmp_path / "test_boosting_tree.nemo")
|
||||
boosting_tree_cfg = BoostingTreeModelConfig(model_path=tmp_path / "test_boosting_tree.nemo")
|
||||
boosting_tree_from_model_path = GPUBoostingTreeModel.from_config(
|
||||
boosting_tree_cfg, tokenizer=conformer_ctc_bpe_model.tokenizer
|
||||
)
|
||||
|
||||
# 2. build boosting tree model from key phrases file
|
||||
with open(tmp_path / "test_boosting_tree.txt", "w") as f:
|
||||
f.write("abc\nabd\nc")
|
||||
boosting_tree_cfg = BoostingTreeModelConfig(key_phrases_file=tmp_path / "test_boosting_tree.txt")
|
||||
boosting_tree_from_key_phrases_file = GPUBoostingTreeModel.from_config(
|
||||
boosting_tree_cfg, tokenizer=conformer_ctc_bpe_model.tokenizer
|
||||
)
|
||||
|
||||
# 3. build boosting tree model from key phrases list
|
||||
boosting_tree_cfg = BoostingTreeModelConfig(key_phrases_list=["abc", "abd", "c"])
|
||||
boosting_tree_from_key_phrases_list = GPUBoostingTreeModel.from_config(
|
||||
boosting_tree_cfg, tokenizer=conformer_ctc_bpe_model.tokenizer
|
||||
)
|
||||
|
||||
# check that the boosting tree models are the same
|
||||
assert torch.allclose(
|
||||
boosting_tree_from_model_path.arcs_weights, boosting_tree_from_key_phrases_file.arcs_weights
|
||||
)
|
||||
assert torch.allclose(
|
||||
boosting_tree_from_model_path.arcs_weights, boosting_tree_from_key_phrases_list.arcs_weights
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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 numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.modules.conformer_encoder import ConformerEncoder
|
||||
|
||||
|
||||
class TestStochasticDepth:
|
||||
"""Testing stochastic depth functionality."""
|
||||
|
||||
def test_stochastic_depth_model_creation(self):
|
||||
"""Testing basic model creation and the drop probs are correctly assigned."""
|
||||
n_layers = 4
|
||||
model = ConformerEncoder(feat_in=10, n_layers=n_layers, d_model=4, feat_out=8)
|
||||
|
||||
# checking that by default SD is disabled
|
||||
assert model.layer_drop_probs == [0.0] * n_layers
|
||||
|
||||
# linear mode
|
||||
for drop_prob in [0.3, 0.5, 0.9]:
|
||||
for start_layer in [1, 3]:
|
||||
model = ConformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=n_layers,
|
||||
d_model=4,
|
||||
feat_out=8,
|
||||
stochastic_depth_drop_prob=drop_prob,
|
||||
stochastic_depth_start_layer=start_layer,
|
||||
)
|
||||
L = n_layers - start_layer
|
||||
assert model.layer_drop_probs == [0.0] * start_layer + [drop_prob * l / L for l in range(1, L + 1)]
|
||||
|
||||
# uniform mode
|
||||
for drop_prob in [0.3, 0.5, 0.9]:
|
||||
model = ConformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=n_layers,
|
||||
d_model=4,
|
||||
feat_out=8,
|
||||
stochastic_depth_drop_prob=drop_prob,
|
||||
stochastic_depth_mode="uniform",
|
||||
stochastic_depth_start_layer=start_layer,
|
||||
)
|
||||
L = n_layers - start_layer
|
||||
assert model.layer_drop_probs == [0.0] * start_layer + [drop_prob] * L
|
||||
|
||||
# checking for errors
|
||||
for drop_prob in [-1.0, 1.0]:
|
||||
with pytest.raises(ValueError, match="stochastic_depth_drop_prob has to be in"):
|
||||
ConformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=n_layers,
|
||||
d_model=4,
|
||||
feat_out=8,
|
||||
stochastic_depth_drop_prob=drop_prob,
|
||||
stochastic_depth_mode="uniform",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="stochastic_depth_mode has to be one of"):
|
||||
ConformerEncoder(feat_in=10, n_layers=n_layers, d_model=4, feat_out=8, stochastic_depth_mode="weird")
|
||||
|
||||
for start_layer in [-1, 0, 5]:
|
||||
with pytest.raises(ValueError, match="stochastic_depth_start_layer has to be in"):
|
||||
ConformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=n_layers,
|
||||
d_model=4,
|
||||
feat_out=8,
|
||||
stochastic_depth_start_layer=start_layer,
|
||||
)
|
||||
|
||||
@pytest.mark.pleasefixme
|
||||
def test_stochastic_depth_forward(self):
|
||||
"""Testing that forward works and we get randomness during training, but not during eval."""
|
||||
random_input = torch.rand((1, 2, 2))
|
||||
random_length = torch.tensor([2], dtype=torch.int64)
|
||||
|
||||
model = ConformerEncoder(
|
||||
feat_in=2,
|
||||
n_layers=3,
|
||||
d_model=4,
|
||||
feat_out=4,
|
||||
stochastic_depth_drop_prob=0.8,
|
||||
dropout=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
conv_norm_type="layer_norm",
|
||||
conv_kernel_size=3,
|
||||
)
|
||||
model.train()
|
||||
outputs = [None] * 5
|
||||
for i in range(5):
|
||||
outputs[i] = model(audio_signal=random_input, length=random_length)[0]
|
||||
# checking that not all outputs are the same
|
||||
num_diff = 0
|
||||
for i in range(1, 5):
|
||||
if not torch.allclose(outputs[i], outputs[0]):
|
||||
num_diff += 1
|
||||
assert num_diff > 0
|
||||
|
||||
model.eval()
|
||||
outputs = [None] * 5
|
||||
for i in range(5):
|
||||
outputs[i] = model(audio_signal=random_input, length=random_length)[0]
|
||||
# checking that not all outputs are the same
|
||||
num_diff = 0
|
||||
for i in range(1, 5):
|
||||
if not torch.allclose(outputs[i], outputs[0]):
|
||||
num_diff += 1
|
||||
assert num_diff == 0
|
||||
|
||||
|
||||
class TestBypassPreEncode:
|
||||
"""Testing bypass pre-encode functionality."""
|
||||
|
||||
def test_bypass_pre_encode_forward(self):
|
||||
"""Testing that forward works with "bypass pre-encode" mode."""
|
||||
# For pre-encoded embeddings, the shape is (batch_size, n_frames, emb_dim)
|
||||
batch_size = 2
|
||||
n_frames, emb_dim, feat_out = 17, 16, 8
|
||||
random_input = torch.rand((batch_size, n_frames, emb_dim))
|
||||
random_length = torch.tensor([n_frames], dtype=torch.int64)
|
||||
|
||||
model = ConformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=3,
|
||||
d_model=emb_dim,
|
||||
feat_out=feat_out,
|
||||
stochastic_depth_drop_prob=0.0,
|
||||
dropout=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
conv_norm_type="layer_norm",
|
||||
conv_kernel_size=3,
|
||||
)
|
||||
model.train()
|
||||
fwd_outputs = model(audio_signal=random_input, length=random_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
model.eval()
|
||||
fwd_outputs = model(audio_signal=random_input, length=random_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
def test_error_shape_invalid_bypass_pre_encode_forward(self):
|
||||
"""
|
||||
Testing that error messages are correctly triggered regarding "bypass pre-encode" mode.
|
||||
Both correct samples and wrongs samples are tested.
|
||||
|
||||
(1) bypass_pre_encode = False (default):
|
||||
`audio_signal` must be a tensor containing audio features.
|
||||
Shape: (batch, self._feat_in, n_frames)
|
||||
(2) bypass_pre_encode = True:
|
||||
`audio_signal` must be a tensor containing pre-encoded embeddings.
|
||||
Shape: (batch, n_frame, self.d_model)
|
||||
"""
|
||||
batch_size = 2
|
||||
n_frames, emb_dim, feat_in, feat_out = 17, 16, 10, 8
|
||||
|
||||
pre_encode_input = torch.rand((batch_size, n_frames, emb_dim))
|
||||
feat_input = torch.rand((batch_size, feat_in, n_frames))
|
||||
input_length = torch.tensor([n_frames], dtype=torch.int64)
|
||||
|
||||
model = ConformerEncoder(
|
||||
feat_in=feat_in,
|
||||
n_layers=3,
|
||||
d_model=emb_dim,
|
||||
feat_out=feat_out,
|
||||
stochastic_depth_drop_prob=0.0,
|
||||
dropout=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
conv_norm_type="layer_norm",
|
||||
conv_kernel_size=3,
|
||||
)
|
||||
sub_sampled_n_frames = np.ceil(n_frames / model.subsampling_factor)
|
||||
|
||||
# Test with bypass_pre_encode = True, should be pre_encode_input but given feat_input.
|
||||
model.train()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=feat_input, length=input_length, bypass_pre_encode=True)
|
||||
|
||||
model.eval()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=feat_input, length=input_length, bypass_pre_encode=True)
|
||||
|
||||
# Test with bypass_pre_encode = True, given the correct input pre_encode_input.
|
||||
model.train()
|
||||
fwd_outputs = model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
model.eval()
|
||||
fwd_outputs = model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
# Test with bypass_pre_encode = False, should be feat_input but given pre_encode_input.
|
||||
model.train()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=False)
|
||||
|
||||
model.eval()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=False)
|
||||
|
||||
# Test with bypass_pre_encode = False, given the correct input feat_input.
|
||||
model.train()
|
||||
fwd_outputs = model(audio_signal=feat_input, length=input_length, bypass_pre_encode=False)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, sub_sampled_n_frames)
|
||||
|
||||
model.eval()
|
||||
fwd_outputs = model(audio_signal=feat_input, length=input_length, bypass_pre_encode=False)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, sub_sampled_n_frames)
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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 sentencepiece as spm
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.parts.mixins import ASRBPEMixin
|
||||
from nemo.collections.common.tokenizers.canary_tokenizer import DEFAULT_TOKENS, CanaryTokenizer
|
||||
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
|
||||
from nemo.core import Serialization
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def special_tokenizer_path(tmp_path_factory) -> str:
|
||||
tokens = ["asr", "ast", "en", "de", "fr", "es"]
|
||||
tmpdir = tmp_path_factory.mktemp("spl_tokens")
|
||||
CanaryTokenizer.build_special_tokenizer(tokens, tmpdir)
|
||||
return str(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def lang_tokenizer_path(tmp_path_factory) -> str:
|
||||
tmpdir = tmp_path_factory.mktemp("klingon_tokens")
|
||||
text_path = tmpdir / "text.txt"
|
||||
text_path.write_text("a\nb\nc\nd\n")
|
||||
create_spt_model(text_path, vocab_size=8, sample_size=-1, do_lower_case=False, output_dir=str(tmpdir))
|
||||
return str(tmpdir)
|
||||
|
||||
|
||||
def test_canary_tokenizer_build_special_tokenizer(tmp_path):
|
||||
tokens = ["asr", "ast", "en", "de", "fr", "es"]
|
||||
tokenizer = CanaryTokenizer.build_special_tokenizer(tokens, tmp_path)
|
||||
expected_tokens = DEFAULT_TOKENS + [f"<|{t}|>" for t in tokens] + ["▁", "<unk>"]
|
||||
tokens = []
|
||||
for i in range(tokenizer.tokenizer.vocab_size()):
|
||||
tokens.append(tokenizer.tokenizer.IdToPiece(i))
|
||||
expected_tokens.sort(), tokens.sort()
|
||||
print(expected_tokens, tokens)
|
||||
assert expected_tokens == tokens
|
||||
|
||||
|
||||
def test_canary_tokenizer_init_from_cfg(special_tokenizer_path, lang_tokenizer_path):
|
||||
class DummyModel(ASRBPEMixin, Serialization):
|
||||
pass
|
||||
|
||||
model = DummyModel()
|
||||
model.register_artifact = Mock(side_effect=lambda self, x: x)
|
||||
config = OmegaConf.create(
|
||||
{
|
||||
"type": "agg",
|
||||
"dir": None,
|
||||
"langs": {
|
||||
"spl_tokens": {"dir": special_tokenizer_path, "type": "bpe"},
|
||||
"en": {"dir": lang_tokenizer_path, "type": "bpe"},
|
||||
},
|
||||
"custom_tokenizer": {
|
||||
"_target_": "nemo.collections.common.tokenizers.canary_tokenizer.CanaryTokenizer",
|
||||
},
|
||||
}
|
||||
)
|
||||
model._setup_aggregate_tokenizer(config)
|
||||
tokenizer = model.tokenizer
|
||||
|
||||
assert isinstance(tokenizer, CanaryTokenizer)
|
||||
assert len(tokenizer.tokenizers_dict) == 2
|
||||
assert set(tokenizer.tokenizers_dict.keys()) == {"spl_tokens", "en"}
|
||||
|
||||
assert isinstance(tokenizer.tokenizers_dict["spl_tokens"], SentencePieceTokenizer)
|
||||
assert tokenizer.tokenizers_dict["spl_tokens"].vocab_size == 14
|
||||
|
||||
assert isinstance(tokenizer.tokenizers_dict["en"], SentencePieceTokenizer)
|
||||
assert tokenizer.tokenizers_dict["en"].vocab_size == 6
|
||||
|
||||
assert tokenizer.text_to_ids("<|startoftranscript|><|en|><|asr|><|en|><|pnc|>", lang_id="spl_tokens") == [
|
||||
4,
|
||||
9,
|
||||
7,
|
||||
9,
|
||||
5,
|
||||
]
|
||||
assert tokenizer.text_to_ids("a", lang_id="en") == [14 + 1, 14 + 2]
|
||||
@@ -0,0 +1,398 @@
|
||||
# Copyright (c) 2021, 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.submodules import jasper
|
||||
|
||||
|
||||
class TestJasperBlock:
|
||||
@staticmethod
|
||||
def jasper_base_config(**kwargs):
|
||||
base = dict(
|
||||
inplanes=16,
|
||||
planes=8,
|
||||
kernel_size=[11],
|
||||
repeat=1,
|
||||
stride=[1],
|
||||
dilation=[1],
|
||||
activation="relu",
|
||||
conv_mask=True,
|
||||
separable=False,
|
||||
se=False,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return base
|
||||
|
||||
def check_module_exists(self, module, cls):
|
||||
global _MODULE_EXISTS
|
||||
_MODULE_EXISTS = 0
|
||||
|
||||
def _traverse(m):
|
||||
if isinstance(m, cls):
|
||||
global _MODULE_EXISTS
|
||||
_MODULE_EXISTS += 1
|
||||
|
||||
module.apply(_traverse)
|
||||
assert _MODULE_EXISTS > 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block(self):
|
||||
config = self.jasper_base_config(residual=False)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block(self):
|
||||
config = self.jasper_base_config(residual=True)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block_repeat(self):
|
||||
config = self.jasper_base_config(residual=False, repeat=3)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
assert len(block.mconv) == 3 * 3 + 1 # (3 repeats x {1 conv + 1 norm + 1 dropout} + final conv)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block_repeat_stride(self):
|
||||
config = self.jasper_base_config(residual=False, repeat=3, stride=[2])
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 17]) # 131 // (stride ^ repeats)
|
||||
assert ylen[0] == 17 # 131 // (stride ^ repeats)
|
||||
assert len(block.mconv) == 3 * 3 + 1 # (3 repeats x {1 conv + 1 norm + 1 dropout} + final conv)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block_repeat_stride_last(self):
|
||||
config = self.jasper_base_config(residual=False, repeat=3, stride=[2], stride_last=True)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 66]) # 131 // stride
|
||||
assert ylen[0] == 66 # 131 // stride
|
||||
assert len(block.mconv) == 3 * 3 + 1 # (3 repeats x {1 conv + 1 norm + 1 dropout} + final conv)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block_repeat_separable(self):
|
||||
config = self.jasper_base_config(residual=False, repeat=3, separable=True)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
assert len(block.mconv) == 3 * 4 + 1 # (3 repeats x {1 dconv + 1 pconv + 1 norm + 1 dropout} + final conv)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_basic_block_stride(self):
|
||||
config = self.jasper_base_config(stride=[2], residual=False)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
print(config)
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 66])
|
||||
assert ylen[0] == 66
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_stride(self):
|
||||
config = self.jasper_base_config(stride=[2], residual=True, residual_mode='stride_add')
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
print(config)
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 66])
|
||||
assert ylen[0] == 66
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_activations(self):
|
||||
for activation in jasper.jasper_activations.keys():
|
||||
config = self.jasper_base_config(activation=activation)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
self.check_module_exists(block, act.__class__)
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_normalizations(self):
|
||||
NORMALIZATIONS = ["batch", "layer", "group"]
|
||||
for normalization in NORMALIZATIONS:
|
||||
config = self.jasper_base_config(normalization=normalization)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_se(self):
|
||||
config = self.jasper_base_config(se=True, se_reduction_ratio=8)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
self.check_module_exists(block, jasper.SqueezeExcite)
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_asymmetric_pad_future_contexts(self):
|
||||
# test future contexts at various values
|
||||
# 0 = no future context
|
||||
# 2 = limited future context
|
||||
# 5 = symmetric context
|
||||
# 8 = excess future context (more future context than present or past context)
|
||||
future_contexts = [0, 2, 5, 8]
|
||||
for future_context in future_contexts:
|
||||
print(future_context)
|
||||
config = self.jasper_base_config(future_context=future_context)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
self.check_module_exists(block, torch.nn.ConstantPad1d)
|
||||
self.check_module_exists(block, jasper.MaskedConv1d)
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
assert block.mconv[0].pad_layer is not None
|
||||
assert block.mconv[0]._padding == (config['kernel_size'][0] - 1 - future_context, future_context)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_residual_block_asymmetric_pad_future_context_fallback(self):
|
||||
# test future contexts at various values
|
||||
# 15 = K < FC; fall back to symmetric context
|
||||
future_context = 15
|
||||
print(future_context)
|
||||
config = self.jasper_base_config(future_context=future_context)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
|
||||
x = torch.randn(1, 16, 131)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
self.check_module_exists(block, jasper.MaskedConv1d)
|
||||
|
||||
assert isinstance(block, jasper.JasperBlock)
|
||||
assert y[0].shape == torch.Size([1, config['planes'], 131])
|
||||
assert ylen[0] == 131
|
||||
assert block.mconv[0].pad_layer is None
|
||||
assert block.mconv[0]._padding == config['kernel_size'][0] // 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_padding_size_conv1d(self):
|
||||
input_channels = 1
|
||||
output_channels = 1
|
||||
kernel_sizes = [3, 7, 11]
|
||||
dilation_sizes = [2, 3, 4]
|
||||
stride = 1
|
||||
inp = torch.rand(2, 1, 40)
|
||||
|
||||
for kernel_size in kernel_sizes:
|
||||
for dilation_size in dilation_sizes:
|
||||
padding = jasper.get_same_padding(kernel_size, stride, dilation_size)
|
||||
|
||||
conv = torch.nn.Conv1d(
|
||||
input_channels, output_channels, kernel_size=kernel_size, dilation=dilation_size, padding=padding
|
||||
)
|
||||
|
||||
out = conv(inp)
|
||||
assert out.shape == inp.shape
|
||||
|
||||
|
||||
class TestParallelBlock:
|
||||
@staticmethod
|
||||
def contrust_jasper_block(**config_kwargs):
|
||||
config = TestJasperBlock.jasper_base_config(**config_kwargs)
|
||||
act = jasper.jasper_activations.get(config.pop('activation'))()
|
||||
block = jasper.JasperBlock(**config, activation=act)
|
||||
return block
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_blocks_with_same_input_output_channels_sum_residual(self):
|
||||
blocks = []
|
||||
in_planes = 8
|
||||
out_planes = 8
|
||||
for _ in range(2):
|
||||
blocks.append(self.contrust_jasper_block(inplanes=in_planes, planes=out_planes))
|
||||
|
||||
block = jasper.ParallelBlock(blocks, residual_mode='sum')
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert y[0].shape == torch.Size([1, out_planes, 140])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_blocks_with_different_input_output_channels_sum_residual(self):
|
||||
blocks = []
|
||||
in_planes = 8
|
||||
out_planes = 16
|
||||
for _ in range(2):
|
||||
blocks.append(self.contrust_jasper_block(inplanes=in_planes, planes=out_planes))
|
||||
|
||||
block = jasper.ParallelBlock(blocks, residual_mode='sum')
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
block(([x], xlen))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_blocks_with_same_input_output_channels_conv_residual(self):
|
||||
blocks = []
|
||||
in_planes = 8
|
||||
out_planes = 8
|
||||
for _ in range(2):
|
||||
blocks.append(self.contrust_jasper_block(inplanes=in_planes, planes=out_planes))
|
||||
|
||||
block = jasper.ParallelBlock(blocks, residual_mode='conv', in_filters=in_planes, out_filters=out_planes)
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert y[0].shape == torch.Size([1, out_planes, 140])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_blocks_with_different_input_output_channels_conv_residual(self):
|
||||
blocks = []
|
||||
in_planes = 8
|
||||
out_planes = 16
|
||||
for _ in range(2):
|
||||
blocks.append(self.contrust_jasper_block(inplanes=in_planes, planes=out_planes))
|
||||
|
||||
block = jasper.ParallelBlock(blocks, residual_mode='conv', in_filters=in_planes, out_filters=out_planes)
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert y[0].shape == torch.Size([1, out_planes, 140])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_single_block(self):
|
||||
in_planes = 8
|
||||
out_planes = 16
|
||||
blocks = [self.contrust_jasper_block(inplanes=in_planes, planes=out_planes)]
|
||||
|
||||
block = jasper.ParallelBlock(blocks)
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
y, ylen = block(([x], xlen))
|
||||
|
||||
assert y[0].shape == torch.Size([1, out_planes, 140])
|
||||
assert ylen[0] == 131
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tower_dropout(self):
|
||||
blocks = []
|
||||
in_planes = 8
|
||||
out_planes = 8
|
||||
for _ in range(2):
|
||||
blocks.append(self.contrust_jasper_block(inplanes=in_planes, planes=out_planes))
|
||||
|
||||
block = jasper.ParallelBlock(blocks, aggregation_mode='dropout', block_dropout_prob=1.0)
|
||||
x = torch.randn(1, in_planes, 140)
|
||||
xlen = torch.tensor([131])
|
||||
y, _ = block(([x], xlen))
|
||||
|
||||
# Tower dropout is 1.0, meaning that all towers have to be dropped, so only residual remains.
|
||||
torch.testing.assert_close(y[0], x)
|
||||
@@ -0,0 +1,162 @@
|
||||
# Copyright (c) 2020, 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 json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.data.audio_to_label import AudioToMultiLabelDataset, TarredAudioToClassificationLabelDataset
|
||||
from nemo.collections.asr.data.feature_to_label import FeatureToLabelDataset, FeatureToSeqSpeakerLabelDataset
|
||||
from nemo.collections.asr.parts.preprocessing.feature_loader import ExternalFeatureLoader
|
||||
from nemo.collections.asr.parts.preprocessing.features import WaveformFeaturizer
|
||||
|
||||
|
||||
class TestASRDatasets:
|
||||
labels = ["fash", "fbbh", "fclc"]
|
||||
unique_labels_in_seq = ['0', '1', '2', '3', "zero", "one", "two", "three"]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tarred_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/tarred_audio_manifest.json'))
|
||||
|
||||
# Test braceexpand loading
|
||||
tarpath = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/audio_{0..1}.tar'))
|
||||
featurizer = WaveformFeaturizer(sample_rate=16000, int_values=False, augmentor=None)
|
||||
ds_braceexpand = TarredAudioToClassificationLabelDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, featurizer=featurizer
|
||||
)
|
||||
|
||||
assert len(ds_braceexpand) == 32
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
# Test loading via list
|
||||
tarpath = [os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{i}.tar')) for i in range(2)]
|
||||
ds_list_load = TarredAudioToClassificationLabelDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, featurizer=featurizer
|
||||
)
|
||||
count = 0
|
||||
for _ in ds_list_load:
|
||||
count += 1
|
||||
assert count == 32
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tarred_dataset_duplicate_name(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(
|
||||
os.path.join(test_data_dir, 'asr/tarred_an4/tarred_duplicate_audio_manifest.json')
|
||||
)
|
||||
|
||||
# Test braceexpand loading
|
||||
tarpath = os.path.abspath(os.path.join(test_data_dir, 'asr/tarred_an4/audio_{0..1}.tar'))
|
||||
featurizer = WaveformFeaturizer(sample_rate=16000, int_values=False, augmentor=None)
|
||||
ds_braceexpand = TarredAudioToClassificationLabelDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, featurizer=featurizer
|
||||
)
|
||||
|
||||
assert len(ds_braceexpand) == 6
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 6
|
||||
|
||||
# Test loading via list
|
||||
tarpath = [os.path.abspath(os.path.join(test_data_dir, f'asr/tarred_an4/audio_{i}.tar')) for i in range(2)]
|
||||
ds_list_load = TarredAudioToClassificationLabelDataset(
|
||||
audio_tar_filepaths=tarpath, manifest_filepath=manifest_path, labels=self.labels, featurizer=featurizer
|
||||
)
|
||||
count = 0
|
||||
for _ in ds_list_load:
|
||||
count += 1
|
||||
assert count == 6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feat_seqlabel_dataset(self, test_data_dir):
|
||||
manifest_path = os.path.abspath(os.path.join(test_data_dir, 'asr/feat/emb.json'))
|
||||
feature_loader = ExternalFeatureLoader(augmentor=None)
|
||||
ds_braceexpand = FeatureToSeqSpeakerLabelDataset(
|
||||
manifest_filepath=manifest_path, labels=self.unique_labels_in_seq, feature_loader=feature_loader
|
||||
)
|
||||
# fmt: off
|
||||
correct_label = torch.tensor(
|
||||
[0.0, 1.0, 2.0, 2.0, 1.0, 2.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 3.0, 1.0, 2.0, 2.0, 2.0, 0.0, 2.0, 1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0, 0.0, 2.0, 2.0, 2.0, 1.0, 2.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 2.0, 1.0, 2.0, 1.0,]
|
||||
)
|
||||
# fmt: on
|
||||
correct_label_length = torch.tensor(50)
|
||||
|
||||
assert ds_braceexpand[0][0].shape == (50, 32)
|
||||
assert torch.equal(ds_braceexpand[0][2], correct_label)
|
||||
assert torch.equal(ds_braceexpand[0][3], correct_label_length)
|
||||
|
||||
count = 0
|
||||
for _ in ds_braceexpand:
|
||||
count += 1
|
||||
assert count == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_feat_label_dataset(self):
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
for i in range(2):
|
||||
feat_file = os.path.join(tmpdir, f"feat_{i}.pt")
|
||||
torch.save(torch.randn(80, 5), feat_file)
|
||||
entry = {'feature_file': feat_file, 'duration': 100000, 'label': '0'}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = FeatureToLabelDataset(manifest_filepath=manifest_path, labels=self.unique_labels_in_seq)
|
||||
|
||||
correct_label = torch.tensor(self.unique_labels_in_seq.index('0'))
|
||||
correct_label_length = torch.tensor(1)
|
||||
|
||||
assert dataset[0][0].shape == (80, 5)
|
||||
assert torch.equal(dataset[0][2], correct_label)
|
||||
assert torch.equal(dataset[0][3], correct_label_length)
|
||||
|
||||
count = 0
|
||||
for _ in dataset:
|
||||
count += 1
|
||||
assert count == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_audio_multilabel_dataset(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = os.path.join(tmpdir, 'manifest_input.json')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as fp:
|
||||
for i in range(2):
|
||||
audio_file = os.path.join(tmpdir, f"audio_{i}.wav")
|
||||
data = np.random.normal(0, 1, 16000 * 10)
|
||||
sf.write(audio_file, data, 16000)
|
||||
entry = {'audio_filepath': audio_file, 'duration': 10, 'label': '0 1 0 1'}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
|
||||
dataset = AudioToMultiLabelDataset(manifest_filepath=manifest_path, sample_rate=16000, labels=['0', '1'])
|
||||
|
||||
correct_label = torch.tensor([0, 1, 0, 1])
|
||||
correct_label_length = torch.tensor(4)
|
||||
|
||||
assert dataset[0][0].shape == torch.tensor([0.1] * 160000).shape
|
||||
assert torch.equal(dataset[0][2], correct_label)
|
||||
assert torch.equal(dataset[0][3], correct_label_length)
|
||||
|
||||
count = 0
|
||||
for _ in dataset:
|
||||
count += 1
|
||||
assert count == 2
|
||||
@@ -0,0 +1,209 @@
|
||||
# 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 random
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import KenLMBatchedWrapper, NGramGPULanguageModel
|
||||
from nemo.core.utils.optional_libs import KENLM_AVAILABLE, TRITON_AVAILABLE
|
||||
|
||||
DEVICES = [torch.device("cpu")]
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICES.append('cuda')
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def n_gpu_lm(test_data_dir):
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
vocab_size = 1024
|
||||
return NGramGPULanguageModel.from_arpa(kenlm_model_path, vocab_size=vocab_size, normalize_unk=False)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kenlm_wrapper(test_data_dir):
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
vocab_size = 1024
|
||||
return KenLMBatchedWrapper.from_file(lm_path=kenlm_model_path, vocab_size=vocab_size)
|
||||
|
||||
|
||||
class TestNGramGPULanguageModel:
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
def test_load(self, test_data_dir):
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
_ = NGramGPULanguageModel.from_arpa(kenlm_model_path, vocab_size=1024)
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not KENLM_AVAILABLE, reason="KenLM is not available")
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("batch_size", [1, 3])
|
||||
@pytest.mark.parametrize("bos", [True, False])
|
||||
def test_initial_states(
|
||||
self,
|
||||
n_gpu_lm: NGramGPULanguageModel,
|
||||
kenlm_wrapper: KenLMBatchedWrapper,
|
||||
bos: bool,
|
||||
batch_size: int,
|
||||
device: torch.device,
|
||||
):
|
||||
n_gpu_lm = n_gpu_lm.to(device)
|
||||
init_states = n_gpu_lm.get_init_states(batch_size=batch_size, bos=bos)
|
||||
init_states_kenlm = kenlm_wrapper.get_init_states(batch_size=batch_size, bos=bos)
|
||||
scores_lm, _ = n_gpu_lm.advance(init_states)
|
||||
scores_ref, _ = kenlm_wrapper.advance(init_states_kenlm)
|
||||
assert torch.allclose(scores_lm, scores_ref.to(device))
|
||||
|
||||
@pytest.mark.with_downloads
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not TRITON_AVAILABLE, reason="Triton is not available")
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available")
|
||||
def test_triton_vs_pytorch_random_states(self, n_gpu_lm: NGramGPULanguageModel, batch_size=2, num_iterations=100):
|
||||
"""Randomly initializes the states and compares the scores from Triton and PyTorch implementations."""
|
||||
torch.manual_seed(777)
|
||||
device = torch.device("cuda")
|
||||
n_gpu_lm = n_gpu_lm.to(device)
|
||||
for _ in tqdm(range(num_iterations)):
|
||||
start_state = random.randint(0, n_gpu_lm.num_states - 1)
|
||||
with torch.no_grad():
|
||||
scores1, states1 = n_gpu_lm._advance_pytorch(
|
||||
states=torch.full([batch_size], fill_value=start_state, device=device, dtype=torch.int64)
|
||||
)
|
||||
scores2, states2 = n_gpu_lm._advance_triton(
|
||||
states=torch.full([batch_size], fill_value=start_state, device=device, dtype=torch.int64)
|
||||
)
|
||||
assert (states1 == states2).all()
|
||||
assert torch.allclose(scores1, scores2)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not KENLM_AVAILABLE, reason="KenLM is not available")
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("bos", [True, False])
|
||||
def test_final(
|
||||
self, n_gpu_lm: NGramGPULanguageModel, kenlm_wrapper: KenLMBatchedWrapper, bos: bool, device: torch.device
|
||||
):
|
||||
"""Test final (eos) scores"""
|
||||
n_gpu_lm = n_gpu_lm.to(device)
|
||||
sentences = [
|
||||
[25, 70, 12],
|
||||
[58, 41, 186, 293, 306, 999, 163, 264, 689, 683, 999],
|
||||
[], # empty sentence
|
||||
]
|
||||
last_states = []
|
||||
for sentence in sentences:
|
||||
state = kenlm_wrapper.get_init_state(bos=bos)
|
||||
for label in sentence:
|
||||
_, state = kenlm_wrapper.advance_single(state=state, label=label)
|
||||
last_states.append(state)
|
||||
final_ref = kenlm_wrapper.get_final(states=last_states).to(device=device)
|
||||
|
||||
last_states = []
|
||||
for sentence in sentences:
|
||||
states = n_gpu_lm.get_init_states(batch_size=1, bos=bos)
|
||||
for label in sentence:
|
||||
_, states = n_gpu_lm.advance(states=states)
|
||||
states = states[0, label].unsqueeze(0)
|
||||
last_states.append(states)
|
||||
final_lm = n_gpu_lm.get_final(states=torch.cat(last_states, dim=0))
|
||||
|
||||
assert torch.allclose(final_lm, final_ref), "Final scores do not match"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not KENLM_AVAILABLE, reason="KenLM is not available")
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("bos", [True, False])
|
||||
@pytest.mark.parametrize("eos", [True, False])
|
||||
def test_sentences(
|
||||
self,
|
||||
n_gpu_lm: NGramGPULanguageModel,
|
||||
kenlm_wrapper: KenLMBatchedWrapper,
|
||||
bos: bool,
|
||||
eos: bool,
|
||||
device: torch.device,
|
||||
):
|
||||
n_gpu_lm = n_gpu_lm.to(device)
|
||||
sentences = [
|
||||
[25, 70, 12],
|
||||
[58, 41, 186, 293, 306, 999, 163, 264, 689, 683, 999],
|
||||
[], # empty sentence
|
||||
]
|
||||
# non-batched
|
||||
for sentence in sentences:
|
||||
scores_ref = kenlm_wrapper.score_sentences([sentence], bos=bos, eos=eos).to(device)
|
||||
scores_lm = n_gpu_lm(
|
||||
labels=torch.LongTensor([sentence]).to(device),
|
||||
bos=bos,
|
||||
eos=eos,
|
||||
)
|
||||
assert torch.allclose(scores_ref, scores_lm), "Non-batched scores do not match"
|
||||
|
||||
# batched
|
||||
scores_ref = kenlm_wrapper.score_sentences(sentences, bos=bos, eos=eos).to(device)
|
||||
scores_lm = n_gpu_lm(
|
||||
labels=pad_sequence([torch.LongTensor(sentence) for sentence in sentences], batch_first=True).to(device),
|
||||
labels_lengths=torch.LongTensor([len(sentence) for sentence in sentences]).to(device),
|
||||
bos=bos,
|
||||
eos=eos,
|
||||
)
|
||||
assert torch.allclose(scores_lm, scores_ref), "Batched scores do not match"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_load_nemo(self, tmp_path, test_data_dir):
|
||||
vocab_size = 1024
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
n_gpu_lm = NGramGPULanguageModel.from_arpa(kenlm_model_path, vocab_size=vocab_size, normalize_unk=False)
|
||||
nemo_path = tmp_path / "ngram_lm.nemo"
|
||||
n_gpu_lm.save_to(f"{nemo_path}")
|
||||
n_gpu_lm_loaded = NGramGPULanguageModel.from_nemo(f"{nemo_path}", vocab_size=vocab_size)
|
||||
|
||||
# arcs data
|
||||
assert torch.allclose(n_gpu_lm_loaded.arcs_weights, n_gpu_lm.arcs_weights)
|
||||
assert (n_gpu_lm_loaded.from_states == n_gpu_lm.from_states).all()
|
||||
assert (n_gpu_lm_loaded.to_states == n_gpu_lm.to_states).all()
|
||||
assert (n_gpu_lm_loaded.ilabels == n_gpu_lm.ilabels).all()
|
||||
|
||||
# states data
|
||||
assert (n_gpu_lm_loaded.start_end_arcs == n_gpu_lm.start_end_arcs).all()
|
||||
assert (n_gpu_lm_loaded.state_order == n_gpu_lm.state_order).all()
|
||||
assert (n_gpu_lm_loaded.backoff_to_states == n_gpu_lm.backoff_to_states).all()
|
||||
assert torch.allclose(n_gpu_lm_loaded.backoff_weights, n_gpu_lm.backoff_weights)
|
||||
assert torch.allclose(n_gpu_lm_loaded.final_weights, n_gpu_lm.final_weights)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_load_from_file(self, tmp_path, test_data_dir):
|
||||
vocab_size = 1024
|
||||
kenlm_model_path = Path(test_data_dir) / "asr/kenlm_ngram_lm/parakeet-tdt_ctc-110m-libri-1024.kenlm.tmp.arpa"
|
||||
n_gpu_lm = NGramGPULanguageModel.from_file(kenlm_model_path, vocab_size=vocab_size, normalize_unk=False)
|
||||
nemo_path = tmp_path / "ngram_lm.nemo"
|
||||
n_gpu_lm.save_to(f"{nemo_path}")
|
||||
n_gpu_lm_loaded = NGramGPULanguageModel.from_file(f"{nemo_path}", vocab_size=vocab_size)
|
||||
|
||||
# arcs data
|
||||
assert torch.allclose(n_gpu_lm_loaded.arcs_weights, n_gpu_lm.arcs_weights)
|
||||
assert (n_gpu_lm_loaded.from_states == n_gpu_lm.from_states).all()
|
||||
assert (n_gpu_lm_loaded.to_states == n_gpu_lm.to_states).all()
|
||||
assert (n_gpu_lm_loaded.ilabels == n_gpu_lm.ilabels).all()
|
||||
|
||||
# states data
|
||||
assert (n_gpu_lm_loaded.start_end_arcs == n_gpu_lm.start_end_arcs).all()
|
||||
assert (n_gpu_lm_loaded.state_order == n_gpu_lm.state_order).all()
|
||||
assert (n_gpu_lm_loaded.backoff_to_states == n_gpu_lm.backoff_to_states).all()
|
||||
assert torch.allclose(n_gpu_lm_loaded.backoff_weights, n_gpu_lm.backoff_weights)
|
||||
assert torch.allclose(n_gpu_lm_loaded.final_weights, n_gpu_lm.final_weights)
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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.testing
|
||||
from lhotse.testing.random import deterministic_rng
|
||||
|
||||
from nemo.collections.asr.modules import AudioToMelSpectrogramPreprocessor, ConformerEncoder
|
||||
from nemo.collections.asr.parts.preprocessing import FilterbankFeatures
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", list(range(15950, 16050, 3)))
|
||||
def test_preprocessor_invariant_to_padding(deterministic_rng, length):
|
||||
# Settings corresponding to Canary-1B features
|
||||
f = FilterbankFeatures(n_window_size=400, nfilt=128, pad_to=0).eval()
|
||||
|
||||
# Test data:
|
||||
# * a1: 1s "audio"
|
||||
# * a2: 1s "audio" + 1s padding, keep length tensor unchanged
|
||||
a1 = torch.arange(0, length).unsqueeze(0) / 16000
|
||||
a1l = torch.tensor([length])
|
||||
|
||||
a2 = torch.cat([a1, torch.zeros(1, 16000)], dim=1)
|
||||
a2l = a1l.clone()
|
||||
|
||||
mels1, mels1l = f(a1, a1l)
|
||||
mels2, mels2l = f(a2, a2l)
|
||||
|
||||
# Ideally, we'd have strictly identical results.
|
||||
# However, we observed depending on PyTorch build and environment,
|
||||
# Mel-spectrogram normalization tends to yield non-deterministic results;
|
||||
# specifically, in the computation of numerator in
|
||||
# nemo.collections.asr.parts.preprocessing.features.normalize_batch
|
||||
# where identical inputs lead up to +/- 2e-3 numerical differences.
|
||||
torch.testing.assert_close(mels1[..., :mels1l], mels2[..., :mels1l], atol=5e-2, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [16000])
|
||||
def test_canary_encoder_invariant_to_padding(deterministic_rng, length):
|
||||
preprocessor = AudioToMelSpectrogramPreprocessor(
|
||||
sample_rate=16000,
|
||||
normalize="per_feature",
|
||||
window_size=0.025,
|
||||
window_stride=0.01,
|
||||
window="hann",
|
||||
features=128,
|
||||
n_fft=512,
|
||||
log=True,
|
||||
frame_splicing=1,
|
||||
dither=1e-5,
|
||||
pad_to=0,
|
||||
pad_value=0.0,
|
||||
).eval()
|
||||
encoder = ConformerEncoder(
|
||||
feat_in=128,
|
||||
feat_out=-1,
|
||||
n_layers=17,
|
||||
d_model=512,
|
||||
subsampling="dw_striding",
|
||||
subsampling_factor=8,
|
||||
subsampling_conv_channels=256,
|
||||
causal_downsampling=True,
|
||||
reduction=None,
|
||||
reduction_factor=1,
|
||||
ff_expansion_factor=4,
|
||||
self_attention_model="rel_pos",
|
||||
n_heads=8,
|
||||
att_context_size=[-1, -1],
|
||||
xscaling=False,
|
||||
untie_biases=True,
|
||||
pos_emb_max_len=5000,
|
||||
conv_kernel_size=9,
|
||||
conv_norm_type="batch_norm",
|
||||
conv_context_size=None,
|
||||
dropout=0.1,
|
||||
dropout_pre_encoder=0.1,
|
||||
dropout_emb=0.0,
|
||||
dropout_att=0.1,
|
||||
).eval()
|
||||
|
||||
# Test data:
|
||||
# * a1: 1s "audio"
|
||||
# * a2: 1s "audio" + 1s padding, keep length tensor unchanged
|
||||
a1 = torch.arange(0, length).unsqueeze(0) / 16000
|
||||
a1l = torch.tensor([length])
|
||||
|
||||
a2 = torch.cat([a1, torch.zeros(1, 16000)], dim=1)
|
||||
a2l = a1l.clone()
|
||||
|
||||
mels1, mels1l = preprocessor(input_signal=a1, length=a1l)
|
||||
mels2, mels2l = preprocessor(input_signal=a2, length=a2l)
|
||||
|
||||
torch.testing.assert_close(mels1[..., :mels1l], mels2[..., :mels1l], atol=5e-4, rtol=0)
|
||||
|
||||
# SUBSAMPLING MODULE NOT MISMATCHING
|
||||
activation = {}
|
||||
|
||||
def get_activation(name):
|
||||
def hook(model, input, output):
|
||||
activation[name] = torch.tensor(output.detach().tolist())
|
||||
|
||||
return hook
|
||||
|
||||
for i, layer in enumerate(encoder.pre_encode.conv):
|
||||
if "ReLU" in str(layer):
|
||||
continue
|
||||
layer.register_forward_hook(get_activation(f"{i}:{layer}"))
|
||||
h1, h1l = encoder.pre_encode(mels1.transpose(1, 2), mels1l)
|
||||
inner1 = activation.copy()
|
||||
h2, h2l = encoder.pre_encode(mels2.transpose(1, 2), mels2l)
|
||||
inner2 = activation
|
||||
for k in inner1:
|
||||
torch.testing.assert_close(inner1[k], inner2[k][:, :, : inner1[k].shape[2]], atol=5e-5, rtol=0)
|
||||
|
||||
torch.testing.assert_close(h1[:, :h1l], h2[:, :h1l])
|
||||
|
||||
h1, h1l = encoder(audio_signal=mels1, length=mels1l)
|
||||
h2, h2l = encoder(audio_signal=mels2, length=mels2l)
|
||||
|
||||
torch.testing.assert_close(h1[..., :h1l], h2[..., :h1l])
|
||||
|
||||
|
||||
def test_conformer_inference_invariant_to_batch_size(deterministic_rng):
|
||||
model = ConformerEncoder(feat_in=128, n_layers=2, d_model=128, feat_out=128)
|
||||
model = model.eval()
|
||||
|
||||
audio_signal_bs1, length_bs1 = model.input_example()
|
||||
h_bs1, h_length_bs1 = model(audio_signal=audio_signal_bs1, length=length_bs1)
|
||||
|
||||
audio_signal_bs2 = audio_signal_bs1.repeat(2, 1, 1)
|
||||
length_bs2 = length_bs1.repeat(2)
|
||||
h_bs2, h_length_bs2 = model(audio_signal=audio_signal_bs2, length=length_bs2)
|
||||
|
||||
torch.testing.assert_close(h_bs1, h_bs2[:1])
|
||||
torch.testing.assert_close(h_bs1, h_bs2[1:])
|
||||
@@ -0,0 +1,639 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
from torch import nn
|
||||
|
||||
from nemo.collections.asr.models import SortformerEncLabelModel
|
||||
from nemo.collections.asr.modules.conformer_encoder import ConformerEncoder
|
||||
from nemo.collections.asr.modules.parallel_expert_encoder import (
|
||||
ParallelExpertEncoder,
|
||||
ParallelExpertEncoderPT,
|
||||
_clone_config,
|
||||
_default_dtype,
|
||||
_disable_dist_feature_sync,
|
||||
)
|
||||
|
||||
# ``@experimental`` wraps the class in a wrapt proxy, so ``__new__`` (used to build
|
||||
# bare instances that skip the heavy real ``__init__``) must target the underlying
|
||||
# class. Attribute access / isinstance still go through the proxy name.
|
||||
_PEE = getattr(ParallelExpertEncoder, "__wrapped__", ParallelExpertEncoder)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Module-level context managers / helpers
|
||||
# ----------------------------------------------------------------------------- #
|
||||
@pytest.mark.unit
|
||||
def test_clone_config_is_deep_and_handles_none():
|
||||
cfg = OmegaConf.create({"a": {"b": 1}})
|
||||
clone = _clone_config(cfg)
|
||||
assert clone == cfg
|
||||
clone.a.b = 2
|
||||
assert cfg.a.b == 1 # original untouched
|
||||
assert _clone_config(None) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("target_dtype", [torch.float64, torch.float16])
|
||||
def test_default_dtype_sets_and_restores(target_dtype):
|
||||
prev = torch.get_default_dtype()
|
||||
with _default_dtype(target_dtype):
|
||||
assert torch.get_default_dtype() == target_dtype
|
||||
assert torch.get_default_dtype() == prev
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("noop_dtype", [torch.get_default_dtype(), torch.int32])
|
||||
def test_default_dtype_noop_paths(noop_dtype):
|
||||
# Same-dtype and non-floating dtype are both no-ops.
|
||||
prev = torch.get_default_dtype()
|
||||
with _default_dtype(noop_dtype):
|
||||
assert torch.get_default_dtype() == prev
|
||||
assert torch.get_default_dtype() == prev
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_disable_dist_feature_sync_noop_when_uninitialized():
|
||||
assert not dist.is_initialized()
|
||||
orig = dist.is_initialized
|
||||
with _disable_dist_feature_sync():
|
||||
pass
|
||||
assert dist.is_initialized is orig # nothing patched when dist is down
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Static pure helpers on ParallelExpertEncoder
|
||||
# ----------------------------------------------------------------------------- #
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("max_pos, dim", [(4, 8), (1, 16), (10, 4)])
|
||||
def test_build_sinusoid_position_encoding(max_pos, dim):
|
||||
pe = ParallelExpertEncoder._build_sinusoid_position_encoding(max_pos, dim)
|
||||
assert pe.shape == (max_pos, dim)
|
||||
# row 0: sin(0)=0 on even indices, cos(0)=1 on odd indices
|
||||
assert torch.allclose(pe[0, 0::2], torch.zeros(dim // 2))
|
||||
assert torch.allclose(pe[0, 1::2], torch.ones(dim // 2))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"cur_len, target_len",
|
||||
[(3, 6), (6, 3), (5, 5), (1, 4)],
|
||||
)
|
||||
def test_align_diar_frames_length_and_padding(cur_len, target_len):
|
||||
n_spk = 3
|
||||
diar = torch.arange(cur_len * n_spk, dtype=torch.float32).reshape(1, cur_len, n_spk)
|
||||
out = ParallelExpertEncoder._align_diar_frames(diar, target_len)
|
||||
assert out.shape == (1, target_len, n_spk)
|
||||
if target_len <= cur_len:
|
||||
# truncation keeps the leading frames unchanged
|
||||
assert torch.equal(out, diar[:, :target_len, :])
|
||||
else:
|
||||
# padding repeats the last frame
|
||||
assert torch.equal(out[:, :cur_len, :], diar)
|
||||
for t in range(cur_len, target_len):
|
||||
assert torch.equal(out[:, t, :], diar[:, -1, :])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("param_dtype", [torch.float64, torch.float16])
|
||||
def test_match_module_io_casts_to_param_dtype(param_dtype):
|
||||
module = nn.Linear(4, 4).to(param_dtype)
|
||||
tensor = torch.zeros(2, 4, dtype=torch.float32)
|
||||
out = ParallelExpertEncoder._match_module_io(tensor, module)
|
||||
assert out.dtype == param_dtype
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_match_module_io_paramless_module_unchanged():
|
||||
module = nn.Identity() # no parameters
|
||||
tensor = torch.zeros(2, 4, dtype=torch.float32)
|
||||
out = ParallelExpertEncoder._match_module_io(tensor, module)
|
||||
assert out.dtype == torch.float32
|
||||
assert out is tensor
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# forward() offline/online dispatch
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def dispatch_stub(online_inference_length, chunk_feat_len, training):
|
||||
"""Build a bare ParallelExpertEncoder with stubbed branch methods."""
|
||||
enc = _PEE.__new__(_PEE)
|
||||
nn.Module.__init__(enc)
|
||||
enc.online_inference_length = online_inference_length
|
||||
enc.chunk_feat_len = chunk_feat_len
|
||||
enc.training = training
|
||||
enc._forward = lambda **kw: "offline"
|
||||
enc._forward_online = lambda **kw: "online"
|
||||
return enc
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"online_len, chunk_feat_len, training, n_frames, expected",
|
||||
[
|
||||
(500, 100, False, 200, "online"), # eval + long enough -> online
|
||||
(500, 100, False, 50, "offline"), # eval but shorter than one window
|
||||
(500, 100, True, 200, "offline"), # training always offline
|
||||
(0, 100, False, 200, "offline"), # online disabled
|
||||
(500, 100, False, 100, "offline"), # exactly one window (not strictly greater)
|
||||
],
|
||||
)
|
||||
def test_forward_dispatch(online_len, chunk_feat_len, training, n_frames, expected):
|
||||
enc = dispatch_stub(online_len, chunk_feat_len, training)
|
||||
audio = torch.zeros(1, 8, n_frames)
|
||||
length = torch.tensor([n_frames])
|
||||
assert enc.forward(audio, length) == expected
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# _forward_online orchestration (stubbed ASR encoder, provided spk_targets)
|
||||
# ----------------------------------------------------------------------------- #
|
||||
class _FakeASR(nn.Module):
|
||||
"""Minimal stand-in for the wrapped ConformerEncoder."""
|
||||
|
||||
def __init__(self, d_model: int, sf: int):
|
||||
super().__init__()
|
||||
self.subsampling_factor = sf
|
||||
self.d_model = d_model
|
||||
self._p = nn.Parameter(torch.zeros(1))
|
||||
|
||||
def forward(self, audio_signal, length):
|
||||
b, _, t = audio_signal.shape
|
||||
# generous frame count so the trim logic never clamps
|
||||
t_out = (t + self.subsampling_factor - 1) // self.subsampling_factor + 8
|
||||
out = torch.randn(b, self.d_model, t_out)
|
||||
return out, length // self.subsampling_factor
|
||||
|
||||
|
||||
def online_stub(d_model, n_spk, sf, win, lc, rc):
|
||||
enc = _PEE.__new__(_PEE)
|
||||
nn.Module.__init__(enc)
|
||||
enc.asr_encoder = _FakeASR(d_model, sf)
|
||||
enc.asr_normalize_type = None
|
||||
enc.online_inference_length = win
|
||||
enc.chunk_left_context = lc
|
||||
enc.chunk_right_context = rc
|
||||
enc.chunk_feat_len = win * sf
|
||||
enc.left_ctx_feat_len = lc * sf
|
||||
enc.right_ctx_feat_len = rc * sf
|
||||
enc.freeze_asr = True
|
||||
enc.freeze_diar = False # The stub has no `diarization_model`, so `freeze_diar` must be False to keep
|
||||
enc.asr_norm = nn.LayerNorm(d_model)
|
||||
enc.diar_norm = nn.LayerNorm(n_spk)
|
||||
enc.register_buffer("diar_kernel", torch.randn(n_spk, d_model))
|
||||
enc._suppress_online_pbar = True
|
||||
enc.eval()
|
||||
return enc
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"sf, win, lc, rc, n_frames",
|
||||
[
|
||||
(8, 10, 2, 2, 240), # 3 full chunks
|
||||
(8, 10, 0, 0, 200), # partial last chunk, no context
|
||||
(4, 5, 1, 1, 64), # 4 chunks, small subsampling
|
||||
(8, 50, 5, 5, 160), # single chunk (n_frames < window)
|
||||
],
|
||||
)
|
||||
def test_forward_online_output_length_telescopes(sf, win, lc, rc, n_frames):
|
||||
d_model, n_spk, b = 16, 4, 2
|
||||
enc = online_stub(d_model, n_spk, sf, win, lc, rc)
|
||||
|
||||
mels = torch.randn(b, 80, n_frames)
|
||||
length = torch.tensor([n_frames] * b)
|
||||
spk_targets = torch.rand(b, 5, n_spk) # arbitrary; aligned internally
|
||||
|
||||
outputs, encoded_len = enc._forward_online(audio_signal=mels, length=length, spk_targets=spk_targets)
|
||||
|
||||
expected_t = round(n_frames / sf)
|
||||
assert outputs.shape == (b, d_model, expected_t)
|
||||
assert encoded_len.tolist() == [expected_t] * b
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# ParallelExpertEncoderPT.is_pe_nemo
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def write_nemo(path, *, target=None, include_cfg=True):
|
||||
with tarfile.open(path, "w") as tf:
|
||||
if include_cfg:
|
||||
data = (f"target: {target}\n" if target is not None else "foo: bar\n").encode()
|
||||
info = tarfile.TarInfo(name="model_config.yaml")
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
else:
|
||||
data = b"not a config"
|
||||
info = tarfile.TarInfo(name="weights.ckpt")
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"target, expected",
|
||||
[
|
||||
("nemo.collections.asr.modules.parallel_expert_encoder.ParallelExpertEncoderPT", True),
|
||||
("ParallelExpertEncoderPT", True),
|
||||
("nemo.collections.asr.models.SomethingElse", False),
|
||||
(None, False), # model_config.yaml present but no `target`
|
||||
],
|
||||
)
|
||||
def test_is_pe_nemo_by_target(tmp_path, target, expected):
|
||||
nemo_path = str(tmp_path / "bundle.nemo")
|
||||
write_nemo(nemo_path, target=target)
|
||||
assert ParallelExpertEncoderPT.is_pe_nemo(nemo_path) is expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_is_pe_nemo_without_model_config(tmp_path):
|
||||
nemo_path = str(tmp_path / "no_cfg.nemo")
|
||||
write_nemo(nemo_path, include_cfg=False)
|
||||
assert ParallelExpertEncoderPT.is_pe_nemo(nemo_path) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[None, 123, "missing.nemo", "not_a_nemo.txt"],
|
||||
)
|
||||
def test_is_pe_nemo_rejects_bad_paths(tmp_path, bad_path):
|
||||
# a real-but-non-.nemo file to exercise the suffix check
|
||||
if bad_path == "not_a_nemo.txt":
|
||||
p = tmp_path / "not_a_nemo.txt"
|
||||
p.write_text("hello")
|
||||
bad_path = str(p)
|
||||
assert ParallelExpertEncoderPT.is_pe_nemo(bad_path) is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# ParallelExpertEncoderPT.save_to_nemo guard rails
|
||||
# ----------------------------------------------------------------------------- #
|
||||
@pytest.mark.unit
|
||||
def test_save_to_nemo_rejects_non_encoder(tmp_path):
|
||||
with pytest.raises(TypeError):
|
||||
ParallelExpertEncoderPT.save_to_nemo(
|
||||
nn.Linear(2, 2), str(tmp_path / "out.nemo"), template_bundle_path=str(tmp_path / "tpl.nemo")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_to_nemo_missing_template(tmp_path):
|
||||
# __new__ produces a real ParallelExpertEncoder instance (passes isinstance)
|
||||
# without running the heavy __init__, so we reach the template existence check.
|
||||
fake_encoder = _PEE.__new__(_PEE)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
ParallelExpertEncoderPT.save_to_nemo(
|
||||
fake_encoder,
|
||||
str(tmp_path / "out.nemo"),
|
||||
template_bundle_path=str(tmp_path / "does_not_exist.nemo"),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# End-to-end fusion with real toy encoders
|
||||
#
|
||||
# ParallelExpertEncoder loads two real sub-encoders and fuses them:
|
||||
# * an ASR ConformerEncoder (cf. tests/collections/asr/test_conformer_encoder.py)
|
||||
# * a Sortformer diarizer (cf. tests/collections/speaker_tasks/test_diar_sortformer_models.py)
|
||||
# These tests build tiny-but-real instances of both and run the wrapper end to end.
|
||||
# ----------------------------------------------------------------------------- #
|
||||
_MEL_FEATURES = 128
|
||||
_ASR_D_MODEL = 32
|
||||
_DIAR_FC_D_MODEL = 32
|
||||
_DIAR_TF_D_MODEL = 16
|
||||
_N_SPK = 4
|
||||
_SUBSAMPLING_FACTOR = 8
|
||||
|
||||
|
||||
def toy_asr_encoder_cfg() -> DictConfig:
|
||||
"""Tiny ConformerEncoder config the PE encoder mounts as its ASR branch."""
|
||||
return DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': _MEL_FEATURES,
|
||||
'feat_out': -1,
|
||||
'n_layers': 1,
|
||||
'd_model': _ASR_D_MODEL,
|
||||
'subsampling': 'dw_striding',
|
||||
'subsampling_factor': _SUBSAMPLING_FACTOR,
|
||||
'subsampling_conv_channels': 16,
|
||||
'ff_expansion_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 4,
|
||||
'att_context_size': [-1, -1],
|
||||
'conv_kernel_size': 9,
|
||||
'dropout': 0.0,
|
||||
'dropout_pre_encoder': 0.0,
|
||||
'dropout_emb': 0.0,
|
||||
'dropout_att': 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def toy_diarization_model_cfg() -> DictConfig:
|
||||
"""Tiny SortformerEncLabelModel config the PE encoder mounts as its diar branch."""
|
||||
model_defaults = {'fc_d_model': _DIAR_FC_D_MODEL, 'tf_d_model': _DIAR_TF_D_MODEL}
|
||||
return DictConfig(
|
||||
{
|
||||
'target': 'nemo.collections.asr.models.sortformer_diar_models.SortformerEncLabelModel',
|
||||
'sample_rate': 16000,
|
||||
'pil_weight': 0.5,
|
||||
'ats_weight': 0.5,
|
||||
'max_num_of_spks': _N_SPK,
|
||||
'streaming_mode': False,
|
||||
'async_streaming': False,
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'preprocessor': DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
'normalize': 'per_feature',
|
||||
'window_size': 0.025,
|
||||
'sample_rate': 16000,
|
||||
'window_stride': 0.01,
|
||||
'window': 'hann',
|
||||
'features': _MEL_FEATURES,
|
||||
'n_fft': 512,
|
||||
'frame_splicing': 1,
|
||||
'dither': 0.00001,
|
||||
}
|
||||
),
|
||||
'encoder': DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.ConformerEncoder',
|
||||
'feat_in': _MEL_FEATURES,
|
||||
'feat_out': -1,
|
||||
'n_layers': 1,
|
||||
'd_model': _DIAR_FC_D_MODEL,
|
||||
'subsampling': 'dw_striding',
|
||||
'subsampling_factor': _SUBSAMPLING_FACTOR,
|
||||
'subsampling_conv_channels': 16,
|
||||
'causal_downsampling': False,
|
||||
'ff_expansion_factor': 4,
|
||||
'self_attention_model': 'rel_pos',
|
||||
'n_heads': 4,
|
||||
'att_context_size': [-1, -1],
|
||||
'conv_kernel_size': 9,
|
||||
'conv_norm_type': 'batch_norm',
|
||||
'dropout': 0.0,
|
||||
'dropout_pre_encoder': 0.0,
|
||||
'dropout_emb': 0.0,
|
||||
'dropout_att': 0.0,
|
||||
}
|
||||
),
|
||||
'transformer_encoder': DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.transformer.transformer_encoders.TransformerEncoder',
|
||||
'num_layers': 1,
|
||||
'hidden_size': _DIAR_TF_D_MODEL,
|
||||
'inner_size': 32,
|
||||
'num_attention_heads': 4,
|
||||
'attn_score_dropout': 0.0,
|
||||
'attn_layer_dropout': 0.0,
|
||||
'ffn_dropout': 0.0,
|
||||
'hidden_act': 'relu',
|
||||
'pre_ln': False,
|
||||
'pre_ln_final_layer_norm': True,
|
||||
}
|
||||
),
|
||||
'sortformer_modules': DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.modules.sortformer_modules.SortformerModules',
|
||||
'num_spks': _N_SPK,
|
||||
'dropout_rate': 0.0,
|
||||
'fc_d_model': _DIAR_FC_D_MODEL,
|
||||
'tf_d_model': _DIAR_TF_D_MODEL,
|
||||
}
|
||||
),
|
||||
'loss': DictConfig(
|
||||
{
|
||||
'_target_': 'nemo.collections.asr.losses.bce_loss.BCELoss',
|
||||
'weight': None,
|
||||
'reduction': 'mean',
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_toy_pe_encoder(**overrides) -> ParallelExpertEncoder:
|
||||
"""Construct a real ParallelExpertEncoder from the tiny ASR + diar configs."""
|
||||
kwargs = dict(
|
||||
asr_encoder_cfg=toy_asr_encoder_cfg(),
|
||||
diarization_model_cfg=toy_diarization_model_cfg(),
|
||||
asr_normalize_type='per_feature',
|
||||
# Keep the input far below one window so forward() stays on the offline path.
|
||||
online_inference_length=500,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return ParallelExpertEncoder(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pe_encoder_builds_and_wires_both_real_encoders():
|
||||
enc = build_toy_pe_encoder()
|
||||
# The two fused sub-encoders are the real classes, not stubs.
|
||||
assert isinstance(enc.asr_encoder, ConformerEncoder)
|
||||
assert isinstance(enc.diarization_model, SortformerEncLabelModel)
|
||||
# ConformerEncoder-compatible drop-in properties come from the ASR branch.
|
||||
assert enc.d_model == _ASR_D_MODEL
|
||||
assert enc.subsampling_factor == _SUBSAMPLING_FACTOR
|
||||
# Speaker count + fusion kernel come from the diar branch.
|
||||
assert enc.n_spk == _N_SPK
|
||||
assert enc.diar_kernel.shape == (_N_SPK, _ASR_D_MODEL)
|
||||
# freeze_diar defaults to True -> diar params are frozen, ASR params remain trainable.
|
||||
assert all(not p.requires_grad for p in enc.diarization_model.parameters())
|
||||
assert any(p.requires_grad for p in enc.asr_encoder.parameters())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("batch_size, n_frames", [(1, 160), (2, 200)])
|
||||
def test_pe_encoder_offline_forward_runs_internal_diarizer(batch_size, n_frames):
|
||||
enc = build_toy_pe_encoder().eval()
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs, encoded_len = enc(mels, length) # spk_targets=None -> Sortformer runs internally
|
||||
|
||||
expected_t = int(encoded_len[0].item())
|
||||
assert outputs.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert expected_t > 0
|
||||
assert torch.isfinite(outputs).all()
|
||||
assert encoded_len.tolist() == [expected_t] * batch_size
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pe_encoder_offline_forward_accepts_diar_override_and_fuses_it():
|
||||
enc = build_toy_pe_encoder().eval()
|
||||
batch_size, n_frames = 2, 160
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long)
|
||||
|
||||
# Arbitrary diar frame count: PE aligns it to the ASR frame count internally.
|
||||
dp1 = torch.rand(batch_size, 7, _N_SPK)
|
||||
dp2 = torch.rand(batch_size, 7, _N_SPK)
|
||||
|
||||
with torch.no_grad():
|
||||
out1, len1 = enc(mels, length, spk_targets=dp1)
|
||||
out2, len2 = enc(mels, length, spk_targets=dp2)
|
||||
|
||||
expected_t = int(len1[0].item())
|
||||
assert out1.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert torch.equal(len1, len2)
|
||||
assert torch.isfinite(out1).all()
|
||||
# Same audio + same (dropout-free, eval) ASR branch, but different speaker
|
||||
# predictions must change the fused output -> proves the diar branch is fused in.
|
||||
assert not torch.allclose(out1, out2)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pe_encoder_online_forward_matches_conformer_io_with_real_encoders():
|
||||
# Small window so a modest input crosses onto the long-form online path.
|
||||
enc = build_toy_pe_encoder(
|
||||
online_inference_length=10,
|
||||
chunk_left_context=2,
|
||||
chunk_right_context=2,
|
||||
diar_fifo_len=10,
|
||||
diar_spkcache_update_period=20,
|
||||
diar_spkcache_len=20,
|
||||
).eval()
|
||||
enc._suppress_online_pbar = True
|
||||
|
||||
batch_size, n_frames = 1, 320 # > online_inference_length * subsampling_factor (=80)
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs, encoded_len = enc(mels, length)
|
||||
|
||||
expected_t = int(encoded_len[0].item())
|
||||
assert outputs.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert expected_t > 0
|
||||
assert torch.isfinite(outputs).all()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# GPU end-to-end fusion with real toy encoders
|
||||
#
|
||||
# These mirror the CPU end-to-end tests but run on CUDA. They additionally
|
||||
# exercise the device/dtype-bridging machinery the wrapper exists for: fp32 mels
|
||||
# fed into (optionally) bf16 experts on the GPU, handled by `_match_module_io`
|
||||
# (offline) and `_default_dtype` / `_disable_dist_feature_sync` (online).
|
||||
# ----------------------------------------------------------------------------- #
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="PEE GPU test requires CUDA")
|
||||
@pytest.mark.parametrize("batch_size, n_frames", [(1, 160), (2, 200)])
|
||||
def test_pe_encoder_offline_forward_on_gpu(batch_size, n_frames):
|
||||
enc = build_toy_pe_encoder().eval().cuda()
|
||||
# Mels arrive un-normalised in fp32 (the SALM perception contract).
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames, device="cuda", dtype=torch.float32)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
outputs, encoded_len = enc(mels, length) # spk_targets=None -> Sortformer runs internally
|
||||
|
||||
expected_t = int(encoded_len[0].item())
|
||||
assert outputs.is_cuda
|
||||
assert outputs.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert expected_t > 0
|
||||
assert torch.isfinite(outputs).all()
|
||||
assert encoded_len.tolist() == [expected_t] * batch_size
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.skipif(
|
||||
not (torch.cuda.is_available() and torch.cuda.is_bf16_supported()),
|
||||
reason="PEE bf16 GPU test requires CUDA with bf16 support",
|
||||
)
|
||||
def test_pe_encoder_offline_forward_bf16_experts_on_gpu():
|
||||
# Experts run in bf16 while mels stay fp32 -> exercises `_match_module_io`
|
||||
# device/dtype bridging on both branches before their conv subsampling.
|
||||
enc = build_toy_pe_encoder().eval().cuda().to(torch.bfloat16)
|
||||
batch_size, n_frames = 2, 200
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames, device="cuda", dtype=torch.float32)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
outputs, encoded_len = enc(mels, length)
|
||||
|
||||
expected_t = int(encoded_len[0].item())
|
||||
assert outputs.is_cuda
|
||||
assert outputs.dtype == torch.bfloat16
|
||||
assert outputs.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert torch.isfinite(outputs).all()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="PEE GPU test requires CUDA")
|
||||
def test_pe_encoder_offline_forward_accepts_diar_override_on_gpu():
|
||||
enc = build_toy_pe_encoder().eval().cuda()
|
||||
batch_size, n_frames = 2, 160
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames, device="cuda", dtype=torch.float32)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long, device="cuda")
|
||||
|
||||
dp1 = torch.rand(batch_size, 7, _N_SPK, device="cuda")
|
||||
dp2 = torch.rand(batch_size, 7, _N_SPK, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
out1, len1 = enc(mels, length, spk_targets=dp1)
|
||||
out2, len2 = enc(mels, length, spk_targets=dp2)
|
||||
|
||||
expected_t = int(len1[0].item())
|
||||
assert out1.is_cuda
|
||||
assert out1.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert torch.equal(len1, len2)
|
||||
assert torch.isfinite(out1).all()
|
||||
# Different speaker predictions must change the fused output.
|
||||
assert not torch.allclose(out1, out2)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="PEE GPU test requires CUDA")
|
||||
def test_pe_encoder_online_forward_on_gpu():
|
||||
enc = (
|
||||
build_toy_pe_encoder(
|
||||
online_inference_length=10,
|
||||
chunk_left_context=2,
|
||||
chunk_right_context=2,
|
||||
diar_fifo_len=10,
|
||||
diar_spkcache_update_period=20,
|
||||
diar_spkcache_len=20,
|
||||
)
|
||||
.eval()
|
||||
.cuda()
|
||||
)
|
||||
enc._suppress_online_pbar = True
|
||||
|
||||
batch_size, n_frames = 1, 320 # > online_inference_length * subsampling_factor (=80)
|
||||
mels = torch.randn(batch_size, _MEL_FEATURES, n_frames, device="cuda", dtype=torch.float32)
|
||||
length = torch.full((batch_size,), n_frames, dtype=torch.long, device="cuda")
|
||||
|
||||
with torch.no_grad():
|
||||
outputs, encoded_len = enc(mels, length)
|
||||
|
||||
expected_t = int(encoded_len[0].item())
|
||||
assert outputs.is_cuda
|
||||
assert outputs.shape == (batch_size, _ASR_D_MODEL, expected_t)
|
||||
assert expected_t > 0
|
||||
assert torch.isfinite(outputs).all()
|
||||
@@ -0,0 +1,532 @@
|
||||
# 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 json
|
||||
import os
|
||||
import tempfile
|
||||
from collections import namedtuple
|
||||
from typing import List, Type, Union
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import NoisePerturbation, ShiftPerturbation, SilencePerturbation
|
||||
from nemo.collections.asr.parts.preprocessing.segment import AudioSegment, select_channels
|
||||
|
||||
|
||||
class TestSelectChannels:
|
||||
num_samples = 1000
|
||||
max_diff_tol = 1e-9
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("channel_selector", [None, 'average', 0, 1, [0, 1]])
|
||||
def test_single_channel_input(self, channel_selector: Type[Union[str, int, List[int]]]):
|
||||
"""Cover the case with single-channel input signal.
|
||||
Channel selector should not do anything in this case.
|
||||
"""
|
||||
golden_out = signal_in = np.random.rand(self.num_samples)
|
||||
|
||||
if channel_selector not in [None, 0, 'average']:
|
||||
# Expect a failure if looking for a different channel when input is 1D
|
||||
with pytest.raises(ValueError):
|
||||
# UUT
|
||||
select_channels(signal_in, channel_selector)
|
||||
else:
|
||||
# UUT
|
||||
signal_out = select_channels(signal_in, channel_selector)
|
||||
|
||||
# Check difference
|
||||
max_diff = np.max(np.abs(signal_out - golden_out))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("num_channels", [2, 4])
|
||||
@pytest.mark.parametrize("channel_selector", [None, 'average', 0, [1], [0, 1]])
|
||||
def test_multi_channel_input(self, num_channels: int, channel_selector: Type[Union[str, int, List[int]]]):
|
||||
"""Cover the case with multi-channel input signal and single-
|
||||
or multi-channel output.
|
||||
"""
|
||||
signal_in = np.random.rand(self.num_samples, num_channels)
|
||||
|
||||
# calculate golden output
|
||||
if channel_selector is None:
|
||||
golden_out = signal_in
|
||||
elif channel_selector == 'average':
|
||||
golden_out = np.mean(signal_in, axis=1)
|
||||
else:
|
||||
golden_out = signal_in[:, channel_selector].squeeze()
|
||||
|
||||
# UUT
|
||||
signal_out = select_channels(signal_in, channel_selector)
|
||||
|
||||
# Check difference
|
||||
max_diff = np.max(np.abs(signal_out - golden_out))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("num_channels", [1, 2])
|
||||
@pytest.mark.parametrize("channel_selector", [2, [1, 2]])
|
||||
def test_select_more_channels_than_available(
|
||||
self, num_channels: int, channel_selector: Type[Union[str, int, List[int]]]
|
||||
):
|
||||
"""This test is expecting the UUT to fail because we ask for more channels
|
||||
than available in the input signal.
|
||||
"""
|
||||
signal_in = np.random.rand(self.num_samples, num_channels)
|
||||
|
||||
# expect failure since we ask for more channels than available
|
||||
with pytest.raises(ValueError):
|
||||
# UUT
|
||||
select_channels(signal_in, channel_selector)
|
||||
|
||||
|
||||
class TestAudioSegment:
|
||||
|
||||
sample_rate = 16000
|
||||
signal_duration_sec = 2
|
||||
max_diff_tol = 1e-9
|
||||
|
||||
@property
|
||||
def num_samples(self):
|
||||
return self.sample_rate * self.signal_duration_sec
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("num_channels", [1, 4])
|
||||
@pytest.mark.parametrize("channel_selector", [None, 'average', 0, 1, [0, 1]])
|
||||
def test_init_single_channel(self, num_channels: int, channel_selector: Type[Union[str, int, List[int]]]):
|
||||
"""Test the constructor directly."""
|
||||
if num_channels == 1:
|
||||
# samples is a one-dimensional vector for single-channel signal
|
||||
samples = np.random.rand(self.num_samples)
|
||||
else:
|
||||
samples = np.random.rand(self.num_samples, num_channels)
|
||||
|
||||
if (isinstance(channel_selector, int) and channel_selector >= num_channels) or (
|
||||
isinstance(channel_selector, list) and max(channel_selector) >= num_channels
|
||||
):
|
||||
# Expect a failure if looking for a different channel when input is 1D
|
||||
with pytest.raises(ValueError):
|
||||
# Construct UUT
|
||||
uut = AudioSegment(samples=samples, sample_rate=self.sample_rate, channel_selector=channel_selector)
|
||||
else:
|
||||
# Construct UUT
|
||||
uut = AudioSegment(samples=samples, sample_rate=self.sample_rate, channel_selector=channel_selector)
|
||||
|
||||
# Create golden reference
|
||||
# Note: AudioSegment converts input samples to float32
|
||||
golden_samples = select_channels(samples.astype('float32'), channel_selector)
|
||||
expected_num_channels = 1 if golden_samples.ndim == 1 else golden_samples.shape[1]
|
||||
|
||||
# Test UUT
|
||||
assert uut.num_channels == expected_num_channels
|
||||
assert uut.num_samples == self.num_samples
|
||||
assert uut.sample_rate == self.sample_rate
|
||||
assert uut.duration == self.signal_duration_sec
|
||||
max_diff = np.max(np.abs(uut.samples - golden_samples))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
# Test zero padding
|
||||
pad_length = 42
|
||||
uut.pad(pad_length, symmetric=False)
|
||||
# compare to golden references
|
||||
assert uut.num_samples == self.num_samples + pad_length
|
||||
assert np.all(uut.samples[-pad_length:] == 0.0)
|
||||
max_diff = np.max(np.abs(uut.samples[:-pad_length] - golden_samples))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
# Test subsegment
|
||||
start_time = 0.2 * self.signal_duration_sec
|
||||
end_time = 0.5 * self.signal_duration_sec
|
||||
uut.subsegment(start_time=start_time, end_time=end_time)
|
||||
# compare to golden references
|
||||
start_sample = int(round(start_time * self.sample_rate))
|
||||
end_sample = int(round(end_time * self.sample_rate))
|
||||
max_diff = np.max(np.abs(uut.samples - golden_samples[start_sample:end_sample]))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("num_channels", [1, 4])
|
||||
@pytest.mark.parametrize("channel_selector", [None, 'average', 0])
|
||||
def test_from_file(self, num_channels, channel_selector):
|
||||
"""Test loading a signal from a file."""
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
# Prepare a wav file
|
||||
audio_file = os.path.join(test_dir, 'audio.wav')
|
||||
if num_channels == 1:
|
||||
# samples is a one-dimensional vector for single-channel signal
|
||||
samples = np.random.rand(self.num_samples)
|
||||
else:
|
||||
samples = np.random.rand(self.num_samples, num_channels)
|
||||
sf.write(audio_file, samples, self.sample_rate, 'float')
|
||||
|
||||
# Create UUT
|
||||
uut = AudioSegment.from_file(audio_file, channel_selector=channel_selector)
|
||||
|
||||
# Create golden reference
|
||||
# Note: AudioSegment converts input samples to float32
|
||||
golden_samples = select_channels(samples.astype('float32'), channel_selector)
|
||||
expected_num_channels = 1 if golden_samples.ndim == 1 else golden_samples.shape[1]
|
||||
|
||||
# Test UUT
|
||||
assert uut.num_channels == expected_num_channels
|
||||
assert uut.num_samples == self.num_samples
|
||||
assert uut.sample_rate == self.sample_rate
|
||||
assert uut.duration == self.signal_duration_sec
|
||||
max_diff = np.max(np.abs(uut.samples - golden_samples))
|
||||
assert max_diff < self.max_diff_tol
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("data_channels", [1, 4])
|
||||
@pytest.mark.parametrize("noise_channels", [1, 4])
|
||||
def test_noise_perturb_channels(self, data_channels, noise_channels):
|
||||
"""Test loading a signal from a file."""
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
# Prepare a wav file
|
||||
audio_file = os.path.join(test_dir, 'audio.wav')
|
||||
if data_channels == 1:
|
||||
# samples is a one-dimensional vector for single-channel signal
|
||||
samples = np.random.rand(self.num_samples)
|
||||
else:
|
||||
samples = np.random.rand(self.num_samples, data_channels)
|
||||
sf.write(audio_file, samples, self.sample_rate, 'float')
|
||||
|
||||
noise_file = os.path.join(test_dir, 'noise.wav')
|
||||
if noise_channels == 1:
|
||||
# samples is a one-dimensional vector for single-channel signal
|
||||
samples = np.random.rand(self.num_samples)
|
||||
else:
|
||||
samples = np.random.rand(self.num_samples, noise_channels)
|
||||
sf.write(noise_file, samples, self.sample_rate, 'float')
|
||||
|
||||
manifest_file = os.path.join(test_dir, 'noise_manifest.json')
|
||||
with open(manifest_file, 'w') as fout:
|
||||
item = {'audio_filepath': os.path.abspath(noise_file), 'label': '-', 'duration': 0.1, 'offset': 0.0}
|
||||
fout.write(f'{json.dumps(item)}\n')
|
||||
|
||||
perturber = NoisePerturbation(manifest_file)
|
||||
audio = AudioSegment.from_file(audio_file)
|
||||
noise = AudioSegment.from_file(noise_file)
|
||||
|
||||
if data_channels == noise_channels:
|
||||
try:
|
||||
_ = perturber.perturb_with_input_noise(audio, noise, ref_mic=0)
|
||||
except ValueError as e:
|
||||
assert False, "perturb_with_input_noise failed with ref_mic=0"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = perturber.perturb_with_input_noise(audio, noise, ref_mic=data_channels)
|
||||
|
||||
try:
|
||||
_ = perturber.perturb_with_foreground_noise(audio, noise, ref_mic=0)
|
||||
except ValueError as e:
|
||||
assert False, "perturb_with_foreground_noise failed with ref_mic=0"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = perturber.perturb_with_foreground_noise(audio, noise, ref_mic=data_channels)
|
||||
else:
|
||||
with pytest.raises(ValueError):
|
||||
_ = perturber.perturb_with_input_noise(audio, noise)
|
||||
with pytest.raises(ValueError):
|
||||
_ = perturber.perturb_with_foreground_noise(audio, noise)
|
||||
|
||||
def test_silence_perturb(self):
|
||||
"""Test loading a signal from a file and apply silence perturbation"""
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
# Prepare a wav file
|
||||
audio_file = os.path.join(test_dir, 'audio.wav')
|
||||
# samples is a one-dimensional vector for single-channel signal
|
||||
samples = np.random.rand(self.num_samples)
|
||||
sf.write(audio_file, samples, self.sample_rate, 'float')
|
||||
|
||||
dur = 2
|
||||
perturber = SilencePerturbation(
|
||||
min_start_silence_secs=dur,
|
||||
max_start_silence_secs=dur,
|
||||
min_end_silence_secs=dur,
|
||||
max_end_silence_secs=dur,
|
||||
)
|
||||
|
||||
audio = AudioSegment.from_file(audio_file)
|
||||
ori_audio_len = len(audio._samples)
|
||||
_ = perturber.perturb(audio)
|
||||
|
||||
assert len(audio._samples) == ori_audio_len + 2 * dur * self.sample_rate
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"num_channels, channel_selectors",
|
||||
[
|
||||
(1, [None, 'average', 0]),
|
||||
(3, [None, 'average', 0, 1, [0, 1]]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("sample_rate", [8000, 16000, 22500])
|
||||
def test_audio_segment_from_file(self, tmpdir, num_channels, channel_selectors, sample_rate):
|
||||
"""Test loading and audio signal from a file."""
|
||||
signal_len_sec = 4
|
||||
num_samples = signal_len_sec * sample_rate
|
||||
num_examples = 10
|
||||
rtol, atol = 1e-5, 1e-6
|
||||
|
||||
for n in range(num_examples):
|
||||
# Create a test vector
|
||||
audio_file = os.path.join(tmpdir, f'test_audio_{n:02}.wav')
|
||||
samples = np.random.randn(num_samples, num_channels)
|
||||
sf.write(audio_file, samples, sample_rate, 'float')
|
||||
|
||||
for channel_selector in channel_selectors:
|
||||
if channel_selector is None:
|
||||
ref_samples = samples
|
||||
elif isinstance(channel_selector, int) or isinstance(channel_selector, list):
|
||||
ref_samples = samples[:, channel_selector]
|
||||
elif channel_selector == 'average':
|
||||
ref_samples = np.mean(samples, axis=1)
|
||||
else:
|
||||
raise ValueError(f'Unexpected value of channel_selector {channel_selector}')
|
||||
|
||||
# 1) Load complete audio
|
||||
# Reference
|
||||
ref_samples = ref_samples.squeeze()
|
||||
ref_channels = 1 if ref_samples.ndim == 1 else ref_samples.shape[1]
|
||||
|
||||
# UUT
|
||||
audio_segment = AudioSegment.from_file(audio_file, channel_selector=channel_selector)
|
||||
|
||||
# Test
|
||||
assert (
|
||||
audio_segment.sample_rate == sample_rate
|
||||
), f'channel_selector {channel_selector}, sample rate not matching: {audio_segment.sample_rate} != {sample_rate}'
|
||||
assert (
|
||||
audio_segment.num_channels == ref_channels
|
||||
), f'channel_selector {channel_selector}, num channels not matching: {audio_segment.num_channels} != {ref_channels}'
|
||||
assert audio_segment.num_samples == len(
|
||||
ref_samples
|
||||
), f'channel_selector {channel_selector}, num samples not matching: {audio_segment.num_samples} != {len(ref_samples)}'
|
||||
assert np.allclose(
|
||||
audio_segment.samples, ref_samples, rtol=rtol, atol=atol
|
||||
), f'channel_selector {channel_selector}, samples not matching'
|
||||
|
||||
# 2) Load a with duration=None and offset=None, should load the whole audio
|
||||
|
||||
# UUT
|
||||
audio_segment = AudioSegment.from_file(
|
||||
audio_file, offset=None, duration=None, channel_selector=channel_selector
|
||||
)
|
||||
|
||||
# Test
|
||||
assert (
|
||||
audio_segment.sample_rate == sample_rate
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, sample rate not matching: {audio_segment.sample_rate} != {sample_rate}'
|
||||
assert (
|
||||
audio_segment.num_channels == ref_channels
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, num channels not matching: {audio_segment.num_channels} != {ref_channels}'
|
||||
assert audio_segment.num_samples == len(
|
||||
ref_samples
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, num samples not matching: {audio_segment.num_samples} != {len(ref_samples)}'
|
||||
assert np.allclose(
|
||||
audio_segment.samples, ref_samples, rtol=rtol, atol=atol
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, samples not matching'
|
||||
|
||||
# 3) Load a random segment
|
||||
offset = 0.45 * np.random.rand() * signal_len_sec
|
||||
duration = 0.45 * np.random.rand() * signal_len_sec
|
||||
|
||||
# Reference
|
||||
start = int(offset * sample_rate)
|
||||
end = start + int(duration * sample_rate)
|
||||
ref_samples = ref_samples[start:end, ...]
|
||||
|
||||
# UUT
|
||||
audio_segment = AudioSegment.from_file(
|
||||
audio_file, offset=offset, duration=duration, channel_selector=channel_selector
|
||||
)
|
||||
|
||||
# Test
|
||||
assert (
|
||||
audio_segment.sample_rate == sample_rate
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, sample rate not matching: {audio_segment.sample_rate} != {sample_rate}'
|
||||
assert (
|
||||
audio_segment.num_channels == ref_channels
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, num channels not matching: {audio_segment.num_channels} != {ref_channels}'
|
||||
assert audio_segment.num_samples == len(
|
||||
ref_samples
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, num samples not matching: {audio_segment.num_samples} != {len(ref_samples)}'
|
||||
assert np.allclose(
|
||||
audio_segment.samples, ref_samples, rtol=rtol, atol=atol
|
||||
), f'channel_selector {channel_selector}, offset {offset}, duration {duration}, samples not matching'
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"num_channels, channel_selectors",
|
||||
[
|
||||
(1, [None, 'average', 0]),
|
||||
(3, [None, 'average', 0, 1, [0, 1]]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("offset", [0, 1.5])
|
||||
@pytest.mark.parametrize("duration", [1, 2])
|
||||
def test_audio_segment_multichannel_with_list(self, tmpdir, num_channels, channel_selectors, offset, duration):
|
||||
"""Test loading an audio signal from a list of single-channel files."""
|
||||
sample_rate = 16000
|
||||
signal_len_sec = 5
|
||||
num_samples = signal_len_sec * sample_rate
|
||||
rtol, atol = 1e-5, 1e-6
|
||||
|
||||
# Random samples
|
||||
samples = np.random.rand(num_samples, num_channels)
|
||||
|
||||
# Save audio
|
||||
audio_files = []
|
||||
for m in range(num_channels):
|
||||
a_file = os.path.join(tmpdir, f'ch_{m}.wav')
|
||||
sf.write(a_file, samples[:, m], sample_rate)
|
||||
audio_files.append(a_file)
|
||||
mc_file = os.path.join(tmpdir, f'mc.wav')
|
||||
sf.write(mc_file, samples, sample_rate)
|
||||
|
||||
for channel_selector in channel_selectors:
|
||||
|
||||
# UUT: loading audio from a list of files
|
||||
uut_segment = AudioSegment.from_file(
|
||||
audio_file=audio_files, offset=offset, duration=duration, channel_selector=channel_selector
|
||||
)
|
||||
|
||||
# Reference: load from the original file
|
||||
ref_segment = AudioSegment.from_file(
|
||||
audio_file=mc_file, offset=offset, duration=duration, channel_selector=channel_selector
|
||||
)
|
||||
|
||||
# Check
|
||||
assert (
|
||||
uut_segment.sample_rate == ref_segment.sample_rate
|
||||
), f'channel_selector {channel_selector}: expecting {ref_segment.sample_rate}, but UUT segment has {uut_segment.sample_rate}'
|
||||
assert (
|
||||
uut_segment.num_samples == ref_segment.num_samples
|
||||
), f'channel_selector {channel_selector}: expecting {ref_segment.num_samples}, but UUT segment has {uut_segment.num_samples}'
|
||||
assert np.allclose(
|
||||
uut_segment.samples, ref_segment.samples, rtol=rtol, atol=atol
|
||||
), f'channel_selector {channel_selector}: samples not matching'
|
||||
|
||||
# Try to get a channel that is out of range.
|
||||
with pytest.raises(RuntimeError, match="Channel cannot be selected"):
|
||||
AudioSegment.from_file(audio_file=audio_files, channel_selector=num_channels)
|
||||
|
||||
if num_channels > 1:
|
||||
# Try to load a list of multichannel files
|
||||
# This is expected to fail since we only support loading a single-channel signal
|
||||
# from each file when audio_file is a list
|
||||
with pytest.raises(RuntimeError, match="Expecting a single-channel audio signal"):
|
||||
AudioSegment.from_file(audio_file=[mc_file, mc_file])
|
||||
|
||||
with pytest.raises(RuntimeError, match="Expecting a single-channel audio signal"):
|
||||
AudioSegment.from_file(audio_file=[mc_file, mc_file], channel_selector=0)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("target_sr", [8000, 16000])
|
||||
def test_audio_segment_trim_match(self, tmpdir, target_sr):
|
||||
"""Test loading and audio signal from a file matches when using a path and a list
|
||||
for different target_sr, int_values and trim setups.
|
||||
"""
|
||||
sample_rate = 24000
|
||||
signal_len_sec = 2
|
||||
num_samples = signal_len_sec * sample_rate
|
||||
num_examples = 10
|
||||
|
||||
TrimSetup = namedtuple("TrimSetup", "ref top_db frame_length hop_length")
|
||||
trim_setups = []
|
||||
trim_setups.append(TrimSetup(np.max, 10, 2048, 1024))
|
||||
trim_setups.append(TrimSetup(1.0, 35, 2048, 1024))
|
||||
trim_setups.append(TrimSetup(0.8, 45, 2048, 1024))
|
||||
|
||||
for n in range(num_examples):
|
||||
# Create a test vector
|
||||
audio_file = os.path.join(tmpdir, f'test_audio_{n:02}.wav')
|
||||
samples = np.random.randn(num_samples)
|
||||
# normalize
|
||||
samples = samples / np.max(samples)
|
||||
# apply random scaling and window to have some samples cut by trim
|
||||
samples = np.random.rand() * np.hanning(num_samples) * samples
|
||||
sf.write(audio_file, samples, sample_rate, 'float')
|
||||
|
||||
for trim_setup in trim_setups:
|
||||
# UUT 1: load from a path
|
||||
audio_segment_1 = AudioSegment.from_file(
|
||||
audio_file,
|
||||
target_sr=target_sr,
|
||||
trim=True,
|
||||
trim_ref=trim_setup.ref,
|
||||
trim_top_db=trim_setup.top_db,
|
||||
trim_frame_length=trim_setup.frame_length,
|
||||
trim_hop_length=trim_setup.hop_length,
|
||||
)
|
||||
|
||||
# UUT 2: load from a list
|
||||
audio_segment_2 = AudioSegment.from_file(
|
||||
[audio_file],
|
||||
target_sr=target_sr,
|
||||
trim=True,
|
||||
trim_ref=trim_setup.ref,
|
||||
trim_top_db=trim_setup.top_db,
|
||||
trim_frame_length=trim_setup.frame_length,
|
||||
trim_hop_length=trim_setup.hop_length,
|
||||
)
|
||||
|
||||
# Test
|
||||
assert audio_segment_1 == audio_segment_2, f'trim setup {trim_setup}, loaded segments not matching'
|
||||
|
||||
|
||||
class TestShiftPerturbation:
|
||||
sample_rate = 16000
|
||||
|
||||
def _make_audio_segment(self, duration_sec=1.0):
|
||||
"""Create a simple AudioSegment with a sine wave for testing."""
|
||||
num_samples = int(duration_sec * self.sample_rate)
|
||||
t = np.linspace(0, duration_sec, num_samples, dtype=np.float32)
|
||||
samples = np.sin(2 * np.pi * 440 * t)
|
||||
return AudioSegment(samples=samples, sample_rate=self.sample_rate)
|
||||
|
||||
def test_shift_perturbation_normal(self):
|
||||
"""Shift perturbation modifies audio when shift is within duration."""
|
||||
perturb = ShiftPerturbation(min_shift_ms=-5.0, max_shift_ms=5.0)
|
||||
segment = self._make_audio_segment(duration_sec=1.0)
|
||||
original = segment.samples.copy()
|
||||
perturb.perturb(segment)
|
||||
assert segment.samples.shape == original.shape, "Audio length should not change"
|
||||
|
||||
def test_shift_perturbation_short_audio_not_skipped(self):
|
||||
"""Shift perturbation should clamp and apply shift for short audio, not silently skip."""
|
||||
perturb = ShiftPerturbation(min_shift_ms=10.0, max_shift_ms=20.0)
|
||||
duration_sec = 0.005 # 5ms — shorter than min_shift_ms
|
||||
segment = self._make_audio_segment(duration_sec=duration_sec)
|
||||
original = segment.samples.copy()
|
||||
perturb.perturb(segment)
|
||||
assert segment.samples.shape == original.shape, "Audio length should not change"
|
||||
|
||||
@pytest.mark.parametrize("duration_sec", [0.001, 0.01, 0.1, 1.0])
|
||||
def test_shift_perturbation_preserves_length(self, duration_sec):
|
||||
"""Audio length must be preserved regardless of duration."""
|
||||
perturb = ShiftPerturbation(min_shift_ms=-50.0, max_shift_ms=50.0)
|
||||
segment = self._make_audio_segment(duration_sec=duration_sec)
|
||||
original_len = len(segment.samples)
|
||||
perturb.perturb(segment)
|
||||
assert len(segment.samples) == original_len, "Shift perturbation must preserve audio length"
|
||||
|
||||
def test_shift_perturbation_zero_shift(self):
|
||||
"""When min and max shift are both 0, audio should be unchanged."""
|
||||
perturb = ShiftPerturbation(min_shift_ms=0.0, max_shift_ms=0.0)
|
||||
segment = self._make_audio_segment(duration_sec=0.5)
|
||||
original = segment.samples.copy()
|
||||
perturb.perturb(segment)
|
||||
np.testing.assert_array_equal(segment.samples, original, "Zero shift should not modify audio")
|
||||
@@ -0,0 +1,446 @@
|
||||
# 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 pytest
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.losses import ContrastiveLoss
|
||||
from nemo.collections.asr.models import EncDecDenoiseMaskedTokenPredModel, SpeechEncDecSelfSupervisedModel
|
||||
from nemo.core.classes.common import typecheck
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ssl_model():
|
||||
preprocessor = {
|
||||
'cls': 'nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor',
|
||||
'params': dict({'pad_to': 16, 'dither': 0}),
|
||||
}
|
||||
|
||||
model_defaults = {'enc_hidden': 32, 'dec_out': 128}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': 64,
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
spec_augment = {
|
||||
'_target_': 'nemo.collections.asr.modules.MaskedPatchAugmentation',
|
||||
'freq_masks': 3,
|
||||
'freq_width': 20,
|
||||
'patch_size': 16,
|
||||
'mask_patches': 0.5,
|
||||
}
|
||||
|
||||
loss_list_contr_mlm = {
|
||||
'contr': {
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoderReconstruction',
|
||||
'feat_in': model_defaults['enc_hidden'],
|
||||
'feat_hidden': 128,
|
||||
'feat_out': model_defaults['dec_out'],
|
||||
'stride_layers': 0,
|
||||
'non_stride_layers': 0,
|
||||
'stride_transpose': False,
|
||||
},
|
||||
'loss': {
|
||||
'_target_': 'nemo.collections.asr.losses.ContrastiveLoss',
|
||||
'in_dim': 64,
|
||||
'proj_dim': model_defaults['dec_out'],
|
||||
'combine_time_steps': 1,
|
||||
'quantized_targets': True,
|
||||
'codebook_size': 64,
|
||||
'sample_from_same_utterance_only': True,
|
||||
'sample_from_non_masked': False,
|
||||
'num_negatives': 3,
|
||||
},
|
||||
},
|
||||
'mlm': {
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': model_defaults['enc_hidden'],
|
||||
'num_classes': 4096,
|
||||
},
|
||||
'loss': {'_target_': 'nemo.collections.asr.losses.MLMLoss', 'combine_time_steps': 1},
|
||||
'targets_from_loss': "contr",
|
||||
},
|
||||
}
|
||||
|
||||
modelConfig_contr_mlm = DictConfig(
|
||||
{
|
||||
'preprocessor': DictConfig(preprocessor),
|
||||
'spec_augment': DictConfig(spec_augment),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
'encoder': DictConfig(encoder),
|
||||
'loss_list': DictConfig(loss_list_contr_mlm),
|
||||
}
|
||||
)
|
||||
ssl_model = SpeechEncDecSelfSupervisedModel(cfg=modelConfig_contr_mlm)
|
||||
return ssl_model
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def denoise_mlm_ssl_model():
|
||||
|
||||
model_defaults = {
|
||||
"subsampling_factor": 1,
|
||||
'enc_hidden': 32,
|
||||
'dec_out': 128,
|
||||
"sample_rate": 16000,
|
||||
"num_classes": 32,
|
||||
"num_books": 1,
|
||||
"code_dim": 16,
|
||||
"squeeze_single": False,
|
||||
"mask_position": "pre_conv", # position to apply masking, before or after conv subsampling, choices in ['pre_conv', 'post_conv']
|
||||
}
|
||||
|
||||
preprocessor = {
|
||||
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
|
||||
"sample_rate": model_defaults["sample_rate"],
|
||||
"normalize": "per_feature",
|
||||
"window_size": 0.025,
|
||||
"window_stride": 0.01,
|
||||
"window": "hann",
|
||||
"features": 80,
|
||||
"n_fft": 512,
|
||||
"log": True,
|
||||
"frame_splicing": 1,
|
||||
"dither": 0.00001,
|
||||
"pad_to": 16,
|
||||
"pad_value": 0.0,
|
||||
}
|
||||
|
||||
encoder = {
|
||||
'cls': 'nemo.collections.asr.modules.ConvASREncoder',
|
||||
'params': {
|
||||
'feat_in': preprocessor["features"],
|
||||
'activation': 'relu',
|
||||
'conv_mask': True,
|
||||
'jasper': [
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
{
|
||||
'filters': model_defaults['enc_hidden'],
|
||||
'repeat': 1,
|
||||
'kernel': [1],
|
||||
'stride': [1],
|
||||
'dilation': [1],
|
||||
'dropout': 0.0,
|
||||
'residual': False,
|
||||
'separable': True,
|
||||
'se': True,
|
||||
'se_context_size': -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
spec_augment = {
|
||||
'_target_': 'nemo.collections.asr.modules.SpectrogramAugmentation',
|
||||
'freq_masks': 0,
|
||||
'time_masks': 0,
|
||||
'freq_width': 16,
|
||||
'time_width': 0.05,
|
||||
}
|
||||
|
||||
masking = {
|
||||
"_target_": "nemo.collections.asr.modules.RandomBlockMasking",
|
||||
"block_size": 40, # for pre_conv masking, 10ms per frame, 400ms per block with block_size=40
|
||||
"mask_prob": 0.01, # for allow_overlap=True, this means the mask prob for each frame; otherwise it means the overall masked proportion
|
||||
"feat_in": preprocessor["features"],
|
||||
"freeze": True,
|
||||
"allow_overlap": True,
|
||||
}
|
||||
|
||||
quantizer = {
|
||||
"_target_": "nemo.collections.asr.modules.RandomProjectionVectorQuantizer",
|
||||
"feat_in": preprocessor["features"],
|
||||
"code_dim": model_defaults["code_dim"],
|
||||
"num_books": model_defaults["num_books"],
|
||||
"num_classes": model_defaults["num_classes"],
|
||||
"dist_fn": "l2", # choices=["l2", "cosine"]
|
||||
"freeze": True,
|
||||
"squeeze_single": model_defaults["squeeze_single"],
|
||||
"combine_time_steps": model_defaults["subsampling_factor"], # conformer sub-sampling ratio
|
||||
}
|
||||
|
||||
decoder = {
|
||||
"_target_": "nemo.collections.asr.modules.MultiSoftmaxDecoder",
|
||||
"feat_in": model_defaults["enc_hidden"],
|
||||
"num_classes": model_defaults["num_classes"],
|
||||
"num_decoders": model_defaults["num_books"],
|
||||
"squeeze_single": model_defaults["squeeze_single"],
|
||||
"use_bias": True,
|
||||
}
|
||||
|
||||
loss = {
|
||||
"_target_": "nemo.collections.asr.losses.MultiMLMLoss",
|
||||
"combine_time_steps": model_defaults[
|
||||
"subsampling_factor"
|
||||
], # conformer sub-sampling ratio for 'pre_conv', 1 for 'post_conv'
|
||||
"mask_threshold": 0.8,
|
||||
"num_decoders": model_defaults["num_books"],
|
||||
"squeeze_single": model_defaults["squeeze_single"],
|
||||
}
|
||||
|
||||
optim = {
|
||||
"name": "adamw",
|
||||
"lr": 5.0,
|
||||
# optimizer arguments
|
||||
"betas": [0.9, 0.98],
|
||||
"weight_decay": 1e-3,
|
||||
}
|
||||
|
||||
model_config = DictConfig(
|
||||
{
|
||||
"preprocessor": DictConfig(preprocessor),
|
||||
"spec_augment": DictConfig(spec_augment),
|
||||
'model_defaults': DictConfig(model_defaults),
|
||||
"masking": DictConfig(masking),
|
||||
"quantizer": DictConfig(quantizer),
|
||||
"encoder": DictConfig(encoder),
|
||||
"decoder": DictConfig(decoder),
|
||||
"loss": DictConfig(loss),
|
||||
"optim": DictConfig(optim),
|
||||
}
|
||||
)
|
||||
ssl_model = EncDecDenoiseMaskedTokenPredModel(cfg=model_config)
|
||||
return ssl_model
|
||||
|
||||
|
||||
class TestSSLModel:
|
||||
@pytest.mark.unit
|
||||
def test_constructor(self, ssl_model):
|
||||
confdict = ssl_model.to_config_dict()
|
||||
instance2 = SpeechEncDecSelfSupervisedModel.from_config_dict(confdict)
|
||||
assert isinstance(instance2, SpeechEncDecSelfSupervisedModel)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contr_nonquant(self, ssl_model):
|
||||
modelConfig_contr_nonquant = ssl_model.to_config_dict()
|
||||
|
||||
loss_list_contr_nonquant = dict(modelConfig_contr_nonquant['loss_list'])
|
||||
del loss_list_contr_nonquant['mlm']
|
||||
|
||||
loss_list_contr_nonquant['contr']['loss']['quantized_targets'] = False
|
||||
|
||||
modelConfig_contr_nonquant['loss_list'] = DictConfig(loss_list_contr_nonquant)
|
||||
|
||||
ssl_model = SpeechEncDecSelfSupervisedModel(cfg=modelConfig_contr_nonquant)
|
||||
|
||||
input_signal = torch.randn(size=(4, 64000))
|
||||
length = torch.randint(low=48000, high=64000, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
spectrograms, spec_masks, encoded, encoded_len = ssl_model.forward(
|
||||
input_signal=input_signal, input_signal_length=length
|
||||
)
|
||||
|
||||
loss_value, loss_val_dict = ssl_model.decoder_loss_step(spectrograms, spec_masks, encoded, encoded_len)
|
||||
|
||||
assert len(loss_val_dict) == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contr_mlm(self, ssl_model):
|
||||
|
||||
input_signal = torch.randn(size=(4, 64000))
|
||||
length = torch.randint(low=48000, high=64000, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
spectrograms, spec_masks, encoded, encoded_len = ssl_model.forward(
|
||||
input_signal=input_signal, input_signal_length=length
|
||||
)
|
||||
|
||||
loss_value, loss_val_dict = ssl_model.decoder_loss_step(spectrograms, spec_masks, encoded, encoded_len)
|
||||
|
||||
assert len(loss_val_dict) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contr_mlm_multi(self, ssl_model):
|
||||
modelConfig_contr_mlm_multi = ssl_model.to_config_dict()
|
||||
|
||||
model_defaults = modelConfig_contr_mlm_multi['model_defaults']
|
||||
|
||||
loss_list_contr_mlm_multi = dict(modelConfig_contr_mlm_multi['loss_list'])
|
||||
loss_list_contr_mlm_multi['mlm_2'] = {
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': model_defaults['enc_hidden'],
|
||||
'num_classes': 4096,
|
||||
},
|
||||
'loss': {'_target_': 'nemo.collections.asr.losses.MLMLoss', 'combine_time_steps': 1},
|
||||
'output_from_layer': "encoder.0",
|
||||
'targets_from_loss': "contr",
|
||||
}
|
||||
loss_list_contr_mlm_multi['mlm_3'] = {
|
||||
'decoder': {
|
||||
'_target_': 'nemo.collections.asr.modules.ConvASRDecoder',
|
||||
'feat_in': model_defaults['enc_hidden'],
|
||||
'num_classes': 4096,
|
||||
},
|
||||
'loss': {'_target_': 'nemo.collections.asr.losses.MLMLoss', 'combine_time_steps': 1},
|
||||
'output_from_layer': "encoder.1",
|
||||
'targets_from_loss': "contr",
|
||||
}
|
||||
modelConfig_contr_mlm_multi['loss_list'] = DictConfig(loss_list_contr_mlm_multi)
|
||||
|
||||
ssl_model = SpeechEncDecSelfSupervisedModel(cfg=modelConfig_contr_mlm_multi)
|
||||
|
||||
input_signal = torch.randn(size=(4, 64000))
|
||||
length = torch.randint(low=48000, high=64000, size=[4])
|
||||
|
||||
with torch.no_grad():
|
||||
spectrograms, spec_masks, encoded, encoded_len = ssl_model.forward(
|
||||
input_signal=input_signal, input_signal_length=length
|
||||
)
|
||||
|
||||
loss_value, loss_val_dict = ssl_model.decoder_loss_step(spectrograms, spec_masks, encoded, encoded_len)
|
||||
|
||||
assert len(loss_val_dict) == 4
|
||||
|
||||
|
||||
class TestContrastiveLoss:
|
||||
@pytest.mark.unit
|
||||
def test_sample_negatives_fewer_frames_than_num_negatives(self):
|
||||
num_negatives = 40
|
||||
num_frames = 5
|
||||
num = num_frames
|
||||
feat_dim = 128
|
||||
|
||||
loss = ContrastiveLoss(in_dim=64, proj_dim=feat_dim, num_negatives=num_negatives, quantized_targets=False)
|
||||
y = torch.randn(num_frames, feat_dim)
|
||||
|
||||
negs, neg_idxs = loss.sample_negatives(y, num)
|
||||
|
||||
assert neg_idxs.shape == (num, num_negatives)
|
||||
assert negs.shape == (num_negatives, num, feat_dim)
|
||||
|
||||
|
||||
class TestDenoiseMLMSSLModel:
|
||||
@pytest.mark.unit
|
||||
def test_forward(self, denoise_mlm_ssl_model):
|
||||
input_signal = torch.randn(size=(4, 64000))
|
||||
input_length = torch.randint(low=48000, high=64000, size=[4])
|
||||
noise = 0.1 * torch.ones_like(input_signal)
|
||||
noisy_input_signal = input_signal + noise
|
||||
noisy_input_length = input_length
|
||||
with torch.no_grad():
|
||||
with typecheck.disable_checks():
|
||||
log_probs, encoded_len, masks, tokens = denoise_mlm_ssl_model.forward(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=input_length,
|
||||
noisy_input_signal=noisy_input_signal,
|
||||
noisy_input_signal_length=noisy_input_length,
|
||||
)
|
||||
|
||||
assert log_probs.size(0) == 4
|
||||
assert log_probs.size(2) == denoise_mlm_ssl_model.cfg.model_defaults.num_classes
|
||||
assert encoded_len.size(0) == 4
|
||||
assert masks.size(0) == 4
|
||||
assert tokens.size(0) == 4
|
||||
assert masks.sum() == 0.0 # no mask should be applied to the input by default
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward_masked(self, denoise_mlm_ssl_model: EncDecDenoiseMaskedTokenPredModel):
|
||||
input_signal = torch.randn(size=(4, 64000))
|
||||
input_length = torch.randint(low=48000, high=64000, size=[4])
|
||||
noise = 0.1 * torch.ones_like(input_signal)
|
||||
noisy_input_signal = input_signal + noise
|
||||
noisy_input_length = input_length
|
||||
|
||||
with torch.no_grad():
|
||||
with typecheck.disable_checks():
|
||||
log_probs, encoded_len, masks, tokens = denoise_mlm_ssl_model.forward(
|
||||
input_signal=input_signal,
|
||||
input_signal_length=input_length,
|
||||
noisy_input_signal=noisy_input_signal,
|
||||
noisy_input_signal_length=noisy_input_length,
|
||||
apply_mask=True,
|
||||
)
|
||||
|
||||
loss_value = denoise_mlm_ssl_model.loss(
|
||||
masks=masks, decoder_outputs=log_probs, targets=tokens, decoder_lengths=encoded_len
|
||||
)
|
||||
|
||||
assert log_probs.size(0) == 4
|
||||
assert log_probs.size(2) == denoise_mlm_ssl_model.cfg.model_defaults.num_classes
|
||||
assert encoded_len.size(0) == 4
|
||||
assert masks.size(0) == 4
|
||||
assert tokens.size(0) == 4
|
||||
assert masks.sum() > 0.0 # mask should be applied to the input
|
||||
assert not torch.isnan(loss_value)
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. 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 json
|
||||
import multiprocessing
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
|
||||
nemo_text_processing = pytest.importorskip("nemo_text_processing", reason="Requires nemo_text_processing to run")
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
from nemo.collections.asr.data.text_to_text import TextToTextDataset, TextToTextItem, TextToTextIterableDataset
|
||||
from nemo.collections.common import tokenizers
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def set_multiprocessing_method():
|
||||
"""
|
||||
Try to set 'fork' multiprocessing method to avoid problems with multiprocessing in PyTest on MacOS
|
||||
"""
|
||||
if multiprocessing.get_start_method(allow_none=True) != "fork":
|
||||
multiprocessing.set_start_method("fork", force=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def speakers_path(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("textonly") / "speakers.txt"
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for speaker in [1, 2, 3]:
|
||||
print(f"{speaker}", file=f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def textonly_manifest_path(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("textonly") / "manifest.json"
|
||||
texts = [
|
||||
"lorem ipsum dolor sit amet consectetur adipiscing elit",
|
||||
"nullam rhoncus sapien eros eu mollis sem euismod non",
|
||||
]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for text in texts:
|
||||
print(json.dumps(dict(text=text, tts_text_normalized=text)), file=f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def textonly_unnormalized_manifest_path(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("textonly") / "manifest_nonorm.json"
|
||||
texts = [
|
||||
(
|
||||
"lorem ipsum dolor sit amet consectetur adipiscing elit",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
),
|
||||
(
|
||||
"nullam rhoncus sapien eros eu mollis sem euismod non nineteen",
|
||||
"Nullam rhoncus sapien eros, eu mollis sem euismod non 19.",
|
||||
),
|
||||
]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for asr_text, tts_text in texts:
|
||||
print(json.dumps(dict(text=asr_text, tts_text=tts_text)), file=f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tts_normalizer():
|
||||
normalizer = Normalizer(
|
||||
lang="en",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=None,
|
||||
)
|
||||
return normalizer
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def asr_tokenizer(test_data_dir):
|
||||
tokenizer_path = os.path.join(test_data_dir, "asr", "tokenizers", "an4_wpe_128", 'vocab.txt')
|
||||
tokenizer = tokenizers.AutoTokenizer(pretrained_model_name='bert-base-cased', vocab_file=tokenizer_path)
|
||||
return tokenizer
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tts_tokenizer():
|
||||
@dataclass
|
||||
class G2PConfig:
|
||||
_target_: str = "nemo.collections.tts.g2p.models.en_us_arpabet.EnglishG2p"
|
||||
phoneme_dict: str = str(BASE_DIR / "scripts/tts_dataset_files/cmudict-0.7b_nv22.10")
|
||||
heteronyms: str = str(BASE_DIR / "scripts/tts_dataset_files/heteronyms-052722")
|
||||
phoneme_probability: float = 0.5
|
||||
|
||||
@dataclass
|
||||
class TextTokenizerCfg:
|
||||
_target_: str = "nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.EnglishPhonemesTokenizer"
|
||||
punct: bool = True
|
||||
stresses: bool = True
|
||||
chars: bool = True
|
||||
apostrophe: bool = True
|
||||
pad_with_space: bool = True
|
||||
add_blank_at: bool = True
|
||||
g2p: G2PConfig = field(default_factory=lambda: G2PConfig())
|
||||
|
||||
config = OmegaConf.create(OmegaConf.to_yaml(TextTokenizerCfg()))
|
||||
return safe_instantiate(config)
|
||||
|
||||
|
||||
class TestTextToTextDataset:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("tokenizer_workers", [1, 2])
|
||||
def test_text_to_text_dataset(
|
||||
self,
|
||||
textonly_manifest_path,
|
||||
tokenizer_workers,
|
||||
speakers_path,
|
||||
asr_tokenizer,
|
||||
tts_tokenizer,
|
||||
tts_normalizer,
|
||||
set_multiprocessing_method,
|
||||
):
|
||||
"""
|
||||
Test map-style text-to-text dataset with ASR and TTS tokenizers with normalized text
|
||||
"""
|
||||
dataset = TextToTextDataset(
|
||||
manifest_filepath=textonly_manifest_path,
|
||||
speakers_filepath=speakers_path,
|
||||
asr_tokenizer=asr_tokenizer,
|
||||
asr_use_start_end_token=False,
|
||||
tts_parser=tts_tokenizer,
|
||||
tts_text_pad_id=0,
|
||||
tts_text_normalizer=tts_normalizer,
|
||||
tts_text_normalizer_call_kwargs=dict(),
|
||||
tokenizer_workers=tokenizer_workers,
|
||||
)
|
||||
assert len(dataset) == 2
|
||||
item = dataset[0]
|
||||
assert isinstance(item, TextToTextItem)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_text_to_text_dataset_unnormalized(
|
||||
self, textonly_unnormalized_manifest_path, speakers_path, asr_tokenizer, tts_tokenizer, tts_normalizer
|
||||
):
|
||||
"""
|
||||
Test TextToTextDataset with ASR and TTS tokenizers with non-normalized text
|
||||
"""
|
||||
dataset = TextToTextDataset(
|
||||
manifest_filepath=textonly_unnormalized_manifest_path,
|
||||
speakers_filepath=speakers_path,
|
||||
asr_tokenizer=asr_tokenizer,
|
||||
asr_use_start_end_token=False,
|
||||
tts_parser=tts_tokenizer,
|
||||
tts_text_pad_id=0,
|
||||
tts_text_normalizer=tts_normalizer,
|
||||
tts_text_normalizer_call_kwargs=dict(),
|
||||
)
|
||||
assert len(dataset) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("tokenizer_workers", [1, 2])
|
||||
def test_text_to_text_iterable_dataset(
|
||||
self,
|
||||
textonly_manifest_path,
|
||||
tokenizer_workers,
|
||||
speakers_path,
|
||||
asr_tokenizer,
|
||||
tts_tokenizer,
|
||||
tts_normalizer,
|
||||
set_multiprocessing_method,
|
||||
):
|
||||
"""
|
||||
Test iterable text-to-text dataset with ASR and TTS tokenizers with normalized text
|
||||
"""
|
||||
dataset = TextToTextIterableDataset(
|
||||
manifest_filepath=textonly_manifest_path,
|
||||
speakers_filepath=speakers_path,
|
||||
asr_tokenizer=asr_tokenizer,
|
||||
asr_use_start_end_token=False,
|
||||
tts_parser=tts_tokenizer,
|
||||
tts_text_pad_id=0,
|
||||
tts_text_normalizer=tts_normalizer,
|
||||
tts_text_normalizer_call_kwargs=dict(),
|
||||
tokenizer_workers=tokenizer_workers,
|
||||
)
|
||||
assert len(dataset) == 2
|
||||
item = next(iter(dataset))
|
||||
assert isinstance(item, TextToTextItem)
|
||||
@@ -0,0 +1,647 @@
|
||||
# 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 numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.modules.transformer_encoder import (
|
||||
FeatureStacking,
|
||||
TransformerEncoder,
|
||||
TransformerEncoderConfig,
|
||||
)
|
||||
from nemo.collections.asr.parts.submodules.multi_head_attention import RotaryPositionalEncoding
|
||||
|
||||
|
||||
class TestTransformerEncoderConfig:
|
||||
@pytest.mark.unit
|
||||
def test_default_config(self):
|
||||
cfg = TransformerEncoderConfig()
|
||||
assert cfg.feat_in == 128
|
||||
assert cfg.d_model == 512
|
||||
assert cfg.n_heads == 8
|
||||
assert cfg.n_layers == 17
|
||||
assert cfg.drop_rate == 0.1
|
||||
assert cfg.qkv_bias is False
|
||||
assert cfg.qk_norm is False
|
||||
assert cfg.ff_expansion == 4.0
|
||||
assert cfg.pre_block_norm is True
|
||||
assert cfg.subsampling_factor == 4
|
||||
assert cfg.attn_mode == "full"
|
||||
assert cfg.self_attention_model == "rel_pos"
|
||||
assert cfg.rope_base == 10000.0
|
||||
assert cfg.rotary_fraction == 1.0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_custom_config(self):
|
||||
cfg = TransformerEncoderConfig(
|
||||
feat_in=128, d_model=1280, n_heads=16, n_layers=32, qk_norm=True, self_attention_model="abs_pos"
|
||||
)
|
||||
assert cfg.feat_in == 128
|
||||
assert cfg.d_model == 1280
|
||||
assert cfg.n_heads == 16
|
||||
assert cfg.n_layers == 32
|
||||
assert cfg.qk_norm is True
|
||||
assert cfg.self_attention_model == "abs_pos"
|
||||
|
||||
|
||||
class TestFeatureStacking:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("subsampling_factor", [2, 4, 8])
|
||||
def test_output_shape(self, subsampling_factor):
|
||||
B, C, T = 2, 80, 400
|
||||
stacking = FeatureStacking(subsampling_factor=subsampling_factor, feat_in=C, feat_out=256)
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([400, 300])
|
||||
|
||||
out, out_lengths = stacking(x, lengths)
|
||||
expected_t = stacking.compute_num_out_frames(T)
|
||||
assert out.shape == (B, expected_t, 256)
|
||||
assert out_lengths[0].item() == expected_t
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_padding_when_not_divisible(self):
|
||||
B, C, T = 1, 80, 401
|
||||
subsampling_factor = 4
|
||||
stacking = FeatureStacking(subsampling_factor=subsampling_factor, feat_in=C, feat_out=256)
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([401])
|
||||
|
||||
out, out_lengths = stacking(x, lengths)
|
||||
expected_t = stacking.compute_num_out_frames(T)
|
||||
assert out.shape == (B, expected_t, 256)
|
||||
assert out_lengths[0].item() == expected_t
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_length_shorter_than_batch(self):
|
||||
"""Output length must be ceil(sample_length / factor), not dependent on batch T."""
|
||||
B, C, T = 2, 80, 403
|
||||
subsampling_factor = 4
|
||||
stacking = FeatureStacking(subsampling_factor=subsampling_factor, feat_in=C, feat_out=256)
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([401, 397])
|
||||
|
||||
_, out_lengths = stacking(x, lengths)
|
||||
assert out_lengths[0].item() == stacking.compute_num_out_frames(401)
|
||||
assert out_lengths[1].item() == stacking.compute_num_out_frames(397)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_padding_when_divisible(self):
|
||||
B, C, T = 1, 80, 400
|
||||
stacking = FeatureStacking(subsampling_factor=4, feat_in=C, feat_out=256)
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([400])
|
||||
|
||||
out, out_lengths = stacking(x, lengths)
|
||||
assert out.shape == (B, stacking.compute_num_out_frames(T), 256)
|
||||
assert out_lengths[0].item() == stacking.compute_num_out_frames(T)
|
||||
|
||||
|
||||
class TestBypassPreEncode:
|
||||
"""Testing bypass pre-encode functionality."""
|
||||
|
||||
def test_bypass_pre_encode_forward(self):
|
||||
"""Testing that forward works with "bypass pre-encode" mode.
|
||||
|
||||
Forwards are wrapped in ``torch.no_grad()`` so the test runs on CPU as well as GPU:
|
||||
FlexAttention's CPU path refuses to run when any input requires gradients (parameters
|
||||
of an ``nn.Module`` do by default), and we are only checking output shapes here, never
|
||||
calling ``.backward()``.
|
||||
"""
|
||||
# For pre-encoded embeddings, the shape is (batch_size, n_frames, emb_dim)
|
||||
batch_size = 2
|
||||
n_frames, emb_dim, feat_out = 17, 64, 8 # emb_dim=64 with n_heads=4 -> head_dim=16 (>= 16)
|
||||
random_input = torch.rand((batch_size, n_frames, emb_dim))
|
||||
random_length = torch.tensor([n_frames] * batch_size, dtype=torch.int64)
|
||||
|
||||
model = TransformerEncoder(
|
||||
feat_in=10,
|
||||
n_layers=3,
|
||||
d_model=emb_dim,
|
||||
n_heads=4,
|
||||
feat_out=feat_out,
|
||||
drop_rate=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
)
|
||||
model.train()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=random_input, length=random_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=random_input, length=random_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
def test_error_shape_invalid_bypass_pre_encode_forward(self):
|
||||
"""
|
||||
Testing that error messages are correctly triggered regarding "bypass pre-encode" mode.
|
||||
Both correct samples and wrongs samples are tested.
|
||||
|
||||
(1) bypass_pre_encode = False (default):
|
||||
`audio_signal` must be a tensor containing audio features.
|
||||
Shape: (batch, self._feat_in, n_frames)
|
||||
(2) bypass_pre_encode = True:
|
||||
`audio_signal` must be a tensor containing pre-encoded embeddings.
|
||||
Shape: (batch, n_frame, self.d_model)
|
||||
"""
|
||||
batch_size = 2
|
||||
n_frames, emb_dim, feat_in, feat_out = 17, 64, 10, 8 # emb_dim=64 with n_heads=4 -> head_dim=16 (>= 16)
|
||||
|
||||
pre_encode_input = torch.rand((batch_size, n_frames, emb_dim))
|
||||
feat_input = torch.rand((batch_size, feat_in, n_frames))
|
||||
input_length = torch.tensor([n_frames] * batch_size, dtype=torch.int64)
|
||||
|
||||
model = TransformerEncoder(
|
||||
feat_in=feat_in,
|
||||
n_layers=3,
|
||||
d_model=emb_dim,
|
||||
n_heads=4,
|
||||
feat_out=feat_out,
|
||||
drop_rate=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
)
|
||||
sub_sampled_n_frames = np.ceil(n_frames / model.subsampling_factor)
|
||||
|
||||
# Test with bypass_pre_encode = True, should be pre_encode_input but given feat_input.
|
||||
model.train()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=feat_input, length=input_length, bypass_pre_encode=True)
|
||||
|
||||
model.eval()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=feat_input, length=input_length, bypass_pre_encode=True)
|
||||
|
||||
# Test with bypass_pre_encode = True, given the correct input pre_encode_input.
|
||||
# NB: forwards that actually reach FlexAttention are wrapped in ``torch.no_grad()`` so
|
||||
# the test passes on CPU (FlexAttention's CPU path refuses inputs that require grad).
|
||||
# The ``pytest.raises(ValueError)`` blocks above/below intentionally do *not* need this
|
||||
# wrapper because the shape check in ``TransformerEncoder.forward()`` raises before any
|
||||
# attention computation.
|
||||
model.train()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=True)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, n_frames)
|
||||
|
||||
# Test with bypass_pre_encode = False, should be feat_input but given pre_encode_input.
|
||||
model.train()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=False)
|
||||
|
||||
model.eval()
|
||||
with pytest.raises(ValueError):
|
||||
model(audio_signal=pre_encode_input, length=input_length, bypass_pre_encode=False)
|
||||
|
||||
# Test with bypass_pre_encode = False, given the correct input feat_input.
|
||||
model.train()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=feat_input, length=input_length, bypass_pre_encode=False)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, sub_sampled_n_frames)
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
fwd_outputs = model(audio_signal=feat_input, length=input_length, bypass_pre_encode=False)[0]
|
||||
assert fwd_outputs.shape == (batch_size, feat_out, sub_sampled_n_frames)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bypass_pre_encode_matches_manual_pre_encode(self):
|
||||
"""``bypass_pre_encode=True`` must skip *only* the pre-encoder.
|
||||
|
||||
Running the pre-encoder by hand and feeding its output back in with
|
||||
``bypass_pre_encode=True`` should reproduce the full forward
|
||||
(``bypass_pre_encode=False``) exactly, because the positional-encoding, norm and
|
||||
Transformer-block stack downstream of the pre-encoder is identical on both paths.
|
||||
"""
|
||||
B, feat_in, T, d_model, feat_out = 2, 32, 64, 64, 8 # d_model=64 with n_heads=4 -> head_dim=16 (>= 16)
|
||||
model = TransformerEncoder(
|
||||
feat_in=feat_in,
|
||||
d_model=d_model,
|
||||
n_heads=4,
|
||||
n_layers=2,
|
||||
feat_out=feat_out,
|
||||
subsampling_factor=4,
|
||||
drop_rate=0.0,
|
||||
dropout_pre_encoder=0.0,
|
||||
dropout_emb=0.0,
|
||||
)
|
||||
model.eval()
|
||||
|
||||
mel = torch.randn(B, feat_in, T)
|
||||
lengths = torch.tensor([T, T - 8], dtype=torch.int64)
|
||||
|
||||
with torch.no_grad():
|
||||
out_full, len_full = model(audio_signal=mel, length=lengths, bypass_pre_encode=False)
|
||||
|
||||
# Reproduce just the pre-encoder, then bypass it on the next call.
|
||||
pre_x, pre_len = model.pre_encode(mel, lengths)
|
||||
out_bypass, len_bypass = model(audio_signal=pre_x, length=pre_len, bypass_pre_encode=True)
|
||||
|
||||
assert out_full.shape == out_bypass.shape == (B, feat_out, pre_x.shape[1])
|
||||
assert torch.equal(len_full, len_bypass)
|
||||
assert torch.allclose(out_full, out_bypass, atol=1e-5)
|
||||
|
||||
|
||||
class TestTransformerEncoder:
|
||||
@pytest.mark.unit
|
||||
def test_model_creation(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2)
|
||||
total_params = sum(p.numel() for p in model.parameters())
|
||||
assert total_params > 0
|
||||
assert len(model.layers) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_creation_with_qk_norm(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, qk_norm=True)
|
||||
attn = model.layers[0].attn
|
||||
assert hasattr(attn, 'q_norm')
|
||||
assert hasattr(attn, 'k_norm')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_creation_without_qk_norm(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, qk_norm=False)
|
||||
attn = model.layers[0].attn
|
||||
assert not hasattr(attn, 'q_norm')
|
||||
assert not hasattr(attn, 'k_norm')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_attn_mode(self):
|
||||
with pytest.raises(ValueError, match="not yet supported"):
|
||||
TransformerEncoder(feat_in=80, d_model=64, n_heads=4, n_layers=2, attn_mode="sliding_window")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_head_dim_below_16_raises(self):
|
||||
"""head_dim = d_model // n_heads must be >= 16 (PyTorch FlexAttention CUDA requirement).
|
||||
|
||||
The check happens at construction time, so an unsupported (d_model, n_heads) pair raises
|
||||
before any forward pass.
|
||||
"""
|
||||
# d_model=32, n_heads=4 -> head_dim=8 (< 16).
|
||||
with pytest.raises(ValueError, match="per-head embedding dimension >= 16"):
|
||||
TransformerEncoder(feat_in=128, d_model=32, n_heads=4, n_layers=2)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_causal_forward_cpu(self):
|
||||
model = TransformerEncoder(feat_in=80, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, attn_mode="causal")
|
||||
model.eval()
|
||||
|
||||
x = torch.randn(2, 80, 400)
|
||||
lengths = torch.tensor([400, 300])
|
||||
|
||||
with torch.no_grad():
|
||||
out, out_lengths = model(x, lengths)
|
||||
|
||||
assert out.shape == (2, 64, 100)
|
||||
assert out_lengths.tolist() == [100, 75]
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_causal_future_does_not_affect_past(self):
|
||||
"""Output at position t must be invariant to changes at positions > t."""
|
||||
model = TransformerEncoder(feat_in=80, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, attn_mode="causal")
|
||||
model.eval()
|
||||
|
||||
B, C, T = 1, 80, 400
|
||||
x_a = torch.randn(B, C, T)
|
||||
x_b = x_a.clone()
|
||||
# Perturb only the second half of frames.
|
||||
x_b[:, :, T // 2 :] = torch.randn(B, C, T - T // 2)
|
||||
lengths = torch.tensor([T])
|
||||
|
||||
with torch.no_grad():
|
||||
out_a, _ = model(x_a, lengths)
|
||||
out_b, _ = model(x_b, lengths)
|
||||
|
||||
# Output frames covering only past + present should be identical.
|
||||
# First half of *output* frames corresponds to first half of input frames after subsampling.
|
||||
safe_t = (T // 2) // model.pre_encode.subsampling_factor
|
||||
assert torch.allclose(out_a[:, :, :safe_t], out_b[:, :, :safe_t], atol=1e-5)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_freeze_unfreeze_partial_restores_prior_state(self):
|
||||
model = TransformerEncoder(feat_in=80, d_model=64, n_heads=4, n_layers=2)
|
||||
for p in model.final_norm.parameters():
|
||||
p.requires_grad = False
|
||||
prior = {n: p.requires_grad for n, p in model.named_parameters()}
|
||||
|
||||
model.freeze()
|
||||
assert all(not p.requires_grad for p in model.parameters())
|
||||
assert not model.training
|
||||
|
||||
model.unfreeze(partial=True)
|
||||
assert {n: p.requires_grad for n, p in model.named_parameters()} == prior
|
||||
assert model.training
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward_cpu(self):
|
||||
"""Forward pass on CPU uses unfused FlexAttention fallback."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, subsampling_factor=4)
|
||||
model.eval()
|
||||
|
||||
B, C, T = 2, 128, 400
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([400, 300])
|
||||
|
||||
with torch.no_grad():
|
||||
out, out_lengths = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 64, T // 4)
|
||||
assert out_lengths[0].item() == T // 4
|
||||
assert out_lengths[1].item() == 300 // 4
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forward_cpu_with_qk_norm(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, qk_norm=True)
|
||||
model.eval()
|
||||
|
||||
x = torch.randn(1, 128, 200)
|
||||
lengths = torch.tensor([200])
|
||||
|
||||
with torch.no_grad():
|
||||
out, _ = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (1, 64, 50)
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_forward_basic(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, subsampling_factor=4)
|
||||
model = model.cuda().to(torch.bfloat16)
|
||||
|
||||
B, C, T = 2, 128, 400
|
||||
x = torch.randn(B, C, T, device='cuda', dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([400, 300], device='cuda')
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
out, out_lengths = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 64, T // 4)
|
||||
assert out_lengths[0].item() == T // 4
|
||||
assert out_lengths[1].item() == 300 // 4
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_forward_with_qk_norm(self):
|
||||
model = TransformerEncoder(
|
||||
feat_in=128, d_model=128, n_heads=8, n_layers=2, drop_rate=0.0, qk_norm=True, subsampling_factor=8
|
||||
)
|
||||
model = model.cuda().to(torch.bfloat16)
|
||||
|
||||
B, C, T = 2, 128, 800
|
||||
x = torch.randn(B, C, T, device='cuda', dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([800, 640], device='cuda')
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
out, out_lengths = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 128, T // 8)
|
||||
assert out_lengths[1].item() == 640 // 8
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_forward_output_channels_first(self):
|
||||
"""Verify output is (B, D, T) channels-first as expected by downstream decoders."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=1, drop_rate=0.0)
|
||||
model = model.cuda().to(torch.bfloat16)
|
||||
|
||||
x = torch.randn(1, 128, 200, device='cuda', dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([200], device='cuda')
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
out, _ = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape[1] == 64 # D dimension
|
||||
assert out.shape[2] == 200 // 4 # T dimension
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_eval_deterministic(self):
|
||||
"""In eval mode with no dropout, repeated forward passes should produce identical output."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0)
|
||||
model = model.cuda().to(torch.bfloat16).eval()
|
||||
|
||||
x = torch.randn(1, 128, 200, device='cuda', dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([200], device='cuda')
|
||||
|
||||
with torch.no_grad():
|
||||
out1, _ = model(audio_signal=x, length=lengths)
|
||||
out2, _ = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert torch.allclose(out1, out2, atol=1e-6)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_padding_does_not_affect_valid_output(self):
|
||||
"""Padding frames should not change the encoded output at valid positions."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0)
|
||||
model = model.cuda().to(torch.bfloat16).eval()
|
||||
|
||||
T_valid = 200
|
||||
x_short = torch.randn(1, 128, T_valid, device='cuda', dtype=torch.bfloat16)
|
||||
lengths_short = torch.tensor([T_valid], device='cuda')
|
||||
|
||||
T_padded = 400
|
||||
x_long = torch.zeros(1, 128, T_padded, device='cuda', dtype=torch.bfloat16)
|
||||
x_long[:, :, :T_valid] = x_short
|
||||
lengths_long = torch.tensor([T_valid], device='cuda')
|
||||
|
||||
with torch.no_grad():
|
||||
out_short, len_short = model(audio_signal=x_short, length=lengths_short)
|
||||
out_long, len_long = model(audio_signal=x_long, length=lengths_long)
|
||||
|
||||
assert len_short[0].item() == len_long[0].item()
|
||||
valid_t = len_short[0].item()
|
||||
# bf16 + different block mask shapes cause small numerical differences in Triton kernels
|
||||
assert torch.allclose(out_short[:, :, :valid_t], out_long[:, :, :valid_t], atol=5e-2)
|
||||
|
||||
@pytest.mark.run_only_on('GPU')
|
||||
def test_backward_pass(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0)
|
||||
model = model.cuda().to(torch.bfloat16).train()
|
||||
|
||||
x = torch.randn(2, 128, 200, device='cuda', dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([200, 160], device='cuda')
|
||||
|
||||
out, _ = model(audio_signal=x, length=lengths)
|
||||
loss = out.sum()
|
||||
loss.backward()
|
||||
|
||||
for name, param in model.named_parameters():
|
||||
assert param.grad is not None, f"No gradient for {name}"
|
||||
assert not torch.isnan(param.grad).any(), f"NaN gradient for {name}"
|
||||
|
||||
|
||||
class TestSelfAttentionModel:
|
||||
"""Tests for the ``self_attention_model`` positional encoding option."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_is_rel_pos(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2)
|
||||
assert model.self_attention_model == "rel_pos"
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("mode", ["abs_pos", "rel_pos", "no_pos", "rope"])
|
||||
def test_valid_modes_are_accepted(self, mode):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, self_attention_model=mode)
|
||||
assert model.self_attention_model == mode
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_none_aliases_no_pos(self):
|
||||
"""Passing ``self_attention_model=None`` must be equivalent to ``"no_pos"``."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, self_attention_model=None)
|
||||
assert model.self_attention_model == "no_pos"
|
||||
assert model.pos_enc is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
TransformerEncoder(
|
||||
feat_in=128, d_model=64, n_heads=4, n_layers=2, self_attention_model="rel_pos_local_attn"
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rel_pos_attention_params_allocated(self):
|
||||
"""rel_pos mode allocates the Transformer-XL bias parameters per attention layer."""
|
||||
d_model, n_heads, n_layers = 64, 4, 2
|
||||
model = TransformerEncoder(
|
||||
feat_in=128, d_model=d_model, n_heads=n_heads, n_layers=n_layers, self_attention_model="rel_pos"
|
||||
)
|
||||
head_dim = d_model // n_heads
|
||||
assert model.pos_enc is not None
|
||||
for layer in model.layers:
|
||||
attn = layer.attn
|
||||
assert attn.linear_pos is not None
|
||||
assert attn.pos_bias_u is not None
|
||||
assert attn.pos_bias_v is not None
|
||||
assert attn.pos_bias_u.shape == (n_heads, head_dim)
|
||||
assert attn.pos_bias_v.shape == (n_heads, head_dim)
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("mode", ["abs_pos", "no_pos", "rope"])
|
||||
def test_non_rel_pos_modes_have_no_rel_params(self, mode):
|
||||
"""abs_pos, no_pos and rope modes must not allocate the rel-pos parameters."""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, self_attention_model=mode)
|
||||
for layer in model.layers:
|
||||
attn = layer.attn
|
||||
assert attn.linear_pos is None
|
||||
assert attn.pos_bias_u is None
|
||||
assert attn.pos_bias_v is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_pos_has_no_positional_encoding_module(self):
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=2, self_attention_model="no_pos")
|
||||
assert model.pos_enc is None
|
||||
# set_max_audio_length is invoked in __init__; it must not crash for no_pos and must
|
||||
# still record the requested max length so update_max_seq_length works normally.
|
||||
assert model.max_audio_length == model.pos_emb_max_len
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("mode", ["abs_pos", "rel_pos", "no_pos", "rope", None])
|
||||
def test_forward_each_mode_cpu(self, mode):
|
||||
"""Each ``self_attention_model`` choice (including ``None``) must produce a valid forward."""
|
||||
model = TransformerEncoder(
|
||||
feat_in=128,
|
||||
d_model=64,
|
||||
n_heads=4,
|
||||
n_layers=2,
|
||||
drop_rate=0.0,
|
||||
subsampling_factor=4,
|
||||
self_attention_model=mode,
|
||||
)
|
||||
model.eval()
|
||||
|
||||
B, C, T = 2, 128, 200
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([T, 160])
|
||||
|
||||
with torch.no_grad():
|
||||
out, out_lengths = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 64, T // 4)
|
||||
assert out_lengths[0].item() == T // 4
|
||||
assert out_lengths[1].item() == 160 // 4
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rel_pos_broadcasts_when_T_differs_from_n_heads(self):
|
||||
"""Regression test for the Transformer-XL bias broadcasting.
|
||||
|
||||
``pos_bias_{u,v}`` has shape ``(H, D)`` and must broadcast against the head axis of
|
||||
``q`` which has shape ``(B, H, T, D)``. A naive add would right-align ``H`` against
|
||||
``T`` and either crash (``T != H``) or silently apply the bias on the wrong axis
|
||||
(``T == H``). This test exercises a configuration where ``T_attn != n_heads`` so the
|
||||
broken broadcast would surface as an error.
|
||||
"""
|
||||
# 200 input frames / subsampling_factor=4 -> 50 attention frames; n_heads=4 -> T != H.
|
||||
model = TransformerEncoder(
|
||||
feat_in=128, d_model=64, n_heads=4, n_layers=2, drop_rate=0.0, self_attention_model="rel_pos"
|
||||
)
|
||||
model.eval()
|
||||
|
||||
B, C, T = 2, 128, 200
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([T, 160])
|
||||
|
||||
with torch.no_grad():
|
||||
out, _ = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 64, T // 4)
|
||||
assert not torch.isnan(out).any()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rope_uses_shared_rotary_pos_enc(self):
|
||||
"""rope mode builds a single ``RotaryPositionalEncoding`` reused by every attention layer.
|
||||
|
||||
The cos/sin buffers are computed once on the shared module (see ``TransformerEncoder``),
|
||||
so each layer's ``attn.rope`` must be the *same* object as ``model.pos_enc``.
|
||||
"""
|
||||
model = TransformerEncoder(feat_in=128, d_model=64, n_heads=4, n_layers=3, self_attention_model="rope")
|
||||
assert isinstance(model.pos_enc, RotaryPositionalEncoding)
|
||||
for layer in model.layers:
|
||||
attn = layer.attn
|
||||
assert attn._uses_rope is True
|
||||
assert attn.rope is model.pos_enc
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rope_partial_rotation_forward_cpu(self):
|
||||
"""``rotary_fraction`` < 1.0 rotates only part of each head dim (exercises the pass-through split)."""
|
||||
model = TransformerEncoder(
|
||||
feat_in=128,
|
||||
d_model=64,
|
||||
n_heads=4,
|
||||
n_layers=2,
|
||||
drop_rate=0.0,
|
||||
subsampling_factor=4,
|
||||
self_attention_model="rope",
|
||||
rotary_fraction=0.5,
|
||||
)
|
||||
model.eval()
|
||||
|
||||
B, C, T = 2, 128, 200
|
||||
x = torch.randn(B, C, T)
|
||||
lengths = torch.tensor([T, 160])
|
||||
|
||||
with torch.no_grad():
|
||||
out, _ = model(audio_signal=x, length=lengths)
|
||||
|
||||
assert out.shape == (B, 64, T // 4)
|
||||
assert not torch.isnan(out).any()
|
||||
@@ -0,0 +1,129 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.utils.asr_multispeaker_utils import (
|
||||
find_first_nonzero,
|
||||
get_hidden_length_from_sample_length,
|
||||
read_rttm_supervisions_lenient,
|
||||
)
|
||||
|
||||
|
||||
def _write_rttm(path, lines):
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"SPEAKER rec1 1 1.25 2.50 <NA> <NA> speaker_A <NA>",
|
||||
"SPEAKER rec1 1 1.25 2.50 <NA> <NA> speaker_A <NA> <NA>",
|
||||
],
|
||||
)
|
||||
def test_read_rttm_supervisions_lenient_accepts_9_and_10_column_lines(tmp_path, line):
|
||||
rttm_path = _write_rttm(tmp_path / "valid.rttm", [line])
|
||||
|
||||
supervisions = read_rttm_supervisions_lenient(rttm_path)
|
||||
|
||||
assert len(supervisions) == 1
|
||||
segment = supervisions[0]
|
||||
assert segment.id == "rec1-000000"
|
||||
assert segment.recording_id == "rec1"
|
||||
assert segment.channel == 1
|
||||
assert segment.start == pytest.approx(1.25)
|
||||
assert segment.duration == pytest.approx(2.50)
|
||||
assert segment.speaker == "speaker_A"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"SPEAKER rec1 1 0.0 1.0 <NA> <NA>",
|
||||
"SPEAKER rec1 1 0.0 1.0",
|
||||
"too short",
|
||||
],
|
||||
)
|
||||
def test_read_rttm_supervisions_lenient_rejects_short_lines(tmp_path, line):
|
||||
rttm_path = _write_rttm(tmp_path / "invalid.rttm", [line])
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid RTTM line"):
|
||||
read_rttm_supervisions_lenient(rttm_path)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_read_rttm_supervisions_lenient_skips_blank_and_zero_duration_lines(tmp_path):
|
||||
rttm_path = _write_rttm(
|
||||
tmp_path / "skip.rttm",
|
||||
[
|
||||
"",
|
||||
"SPEAKER rec1 1 0.00 0.00 <NA> <NA> speaker_A <NA>",
|
||||
"SPEAKER rec1 1 3.00 1.25 <NA> <NA> speaker_B <NA>",
|
||||
],
|
||||
)
|
||||
|
||||
supervisions = read_rttm_supervisions_lenient(rttm_path)
|
||||
|
||||
assert len(supervisions) == 1
|
||||
segment = supervisions[0]
|
||||
assert segment.id == "rec1-000002"
|
||||
assert segment.start == pytest.approx(3.00)
|
||||
assert segment.duration == pytest.approx(1.25)
|
||||
assert segment.speaker == "speaker_B"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_read_rttm_supervisions_lenient_accepts_multiple_files(tmp_path):
|
||||
rttm_path_a = _write_rttm(tmp_path / "a.rttm", ["SPEAKER rec_a 1 0.00 1.00 <NA> <NA> speaker_A <NA>"])
|
||||
rttm_path_b = _write_rttm(tmp_path / "b.rttm", ["SPEAKER rec_b 2 1.00 2.00 <NA> <NA> speaker_B <NA>"])
|
||||
|
||||
supervisions = read_rttm_supervisions_lenient([rttm_path_a, rttm_path_b])
|
||||
|
||||
assert len(supervisions) == 2
|
||||
assert [segment.recording_id for segment in supervisions] == ["rec_a", "rec_b"]
|
||||
assert [segment.channel for segment in supervisions] == [1, 2]
|
||||
assert [segment.speaker for segment in supervisions] == ["speaker_A", "speaker_B"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
("num_samples", "expected_hidden_length"),
|
||||
[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(1280, 1),
|
||||
(1281, 2),
|
||||
],
|
||||
)
|
||||
def test_get_hidden_length_rounds_up_to_encoder_frames(num_samples, expected_hidden_length):
|
||||
assert get_hidden_length_from_sample_length(num_samples) == expected_hidden_length
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_find_first_nonzero_returns_first_threshold_crossing_or_cap():
|
||||
mat = torch.tensor(
|
||||
[
|
||||
[0.0, 0.2, 0.6],
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.7, 0.8, 0.0],
|
||||
]
|
||||
)
|
||||
|
||||
result = find_first_nonzero(mat, max_cap_val=99, thres=0.5)
|
||||
|
||||
assert torch.equal(result, torch.tensor([2, 99, 0]))
|
||||
@@ -0,0 +1,248 @@
|
||||
# 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.utils.chunking_utils import (
|
||||
join_char_level_timestamps,
|
||||
merge_all_hypotheses,
|
||||
merge_hypotheses_of_same_audio,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
|
||||
|
||||
def _make_char(char, token_id, start_off, end_off, token=None):
|
||||
return {
|
||||
"char": char,
|
||||
"token": token if token is not None else char,
|
||||
"token_id": token_id,
|
||||
"start_offset": start_off,
|
||||
"end_offset": end_off,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_join_char_level_timestamps_without_filter():
|
||||
# Merging char level timestamps within same audio segment.
|
||||
subsampling_factor = 8
|
||||
window_stride = 0.01
|
||||
chunk_offsets = [0, 32]
|
||||
|
||||
h0 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([]),
|
||||
timestamp={
|
||||
"char": [
|
||||
_make_char("a", 10, 0, 1),
|
||||
_make_char("b", 11, 2, 3),
|
||||
]
|
||||
},
|
||||
)
|
||||
h1 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([]),
|
||||
timestamp={
|
||||
"char": [
|
||||
_make_char("b", 12, 0, 1),
|
||||
_make_char("c", 13, 2, 3),
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
out = join_char_level_timestamps(
|
||||
hypotheses=[h0, h1],
|
||||
chunk_offsets=chunk_offsets,
|
||||
subsampling_factor=subsampling_factor,
|
||||
window_stride=window_stride,
|
||||
merged_tokens=None,
|
||||
)
|
||||
|
||||
assert len(out) == 4
|
||||
shift = chunk_offsets[1] // subsampling_factor
|
||||
|
||||
assert out[0]["start_offset"] == 0 and out[0]["end_offset"] == 1
|
||||
assert out[1]["start_offset"] == 2 and out[1]["end_offset"] == 3
|
||||
|
||||
assert out[2]["start_offset"] == 0 + shift and out[2]["end_offset"] == 1 + shift
|
||||
assert out[3]["start_offset"] == 2 + shift and out[3]["end_offset"] == 3 + shift
|
||||
|
||||
sec_per_subsample = window_stride * subsampling_factor
|
||||
assert out[0]["start"] == pytest.approx(out[0]["start_offset"] * sec_per_subsample)
|
||||
assert out[3]["end"] == pytest.approx(out[3]["end_offset"] * sec_per_subsample)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_join_char_level_timestamps_with_filter():
|
||||
# Merging char level timestamps within same audio segment.
|
||||
subsampling_factor = 8
|
||||
window_stride = 0.01
|
||||
chunk_offsets = [0, 200]
|
||||
|
||||
# Chunk0: tokens 1..4
|
||||
h0 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([]),
|
||||
timestamp={
|
||||
"char": [
|
||||
_make_char("a", 1, 0, 0),
|
||||
_make_char("b", 2, 1, 1),
|
||||
_make_char("c", 3, 2, 2),
|
||||
_make_char("d", 4, 3, 3),
|
||||
]
|
||||
},
|
||||
)
|
||||
# Chunk1: overlaps and -1 offsets as provided
|
||||
h1 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([]),
|
||||
timestamp={
|
||||
"char": [
|
||||
_make_char("a", 1, 0, 0),
|
||||
_make_char("c", 3, 1, 1),
|
||||
_make_char("d", 4, 2, 2),
|
||||
_make_char("e", 5, -1, 3),
|
||||
_make_char("f", 6, 4, 4),
|
||||
_make_char("g", 7, -1, -1),
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
merged_tokens = [1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
out = join_char_level_timestamps(
|
||||
hypotheses=[h0, h1],
|
||||
chunk_offsets=chunk_offsets,
|
||||
subsampling_factor=subsampling_factor,
|
||||
window_stride=window_stride,
|
||||
merged_tokens=merged_tokens,
|
||||
)
|
||||
|
||||
# Token IDs in order
|
||||
assert [d["token_id"] for d in out] == merged_tokens
|
||||
# Expected global offsets (from your provided output)
|
||||
expected_start_offsets = [0, 1, 2, 3, -1, 29, -1]
|
||||
expected_end_offsets = [0, 1, 2, 3, 28, 29, -1]
|
||||
assert [d["start_offset"] for d in out] == expected_start_offsets
|
||||
assert [d["end_offset"] for d in out] == expected_end_offsets
|
||||
|
||||
# Expected times
|
||||
expected_starts = [0.0, 0.08, 0.16, 0.24, -1, 2.32, -1]
|
||||
expected_ends = [0.0, 0.08, 0.16, 0.24, 2.24, 2.32, -1]
|
||||
|
||||
assert [d["start"] for d in out] == pytest.approx(expected_starts)
|
||||
assert [d["end"] for d in out] == pytest.approx(expected_ends)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_hypotheses_of_same_audio():
|
||||
# Different segments of the same audio file are correctly combined
|
||||
subsampling_factor = 8
|
||||
chunk_duration_seconds = 10
|
||||
frame_offset = int(chunk_duration_seconds * 1000 / subsampling_factor)
|
||||
|
||||
h0 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([1]),
|
||||
timestamp={
|
||||
"word": [{"word": "a", "start": 0.0, "end": 0.1, "start_offset": 0, "end_offset": 2}],
|
||||
"segment": [{"segment": "a", "start": 0.0, "end": 0.1, "start_offset": 0, "end_offset": 2}],
|
||||
},
|
||||
)
|
||||
h1 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([2]),
|
||||
timestamp={
|
||||
"word": [{"word": "b", "start": 0.2, "end": 0.3, "start_offset": 0, "end_offset": 3}],
|
||||
"segment": [{"segment": "b", "start": 0.2, "end": 0.3, "start_offset": 0, "end_offset": 3}],
|
||||
},
|
||||
)
|
||||
h2 = Hypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.tensor([3]),
|
||||
timestamp={
|
||||
"word": [],
|
||||
"segment": [],
|
||||
},
|
||||
)
|
||||
|
||||
merged = merge_hypotheses_of_same_audio(
|
||||
hypotheses_list=[h0, h1, h2],
|
||||
timestamps=True,
|
||||
subsampling_factor=subsampling_factor,
|
||||
chunk_duration_seconds=chunk_duration_seconds,
|
||||
)
|
||||
|
||||
words = merged.timestamp["word"]
|
||||
segs = merged.timestamp["segment"]
|
||||
|
||||
assert [w["word"] for w in words] == ["a", "b"]
|
||||
assert words[0]["start"] == pytest.approx(0.0)
|
||||
assert words[0]["start_offset"] == 0
|
||||
assert words[1]["start"] == pytest.approx(0.2 + chunk_duration_seconds)
|
||||
assert words[1]["start_offset"] == frame_offset
|
||||
|
||||
assert [s["segment"] for s in segs] == ["a", "b"]
|
||||
assert segs[1]["end"] == pytest.approx(0.3 + chunk_duration_seconds)
|
||||
assert segs[1]["end_offset"] == 3 + frame_offset
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_all_hypotheses():
|
||||
# Testing if merging by id works
|
||||
def H(text, id_):
|
||||
h = Hypothesis(score=0.0, y_sequence=torch.tensor([1]), timestamp={"word": [], "segment": []})
|
||||
h.text = text
|
||||
h.id = id_
|
||||
return h
|
||||
|
||||
hyps = [H("a", 1), H("b", 1), H("c", 2), H("d", 2)]
|
||||
|
||||
merged_list = merge_all_hypotheses(
|
||||
hypotheses_list=hyps,
|
||||
timestamps=False,
|
||||
subsampling_factor=2,
|
||||
chunk_duration_seconds=3600,
|
||||
)
|
||||
|
||||
assert len(merged_list) == 2
|
||||
texts = {m.text for m in merged_list}
|
||||
assert texts == {"a b", "c d"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_merge_all_hypotheses_with_cut_segmented_suffix():
|
||||
def H(text, id_):
|
||||
h = Hypothesis(score=0.0, y_sequence=torch.tensor([1]), timestamp={"word": [], "segment": []})
|
||||
h.text = text
|
||||
h.id = id_
|
||||
return h
|
||||
|
||||
hyps = [
|
||||
H("root", "11-0"),
|
||||
H("cont1", "11-1_cut_segmented"),
|
||||
H("cont2", "11-2_cut_segmented"),
|
||||
H("other", "12-0"),
|
||||
]
|
||||
|
||||
merged_list = merge_all_hypotheses(
|
||||
hypotheses_list=hyps,
|
||||
timestamps=False,
|
||||
subsampling_factor=8,
|
||||
chunk_duration_seconds=3600,
|
||||
)
|
||||
|
||||
assert len(merged_list) == 2
|
||||
texts = sorted(m.text for m in merged_list)
|
||||
assert texts == ["other", "root cont1 cont2"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user