Source code for nemo.collections.asr.parts.submodules.tdt_beam_decoding

# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES.  All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.

# Copyright 2017 Johns Hopkins University (Shinji Watanabe)
#
# 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, Optional, Tuple

import numpy as np
import torch
from omegaconf import DictConfig
from tqdm import tqdm

from nemo.collections.asr.modules import rnnt_abstract
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel
from nemo.collections.asr.parts.submodules.rnnt_beam_decoding import pack_hypotheses
from nemo.collections.asr.parts.submodules.tdt_malsd_batched_computer import ModifiedALSDBatchedTDTComputer
from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceMethodMixin
from nemo.collections.asr.parts.utils.batched_beam_decoding_utils import BlankLMScoreMode, PruningMode
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis, NBestHypotheses, is_prefix
from nemo.collections.common.parts.optional_cuda_graphs import WithOptionalCudaGraphs
from nemo.core.classes import Typing, typecheck
from nemo.core.neural_types import AcousticEncodedRepresentation, HypothesisType, LengthsType, NeuralType
from nemo.utils import logging

try:
    import kenlm

    KENLM_AVAILABLE = True
except (ImportError, ModuleNotFoundError):
    KENLM_AVAILABLE = False


[docs] class BeamTDTInfer(Typing): """ Beam search implementation for Token-andDuration Transducer (TDT) models. Sequence level beam decoding or batched-beam decoding, performed auto-repressively depending on the search type chosen. Args: decoder_model: rnnt_utils.AbstractRNNTDecoder implementation. joint_model: rnnt_utils.AbstractRNNTJoint implementation. durations: list of duration values from TDT model. beam_size: number of beams for beam search. Must be a positive integer >= 1. If beam size is 1, defaults to stateful greedy search. For accurate greedy results, please use GreedyRNNTInfer or GreedyBatchedRNNTInfer. search_type: str representing the type of beam search to perform. Must be one of ['beam', 'maes']. Algorithm used: `default` - basic beam search strategy. Larger beams generally result in better decoding, however the time required for the search also grows steadily. `maes` = modified adaptive expansion search. Please refer to the paper: [Accelerating RNN Transducer Inference via Adaptive Expansion Search] (https://ieeexplore.ieee.org/document/9250505) Modified Adaptive Synchronous Decoding (mAES) execution time is adaptive w.r.t the number of expansions (for tokens) required per timestep. The number of expansions can usually be constrained to 1 or 2, and in most cases 2 is sufficient. This beam search technique can possibly obtain superior WER while sacrificing some evaluation time. score_norm: bool, whether to normalize the scores of the log probabilities. return_best_hypothesis: bool, decides whether to return a single hypothesis (the best out of N), or return all N hypothesis (sorted with best score first). The container class changes based this flag - When set to True (default), returns a single Hypothesis. When set to False, returns a NBestHypotheses container, which contains a list of Hypothesis. # The following arguments are specific to the chosen `search_type` # mAES flags maes_num_steps: Number of adaptive steps to take. From the paper, 2 steps is generally sufficient. int > 1. maes_prefix_alpha: Maximum prefix length in prefix search. Must be an integer, and is advised to keep this as 1 in order to reduce expensive beam search cost later. int >= 0. maes_expansion_beta: Maximum number of prefix expansions allowed, in addition to the beam size. Effectively, the number of hypothesis = beam_size + maes_expansion_beta. Must be an int >= 0, and affects the speed of inference since large values will perform large beam search in the next step. maes_expansion_gamma: Float pruning threshold used in the prune-by-value step when computing the expansions. The default (2.3) is selected from the paper. It performs a comparison (max_log_prob - gamma <= log_prob[v]) where v is all vocabulary indices in the Vocab set and max_log_prob is the "most" likely token to be predicted. Gamma therefore provides a margin of additional tokens which can be potential candidates for expansion apart from the "most likely" candidate. Lower values will reduce the number of expansions (by increasing pruning-by-value, thereby improving speed but hurting accuracy). Higher values will increase the number of expansions (by reducing pruning-by-value, thereby reducing speed but potentially improving accuracy). This is a hyper parameter to be experimentally tuned on a validation set. softmax_temperature: Scales the logits of the joint prior to computing log_softmax. preserve_alignments: Bool flag which preserves the history of alignments generated during beam decoding (sample). When set to true, the Hypothesis will contain the non-null value for `alignments` in it. Here, `alignments` is a List of List of Tensor (of length V + 1) The length of the list corresponds to the Acoustic Length (T). Each value in the list (Ti) is a torch.Tensor (U), representing 1 or more targets from a vocabulary. U is the number of target tokens for the current timestep Ti. NOTE: `preserve_alignments` is an invalid argument for any `search_type` other than basic beam search. ngram_lm_model: str The path to the N-gram LM. ngram_lm_alpha: float Alpha weight of N-gram LM. """ @property def input_types(self): """Returns definitions of module input ports.""" return { "encoder_output": NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()), "encoded_lengths": NeuralType(tuple('B'), LengthsType()), "partial_hypotheses": [NeuralType(elements_type=HypothesisType(), optional=True)], # must always be last } @property def output_types(self): """Returns definitions of module output ports.""" return {"predictions": [NeuralType(elements_type=HypothesisType())]} def __init__( self, decoder_model: rnnt_abstract.AbstractRNNTDecoder, joint_model: rnnt_abstract.AbstractRNNTJoint, durations: list, beam_size: int, search_type: str = 'default', score_norm: bool = True, return_best_hypothesis: bool = True, maes_num_steps: int = 2, maes_prefix_alpha: int = 1, maes_expansion_gamma: float = 2.3, maes_expansion_beta: int = 2, softmax_temperature: float = 1.0, preserve_alignments: bool = False, ngram_lm_model: Optional[str] = None, ngram_lm_alpha: float = 0.3, max_symbols_per_step: Optional[int] = None, blank_lm_score_mode: Optional[str] = "no_score", pruning_mode: Optional[str] = "early", allow_cuda_graphs: bool = False, ): self.joint = joint_model self.decoder = decoder_model self.durations = durations self.token_offset = 0 self.search_type = search_type self.blank = decoder_model.blank_idx self.vocab_size = decoder_model.vocab_size self.return_best_hypothesis = return_best_hypothesis self.beam_size = beam_size self.score_norm = score_norm self.max_candidates = beam_size self.softmax_temperature = softmax_temperature self.preserve_alignments = preserve_alignments if preserve_alignments: raise ValueError("Alignment preservation has not been implemented.") if beam_size < 1: raise ValueError("Beam search size cannot be less than 1!") if self.preserve_alignments: raise NotImplementedError("Preserving alignments is not implemented.") if search_type == "default": if self.beam_size == 1: logging.info( """If beam size is 1, defaults to stateful greedy search. For accurate greedy results, please use GreedyTDTInfer or GreedyBatchedTDTInfer.""" ) self.search_algorithm = self.default_beam_search elif search_type == "tsd": raise NotImplementedError("`tsd` (Time Synchronous Decoding) has not been implemented.") elif search_type == "alsd": raise NotImplementedError("`alsd` (Alignment Length Synchronous Decoding) has not been implemented.") elif search_type == "nsc": raise NotImplementedError("`nsc` (Constrained Beam Search) has not been implemented.") elif search_type == "maes": self.search_algorithm = self.modified_adaptive_expansion_search else: raise NotImplementedError( f"The search type ({search_type}) supplied is not supported!\n" f"Please use one of : (default, maes)" ) if max_symbols_per_step is not None: logging.warning( f"Not supported parameter `max_symbols_per_step` for decoding strategy {self.search_algorithm }" ) if allow_cuda_graphs: logging.warning( f"""Cuda Graphs are not supported for the decoding strategy {self.search_algorithm}. Decoding will proceed without Cuda Graphs.""" ) strategies = ["default", "maes"] strategies_batch = ["malsd_batch"] if (pruning_mode, blank_lm_score_mode) != ("early", "no_score"): logging.warning( f"""Decoding strategies {strategies} support early pruning and the 'no_score' blank scoring mode. Please choose a strategy from {strategies_batch} for {pruning_mode} pruning and {blank_lm_score_mode} blank scoring mode." """ ) if self.search_type == 'maes': self.maes_num_steps = int(maes_num_steps) self.maes_prefix_alpha = int(maes_prefix_alpha) self.maes_expansion_beta = int(maes_expansion_beta) self.maes_expansion_gamma = float(maes_expansion_gamma) self.max_candidates += maes_expansion_beta if self.maes_prefix_alpha < 0: raise ValueError("`maes_prefix_alpha` must be a positive integer.") if self.vocab_size < beam_size + maes_expansion_beta: raise ValueError( f"beam_size ({beam_size}) + expansion_beta ({maes_expansion_beta}) " f"should be smaller or equal to vocabulary size ({self.vocab_size})." ) if self.maes_num_steps < 1: raise ValueError("`maes_num_steps` must be greater than 0.") try: self.zero_duration_idx = self.durations.index(0) except ValueError: self.zero_duration_idx = None self.min_non_zero_duration_idx = int( np.argmin(np.ma.masked_where(np.array(self.durations) == 0, self.durations)) ) if ngram_lm_model: if search_type != "maes": raise ValueError("For decoding with language model `maes` decoding strategy must be chosen.") self.ngram_lm = ngram_lm_model self.ngram_lm_alpha = ngram_lm_alpha else: self.ngram_lm = None @typecheck() def __call__( self, encoder_output: torch.Tensor, encoded_lengths: torch.Tensor, partial_hypotheses: tuple[list[Hypothesis | NBestHypotheses],] = None, ) -> tuple[list[Hypothesis | NBestHypotheses],]: """Perform general beam search. Args: encoder_output: encoder outputs (batch, features, timesteps). encoded_lengths: lengths of the encoder outputs. Returns: Either a list containing a single Hypothesis (when `return_best_hypothesis=True`, otherwise a list containing a single NBestHypotheses, which itself contains a list of Hypothesis. This list is sorted such that the best hypothesis is the first element. """ # Preserve decoder and joint training state decoder_training_state = self.decoder.training joint_training_state = self.joint.training with torch.inference_mode(): # Apply optional preprocessing encoder_output = encoder_output.transpose(1, 2) # (B, T, D) self.decoder.eval() self.joint.eval() hypotheses = [] with tqdm( range(encoder_output.size(0)), desc='Beam search progress:', total=encoder_output.size(0), unit='sample', ) as idx_gen: _p = next(self.joint.parameters()) dtype = _p.dtype # Decode every sample in the batch independently. for batch_idx in idx_gen: inseq = encoder_output[batch_idx : batch_idx + 1, : encoded_lengths[batch_idx], :] # [1, T, D] logitlen = encoded_lengths[batch_idx] if inseq.dtype != dtype: inseq = inseq.to(dtype=dtype) # Extract partial hypothesis if exists partial_hypothesis = partial_hypotheses[batch_idx] if partial_hypotheses is not None else None # Execute the specific search strategy nbest_hyps = self.search_algorithm( inseq, logitlen, partial_hypotheses=partial_hypothesis ) # sorted list of hypothesis # Prepare the list of hypotheses nbest_hyps = pack_hypotheses(nbest_hyps) # Pack the result if self.return_best_hypothesis: best_hypothesis: Hypothesis = nbest_hyps[0] else: best_hypothesis: NBestHypotheses = NBestHypotheses(nbest_hyps) hypotheses.append(best_hypothesis) self.decoder.train(decoder_training_state) self.joint.train(joint_training_state) return (hypotheses,)
[docs] def merge_duplicate_hypotheses(self, hypotheses): """ Merges hypotheses with identical token sequences and lengths. The combined hypothesis's probability is the sum of the probabilities of all duplicates. Duplicate hypotheses occur when two consecutive blank tokens are predicted and their duration values sum up to the same number. Args: hypotheses: list of hypotheses. Returns: hypotheses: list if hypotheses without duplicates. """ sorted_hyps = sorted(hypotheses, key=lambda x: x.score, reverse=True) kept_hyps = {} for hyp in sorted_hyps: hyp_key = (tuple(hyp.y_sequence), int(hyp.last_frame)) if hyp_key in kept_hyps: kept_hyp = kept_hyps[hyp_key] kept_hyp.score = float(torch.logaddexp(torch.tensor(kept_hyp.score), torch.tensor(hyp.score))) else: kept_hyps[hyp_key] = hyp return list(kept_hyps.values())
[docs] def set_decoding_type(self, decoding_type: str): """ Sets decoding type. Please check train_kenlm.py in scripts/asr_language_modeling/ to find out why we need Args: decoding_type: decoding type """ # TOKEN_OFFSET for BPE-based models if decoding_type == 'subword': from nemo.collections.asr.parts.submodules.ctc_beam_decoding import DEFAULT_TOKEN_OFFSET self.token_offset = DEFAULT_TOKEN_OFFSET
[docs] def compute_ngram_score(self, current_lm_state: "kenlm.State", label: int) -> Tuple[float, "kenlm.State"]: """ Computes the score for KenLM Ngram language model. Args: current_lm_state: current state of the KenLM language model. label: next label. Returns: lm_score: score for `label`. """ if self.token_offset: label = chr(label + self.token_offset) else: label = str(label) next_state = kenlm.State() lm_score = self.ngram_lm.BaseScore(current_lm_state, label, next_state) lm_score *= 1.0 / np.log10(np.e) return lm_score, next_state
[docs] def sort_nbest(self, hyps: List[Hypothesis]) -> List[Hypothesis]: """Sort hypotheses by score or score given sequence length. Args: hyps: list of hypotheses Return: hyps: sorted list of hypotheses """ if self.score_norm: return sorted(hyps, key=lambda x: x.score / len(x.y_sequence), reverse=True) else: return sorted(hyps, key=lambda x: x.score, reverse=True)
[docs] class BeamBatchedTDTInfer(Typing, ConfidenceMethodMixin, WithOptionalCudaGraphs): @property def input_types(self): """Returns definitions of module input ports.""" return { "encoder_output": NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()), "encoded_lengths": NeuralType(tuple('B'), LengthsType()), "multi_biasing_ids": NeuralType(tuple('B'), optional=True), "partial_hypotheses": [NeuralType(elements_type=HypothesisType(), optional=True)], # must always be last } def __init__( self, decoder_model: rnnt_abstract.AbstractRNNTDecoder, joint_model: rnnt_abstract.AbstractRNNTJoint, durations: list, blank_index: int, beam_size: int, search_type: str = 'malsd_batch', score_norm: bool = True, max_symbols_per_step: Optional[int] = None, preserve_alignments: bool = False, fusion_models: Optional[List[NGramGPULanguageModel]] = None, fusion_models_alpha: Optional[List[float]] = None, blank_lm_score_mode: Optional[str | BlankLMScoreMode] = BlankLMScoreMode.NO_SCORE, pruning_mode: Optional[str | PruningMode] = PruningMode.EARLY, allow_cuda_graphs: Optional[bool] = True, return_best_hypothesis: Optional[str] = True, enable_per_stream_biasing: bool = False, preserve_step_confidence: bool = False, include_duration_confidence: bool = False, confidence_method_cfg: Optional[DictConfig] = None, ): """ Init method. Args: decoder_model: Prediction network from RNN-T joint_model: Joint module from RNN-T durations: Token durations tensor for TDT blank_index: index of blank symbol beam_size: beam size max_symbols_per_step: max symbols to emit on each step (to avoid infinite looping) preserve_alignments: if alignments are needed fusion_models: list of fusion models to use for decoding fusion_models_alpha: list of alpha values for fusion models blank_lm_score_mode: mode for scoring blank symbol with LM pruning_mode: mode for pruning hypotheses with LM allow_cuda_graphs: whether to allow CUDA graphs score_norm: whether to normalize scores before best hypothesis extraction enable_per_stream_biasing: whether to enable per-stream biasing via multi-boosting tree preserve_step_confidence: if step confidence should be preserved in beam hypotheses include_duration_confidence: if duration confidence is requested (not supported; logged and skipped) confidence_method_cfg: config for the confidence estimation method """ super().__init__() self.decoder = decoder_model self.joint = joint_model self.durations = durations self._blank_index = blank_index self._SOS = blank_index # Start of single index self.beam_size = beam_size self.return_best_hypothesis = return_best_hypothesis self.score_norm = score_norm if max_symbols_per_step is not None and max_symbols_per_step <= 0: raise ValueError(f"Expected max_symbols_per_step > 0 (or None), got {max_symbols_per_step}") self.max_symbols = max_symbols_per_step self.preserve_alignments = preserve_alignments if search_type == "malsd_batch": # Depending on availability of `blank_as_pad` support # switch between more efficient batch decoding technique self.decoding_computer = ModifiedALSDBatchedTDTComputer( decoder=self.decoder, joint=self.joint, durations=durations, beam_size=self.beam_size, blank_index=self._blank_index, max_symbols_per_step=self.max_symbols, preserve_alignments=preserve_alignments, fusion_models=fusion_models, fusion_models_alpha=fusion_models_alpha, blank_lm_score_mode=blank_lm_score_mode, pruning_mode=pruning_mode, allow_cuda_graphs=allow_cuda_graphs, enable_per_stream_biasing=enable_per_stream_biasing, preserve_step_confidence=preserve_step_confidence, include_duration_confidence=include_duration_confidence, confidence_method_cfg=confidence_method_cfg, ) else: raise Exception(f"Decoding strategy {search_type} nor implemented.")
[docs] def disable_cuda_graphs(self) -> bool: """Disable CUDA graphs (e.g., for decoding in training)""" if isinstance(self.decoding_computer, WithOptionalCudaGraphs): return self.decoding_computer.disable_cuda_graphs() return False
[docs] def maybe_enable_cuda_graphs(self) -> bool: """Enable CUDA graphs (if allowed)""" if isinstance(self.decoding_computer, WithOptionalCudaGraphs): return self.decoding_computer.maybe_enable_cuda_graphs() return False
@property def output_types(self): """Returns definitions of module output ports.""" return {"predictions": [NeuralType(elements_type=HypothesisType())]} def __call__(self, *args, **kwargs): return self.forward(*args, **kwargs)
[docs] @typecheck() def forward( self, encoder_output: torch.Tensor, encoded_lengths: torch.Tensor, partial_hypotheses: Optional[list[Hypothesis]] = None, multi_biasing_ids: Optional[torch.Tensor] = None, ) -> Tuple[list[Hypothesis] | List[NBestHypotheses]]: """Returns a list of hypotheses given an input batch of the encoder hidden embedding. Output token is generated auto-regressively. Args: encoder_output: A tensor of size (batch, features, timesteps). encoded_lengths: list of int representing the length of each sequence output sequence. Returns: Tuple of a list of hypotheses for each batch. Each hypothesis contains the decoded sequence, timestamps and associated scores. If ``return_best_hypothesis`` is True, returns the best hypothesis for each batch; otherwise, returns the N-best hypotheses for each batch. """ if partial_hypotheses is not None: raise NotImplementedError("Partial hypotheses feature is not yet supported in batched beam search.") # Preserve decoder and joint training state decoder_training_state = self.decoder.training joint_training_state = self.joint.training with torch.inference_mode(): # Apply optional preprocessing encoder_output = encoder_output.transpose(1, 2) # (B, T, D) logitlen = encoded_lengths self.decoder.eval() self.joint.eval() inseq = encoder_output # [B, T, D] batched_beam_hyps, _ = self.decoding_computer( x=inseq, out_len=logitlen, multi_biasing_ids=multi_biasing_ids ) # Ensures the correct number of hypotheses (batch_size) for CUDA Graphs compatibility batch_size = encoder_output.shape[0] if self.return_best_hypothesis: hyps = batched_beam_hyps.to_hyps_list(score_norm=self.score_norm)[:batch_size] else: hyps = batched_beam_hyps.to_nbest_hyps_list(score_norm=self.score_norm)[:batch_size] self.decoder.train(decoder_training_state) self.joint.train(joint_training_state) return (hyps,)