Skip to content

Commit 5f15875

Browse files
udsy19artbataev
andauthored
fix(asr): stop TDT greedy decoding skipping an extra encoder frame (#16096)
`GreedyTDTInfer._greedy_decode` advanced `time_idx` by one extra frame whenever the per-frame symbol budget was exhausted, not only when the budget was exhausted on a zero-duration prediction. With `max_symbols_per_step=1` the budget is exhausted on every frame, so the decoder skipped `duration + 1` frames every time: tokens were dropped and the surviving timestamps were late, while `token_duration` still reported the predicted duration, making the hypothesis self-contradictory. The inner loop can only exit with `skip == 0` because the budget ran out, so the forced advance belongs in the existing zero-duration branch and the trailing conditional can go. This also keeps the `preserve_alignments` / `preserve_frame_confidence` buffers, which are extended by `skip` entries, in step with `time_idx`. With random weights over 468 single-vs-batched utterance comparisons, `GreedyTDTInfer` disagreed with `GreedyBatchedTDTInfer` on 135 of them before the change and on none after it. Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com> Co-authored-by: Vladimir Bataev <vbataev@nvidia.com>
1 parent c60f7d7 commit 5f15875

2 files changed

Lines changed: 151 additions & 6 deletions

File tree

nemo/collections/asr/parts/submodules/rnnt_greedy_decoding.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2728,11 +2728,12 @@ def _greedy_decode(
27282728
time_idx += skip
27292729
need_loop = skip == 0
27302730

2731-
# this rarely happens, but we manually increment the `skip` number
2732-
# if blank is emitted and duration=0 is predicted. This prevents possible
2733-
# infinite loops.
2731+
# The inner loop exits either because a non-zero duration was predicted (`time_idx` has already
2732+
# been advanced by it) or because the symbol budget ran out on a zero-duration prediction. Only
2733+
# the latter needs a manual advance, which also prevents possible infinite loops.
27342734
if skip == 0:
27352735
skip = 1
2736+
time_idx += 1
27362737

27372738
if self.preserve_alignments:
27382739
# convert Ti-th logits into a torch array
@@ -2743,9 +2744,6 @@ def _greedy_decode(
27432744
for i in range(skip):
27442745
hypothesis.frame_confidence.append([]) # blank buffer for next timestep
27452746

2746-
if symbols_added == self.max_symbols:
2747-
time_idx += 1
2748-
27492747
# Remove trailing empty list of Alignments
27502748
if self.preserve_alignments:
27512749
if len(hypothesis.alignments[-1]) == 0:

tests/collections/asr/test_asr_rnnt_encdec_model.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,153 @@ def test_multiblank_rnnt_greedy_decoding(self, greedy_class):
670670
with torch.no_grad():
671671
_ = greedy(encoder_output=enc_out, encoded_lengths=enc_len)
672672

673+
@pytest.mark.skipif(
674+
not NUMBA_RNNT_LOSS_AVAILABLE,
675+
reason='RNNTLoss has not been compiled with appropriate numba version.',
676+
)
677+
@pytest.mark.unit
678+
@pytest.mark.parametrize("max_symbols_per_step", [1, 2, 5])
679+
def test_tdt_greedy_decoding_advances_by_predicted_duration(self, max_symbols_per_step: int):
680+
"""TDT greedy decoding must advance the time index by exactly the predicted duration.
681+
682+
A joint that always predicts token 1 with duration 2 makes the answer computable by hand:
683+
the tokens have to land on t = 0, 2, 4, ... for every value of `max_symbols_per_step`.
684+
"""
685+
token_list = [" ", "a", "b", "c"]
686+
vocab_size = len(token_list)
687+
durations = [0, 1, 2, 4]
688+
duration_index = 2
689+
num_frames = 10
690+
691+
encoder_output_size = 4
692+
decoder_output_size = 4
693+
joint_output_shape = 4
694+
695+
class ConstantTDTJoint(RNNTJoint):
696+
"""Joint that always predicts token 1 with duration `durations[duration_index]`."""
697+
698+
def joint_after_projection(self, f: torch.Tensor, g: torch.Tensor) -> torch.Tensor:
699+
batch_size = f.shape[0]
700+
time_steps = f.shape[1] if f.dim() == 3 else 1
701+
target_steps = g.shape[1] if g.dim() == 3 else 1
702+
logits = torch.full(
703+
(batch_size, time_steps, target_steps, vocab_size + 1 + len(durations)), -10.0, device=f.device
704+
)
705+
logits[..., 1] = 5.0
706+
logits[..., vocab_size + 1 + duration_index] = 5.0
707+
return logits
708+
709+
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
710+
jointnet_cfg = {
711+
'encoder_hidden': encoder_output_size,
712+
'pred_hidden': decoder_output_size,
713+
'joint_hidden': joint_output_shape,
714+
'activation': 'relu',
715+
}
716+
717+
decoder = RNNTDecoder(prednet_cfg, vocab_size)
718+
joint_net = ConstantTDTJoint(jointnet_cfg, vocab_size, vocabulary=token_list, num_extra_outputs=len(durations))
719+
720+
duration = durations[duration_index]
721+
expected_timestamp = list(range(0, num_frames, duration))
722+
723+
# (B, D, T)
724+
enc_out = torch.zeros(1, encoder_output_size, num_frames)
725+
enc_len = torch.tensor([num_frames], dtype=torch.int32)
726+
727+
for greedy_class, additional_decoding_kwargs in [
728+
(greedy_decode.GreedyTDTInfer, {}),
729+
(greedy_decode.GreedyBatchedTDTInfer, {"use_cuda_graph_decoder": False}),
730+
]:
731+
greedy = greedy_class(
732+
decoder,
733+
joint_net,
734+
blank_index=vocab_size,
735+
durations=durations,
736+
max_symbols_per_step=max_symbols_per_step,
737+
include_duration=True,
738+
**additional_decoding_kwargs,
739+
)
740+
741+
with torch.no_grad():
742+
hyp = greedy(encoder_output=enc_out, encoded_lengths=enc_len)[0][0]
743+
744+
assert [int(t) for t in hyp.timestamp] == expected_timestamp, greedy_class.__name__
745+
assert [int(d) for d in hyp.token_duration] == [duration] * len(expected_timestamp)
746+
747+
@pytest.mark.skipif(
748+
not NUMBA_RNNT_LOSS_AVAILABLE,
749+
reason='RNNTLoss has not been compiled with appropriate numba version.',
750+
)
751+
@pytest.mark.unit
752+
def test_tdt_greedy_decoding_exhausted_symbol_budget(self):
753+
"""Exhausting the symbol budget on a non-zero duration must not advance the time index further.
754+
755+
The joint alternates duration 0 and duration 2 on successive calls, so with
756+
`max_symbols_per_step=2` every time step emits one token with duration 0 and one with duration 2,
757+
exhausting the budget exactly when the time index has already advanced by 2.
758+
"""
759+
token_list = [" ", "a", "b", "c"]
760+
vocab_size = len(token_list)
761+
durations = [0, 1, 2, 4]
762+
num_frames = 10
763+
764+
encoder_output_size = 4
765+
decoder_output_size = 4
766+
joint_output_shape = 4
767+
768+
class AlternatingTDTJoint(RNNTJoint):
769+
"""Joint that always predicts token 1, with duration 0 and 2 on alternating calls."""
770+
771+
def __init__(self, *args, **kwargs):
772+
super().__init__(*args, **kwargs)
773+
self.num_calls = 0
774+
775+
def joint_after_projection(self, f: torch.Tensor, g: torch.Tensor) -> torch.Tensor:
776+
batch_size = f.shape[0]
777+
time_steps = f.shape[1] if f.dim() == 3 else 1
778+
target_steps = g.shape[1] if g.dim() == 3 else 1
779+
logits = torch.full(
780+
(batch_size, time_steps, target_steps, vocab_size + 1 + len(durations)), -10.0, device=f.device
781+
)
782+
logits[..., 1] = 5.0
783+
logits[..., vocab_size + 1 + (0 if self.num_calls % 2 == 0 else 2)] = 5.0
784+
self.num_calls += 1
785+
return logits
786+
787+
prednet_cfg = {'pred_hidden': decoder_output_size, 'pred_rnn_layers': 1}
788+
jointnet_cfg = {
789+
'encoder_hidden': encoder_output_size,
790+
'pred_hidden': decoder_output_size,
791+
'joint_hidden': joint_output_shape,
792+
'activation': 'relu',
793+
}
794+
795+
decoder = RNNTDecoder(prednet_cfg, vocab_size)
796+
joint_net = AlternatingTDTJoint(
797+
jointnet_cfg, vocab_size, vocabulary=token_list, num_extra_outputs=len(durations)
798+
)
799+
800+
greedy = greedy_decode.GreedyTDTInfer(
801+
decoder,
802+
joint_net,
803+
blank_index=vocab_size,
804+
durations=durations,
805+
max_symbols_per_step=2,
806+
include_duration=True,
807+
)
808+
809+
# (B, D, T)
810+
enc_out = torch.zeros(1, encoder_output_size, num_frames)
811+
enc_len = torch.tensor([num_frames], dtype=torch.int32)
812+
813+
with torch.no_grad():
814+
hyp = greedy(encoder_output=enc_out, encoded_lengths=enc_len)[0][0]
815+
816+
# two tokens per time step, and the time step advances by the duration of the second one
817+
assert [int(t) for t in hyp.timestamp] == [t for t in range(0, num_frames, 2) for _ in range(2)]
818+
assert [int(d) for d in hyp.token_duration] == [0, 2] * (num_frames // 2)
819+
673820
@pytest.mark.skipif(
674821
not NUMBA_RNNT_LOSS_AVAILABLE,
675822
reason='RNNTLoss has not been compiled with appropriate numba version.',

0 commit comments

Comments
 (0)