ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
449 lines
19 KiB
Python
449 lines
19 KiB
Python
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# pylint: disable=C0116
|
|
import math
|
|
from bisect import bisect_left
|
|
from dataclasses import dataclass
|
|
from typing import Any, Sequence
|
|
|
|
import numpy as np
|
|
from lhotse.cut import Cut, MonoCut
|
|
from lhotse.dataset import SamplingConstraint, TokenConstraint
|
|
from lhotse.dataset.sampling.dynamic_bucketing import FixedBucketBatchSizeConstraint
|
|
from lhotse.utils import ifnone
|
|
|
|
from nemo.collections.common.data.lhotse.text_adapters import Formattable, NeMoMultimodalConversation
|
|
|
|
|
|
@dataclass
|
|
class MultimodalSamplingConstraint(SamplingConstraint):
|
|
"""
|
|
Sampling strategy that customizes Lhotse samplers to measure sequence lengths as token counts.
|
|
It provides a unified interface for audio and text examples - audio duration is converted to
|
|
an equivalent token count.
|
|
"""
|
|
|
|
# How many seconds of audio is a text token worth; balances audio to text ratio in a mini-batch.
|
|
# Generally set this to frame_shift * total_subsampling_factor of your audio encoder.
|
|
token_equivalent_duration: float | None = None
|
|
|
|
# Defines maximum batch size (may be lower than that if batch_length is also specified).
|
|
batch_size: int | None = None
|
|
|
|
# Defines the total number of tokens in a mini-batch.
|
|
# Setting this enables dynamic batch sizes.
|
|
# We will use ``token_equivalent_duration`` to convert audio examples to token sizes.
|
|
batch_tokens: int | None = None
|
|
|
|
# When specified, this value is inversely proportional to the penalty we assign
|
|
# to longer examples when measuring their length/duration;
|
|
# i.e. large quadratic factor is a small penalty, small quadratic factor is a large penalty.
|
|
# Tweaking this helps equalize the GPU memory usage for dynamic batch sizes when using bucketing.
|
|
quadratic_factor: float | None = None
|
|
|
|
# When False (default), we only consider the input part of the example to determine its length,
|
|
# e.g. for a Cut that means its audio duration converted to tokens, for text that means len(context_ids), etc.
|
|
# When True, we consider the sum of input and output lengths together (useful mostly for decoder-only models).
|
|
measure_total_length: bool = False
|
|
|
|
_internal = None
|
|
|
|
def __post_init__(self):
|
|
self._internal = TokenConstraint(
|
|
max_tokens=self.batch_tokens,
|
|
max_examples=self.batch_size,
|
|
quadratic_length=self.quadratic_factor,
|
|
)
|
|
|
|
def add(self, example: Any) -> None:
|
|
num_tokens = self.measure_length(example)
|
|
example.num_tokens = num_tokens
|
|
self._internal.add(example)
|
|
|
|
def exceeded(self) -> bool:
|
|
return self._internal.exceeded()
|
|
|
|
def close_to_exceeding(self) -> bool:
|
|
return self._internal.close_to_exceeding()
|
|
|
|
def reset(self) -> None:
|
|
self._internal.reset()
|
|
|
|
def measure_length(self, example: Any) -> float:
|
|
if isinstance(example, Cut):
|
|
audio_len_in_tokens = math.ceil(example.duration / self.token_equivalent_duration)
|
|
if self.measure_total_length:
|
|
# Total length of a Cut (audio+text example) is counted as the sum of:
|
|
# * num_tokens in each supervision segment ("utterance") in the Cut
|
|
# * num_frames of audio (frame=token) given a token-equivalent-duration (basically a frame shift)
|
|
text_tokens = 0
|
|
for s in example.supervisions:
|
|
if s.has_custom("tokens"):
|
|
text_tokens += len(s.tokens)
|
|
return audio_len_in_tokens + text_tokens
|
|
else:
|
|
return audio_len_in_tokens
|
|
elif isinstance(example, Formattable):
|
|
try:
|
|
return example.total_length if self.measure_total_length else example.input_length
|
|
except (AttributeError, AssertionError) as e:
|
|
raise RuntimeError(
|
|
"Couldn't determine the length of a text example; "
|
|
"have you provided both prompt_format and tokenizer when instantiating the dataloader?"
|
|
) from e
|
|
raise RuntimeError(f"Unsupported example type: {type(example)}")
|
|
|
|
|
|
@dataclass
|
|
class FixedBucketBatchSizeConstraint2D(FixedBucketBatchSizeConstraint):
|
|
"""
|
|
Sampling strategy that customizes Lhotse samplers to support 2D bucket selection (it also supports 1D).
|
|
It is intended only for audio examples (i.e., Lhotse Cut objects).
|
|
|
|
When ``strict_2d`` is set, we only consider sub-buckets for a single bucket that is the best match.
|
|
When set to ``False``, we'll promote an example to buckets with larger 1st dim if they can accommodate the 2nd dim.
|
|
|
|
When ``max_ratio`` is set, it discards the examples that exceed a specific output-to-input length ratio.
|
|
``max_ratio`` must be a list with the same length as the number of buckets.
|
|
``max_ratio`` is only applied when ``strict_2d`` is set to ``True``.
|
|
"""
|
|
|
|
strict_2d: bool = True
|
|
max_ratio: list[float] | None = None
|
|
|
|
def __post_init__(self):
|
|
if isinstance(self.max_seq_len_buckets[0], Sequence):
|
|
self.max_seq_len_buckets = np.asarray(self.max_seq_len_buckets)
|
|
if self.max_ratio is not None:
|
|
assert isinstance(self.max_ratio, Sequence), f"self.max_ratio must be a list, but we got: {self.max_ratio}"
|
|
assert len(self.max_ratio) == len(
|
|
self.max_seq_len_buckets
|
|
), f"{len(self.max_ratio)=} != {len(self.max_seq_len_buckets)=}"
|
|
|
|
@property
|
|
def bucketing_2d_enabled(self) -> bool:
|
|
return isinstance(self.max_seq_len_buckets, np.ndarray)
|
|
|
|
def measure_length(self, example: Cut) -> tuple[float, float] | float:
|
|
if self.bucketing_2d_enabled:
|
|
return example.duration, _measure_tokens(example)
|
|
else:
|
|
return example.duration
|
|
|
|
def select_bucket(self, buckets: Any, example: Any = None, example_len: Any = None) -> int:
|
|
if example_len is None:
|
|
example_len = self.measure_length(example)
|
|
return find_smallest_bucket(
|
|
self.max_seq_len_buckets, example_len, strict=self.strict_2d, max_ratio=self.max_ratio
|
|
)
|
|
|
|
|
|
def find_smallest_bucket(
|
|
buckets: np.ndarray,
|
|
example_lens: float | Sequence[float],
|
|
strict: bool = True,
|
|
max_ratio: Sequence[float] | None = None,
|
|
) -> int | None:
|
|
"""
|
|
Find the smallest bucket that fits a given example.
|
|
Each bucket and ``example_lens`` are floats (1-D bucketing)
|
|
or tuples of (dim0, dim1, dim2, ...) (N-D bucketing, typically 2-D).
|
|
Assumes the buckets have been sorted ascendingly.
|
|
Returns a tuple of (smallest_bin, bin_idx), or (None, None) if no bucket fits the example.
|
|
"""
|
|
# 1D bucketing - binary search.
|
|
if isinstance(example_lens, (float, int)): # 1-D
|
|
idx = bisect_left(buckets, example_lens)
|
|
if idx == len(buckets):
|
|
return None
|
|
return idx
|
|
|
|
# 2D bucketing 'strict' mode: only consider sub-buckets for the specific bucket that matches this example.
|
|
# E.g. for buckets = [(10, 5), (10, 10), (20, 12), (20, 18)]
|
|
# and example_lens = (8, 11)
|
|
# we will return None because we only consider the first two buckets based on dim0 (=8).
|
|
if strict:
|
|
# Find the first 2D bucket that accepts this example
|
|
dim0_begin = bisect_left(buckets[:, 0], example_lens[0])
|
|
if dim0_begin == buckets.shape[0]:
|
|
return None
|
|
# Find the last 2D bucket that accepts this example
|
|
dim0_end = dim0_begin
|
|
while dim0_end < buckets.shape[0] and buckets[dim0_end, 0] == buckets[dim0_begin, 0]:
|
|
dim0_end += 1
|
|
# Find the smallest 2D bucket in this range that accepts this example
|
|
dim1_begin = bisect_left(buckets[dim0_begin:dim0_end, 1], example_lens[1])
|
|
if dim1_begin == dim0_end - dim0_begin:
|
|
return None
|
|
fit_idx = dim0_begin + dim1_begin
|
|
# Apply max_ratio (token-per-second/token-per-token) filtering if requested
|
|
if max_ratio is not None and example_lens[1] / example_lens[0] > max_ratio[fit_idx]:
|
|
return None
|
|
return fit_idx
|
|
|
|
# 2D bucketing 'lenient' mode - linear search (as 2nd dim may not be growing monotonically).
|
|
# E.g. for buckets = [(10, 5), (10, 10), (20, 12), (20, 18)]
|
|
# and example_lens = (8, 11)
|
|
# we will return bucket_idx=2 because (20, 12) fits (8, 11) at the cost of more padding.
|
|
does_fit = np.all(np.asarray(example_lens) <= buckets, axis=1)
|
|
min_fit_idx = np.argmax(does_fit)
|
|
if min_fit_idx or does_fit[min_fit_idx]:
|
|
return min_fit_idx.item()
|
|
else:
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class MultimodalFixedBucketBatchSizeConstraint2D(FixedBucketBatchSizeConstraint2D):
|
|
"""
|
|
Sampling strategy that customizes Lhotse samplers to support both multimodal sampling and 2D bucket selection.
|
|
It combines the capabilities of :class:`FixedBucketBatchSizeConstraint2D` and :class:`MultimodalSamplingConstraint`
|
|
"""
|
|
|
|
# How many seconds of audio is a text token worth; balances audio to text ratio in a mini-batch.
|
|
# Generally set this to frame_shift * total_subsampling_factor of your audio encoder.
|
|
token_equivalent_duration: float | None = None
|
|
|
|
# When False (default), we only consider the input part of the example to determine its length,
|
|
# e.g. for a Cut that means its audio duration converted to tokens, for text that means len(context_ids), etc.
|
|
# When True, we consider the sum of input and output lengths together (useful mostly for decoder-only models).
|
|
measure_total_length: bool = False
|
|
|
|
def measure_length(self, example: Any) -> float | tuple[float, float]:
|
|
if isinstance(example, Cut):
|
|
# Total length of a Cut (audio+text example) is counted as the sum of:
|
|
# * num_tokens in each supervision segment ("utterance") in the Cut
|
|
# * num_frames of audio (frame=token) given a token-equivalent-duration (basically a frame shift)
|
|
audio_len_in_tokens = math.ceil(example.duration / self.token_equivalent_duration)
|
|
text_tokens = _measure_tokens(example)
|
|
|
|
if self.bucketing_2d_enabled:
|
|
return audio_len_in_tokens, text_tokens
|
|
|
|
else:
|
|
if self.measure_total_length:
|
|
return audio_len_in_tokens + text_tokens
|
|
else:
|
|
return audio_len_in_tokens
|
|
|
|
elif isinstance(example, Formattable):
|
|
if self.bucketing_2d_enabled:
|
|
return example.input_length, example.output_length
|
|
else:
|
|
return example.total_length if self.measure_total_length else example.input_length
|
|
|
|
raise RuntimeError(f"Unsupported example type: {type(example)}")
|
|
|
|
|
|
class DurationFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's duration is in range [d_min, d_max] and ``False`` otherwise.
|
|
Acts as a pass-through for objects of other type than Cut.
|
|
"""
|
|
|
|
def __init__(self, d_min: float | None, d_max: float | None) -> None:
|
|
self.d_min = ifnone(d_min, -1)
|
|
self.d_max = ifnone(d_max, float("inf"))
|
|
|
|
def __call__(self, example) -> bool:
|
|
if isinstance(example, Cut):
|
|
return self.d_min <= example.duration <= self.d_max
|
|
elif isinstance(example, NeMoMultimodalConversation):
|
|
if example.is_text_only:
|
|
return True # does not apply to text
|
|
tot_dur = sum(c.duration for c in example.list_cuts())
|
|
return self.d_min <= tot_dur <= self.d_max
|
|
else:
|
|
return True # does not apply to text etc.
|
|
|
|
|
|
class ValidationStatusFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's validation status is equal to keep and ``False`` otherwise.
|
|
Acts as a pass-through for objects of other type than Cut.
|
|
"""
|
|
|
|
def __init__(self, keep: str = "pass") -> None:
|
|
self.keep = keep
|
|
|
|
def __call__(self, example) -> bool:
|
|
if (
|
|
isinstance(example, MonoCut)
|
|
and example.has_custom("validation_status")
|
|
and example.validation_status != self.keep
|
|
):
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
class CERFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's CER is less than max_cer and ``False`` otherwise.
|
|
Acts as a pass-through for objects of other type than Cut.
|
|
"""
|
|
|
|
def __init__(self, max_cer: float | None) -> None:
|
|
self.max_cer = ifnone(max_cer, float("inf"))
|
|
|
|
def __call__(self, example) -> bool:
|
|
if (
|
|
isinstance(example, MonoCut)
|
|
and len(example.supervisions) > 0
|
|
and example.supervisions[0].has_custom("cer")
|
|
):
|
|
return example.supervisions[0].cer <= self.max_cer
|
|
else:
|
|
return True
|
|
|
|
|
|
class ContextSpeakerSimilarityFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's context speaker similarity is greater than min_context_speaker_similarity and ``False`` otherwise.
|
|
Acts as a pass-through for objects of other type than Cut.
|
|
"""
|
|
|
|
def __init__(self, min_context_speaker_similarity: float | None) -> None:
|
|
self.min_context_speaker_similarity = ifnone(min_context_speaker_similarity, -1)
|
|
|
|
def __call__(self, example) -> bool:
|
|
if (
|
|
isinstance(example, MonoCut)
|
|
and len(example.supervisions) > 0
|
|
and example.supervisions[0].has_custom("context_speaker_similarity")
|
|
):
|
|
return example.supervisions[0].context_speaker_similarity >= self.min_context_speaker_similarity
|
|
else:
|
|
return True
|
|
|
|
|
|
class TokenCountFilter:
|
|
"""
|
|
Callable, returns ``True`` if an example's number of tokens is in range [t_min, t_max] and ``False`` otherwise.
|
|
|
|
It is only applicable to data types that derive from class ``Formattable`` and lhotse ``Cut`` objects.
|
|
Acts as a passthrough for Cuts.
|
|
Raises exception if a non-Formattable and non-Cut data are provided.
|
|
|
|
The ``measure_total_length`` option allows to select whether we should filter on context_ids length (=False)
|
|
or input_ids length (=True).
|
|
The difference is that for decoder-only models, we collapse input and output into a single sequence,
|
|
so we should measure the example length using input_ids (measure_total_length=True).
|
|
However, for models which have separate inputs and outputs such as encoder-decoder models,
|
|
we want to measure the input lengths only here (measure_total_length=False),
|
|
and enable ``TokenPerTokenFilter`` for additional filtering on the output sequence length.
|
|
"""
|
|
|
|
def __init__(self, t_min: float | None, t_max: float | None, measure_total_length: bool) -> None:
|
|
self.t_min = ifnone(t_min, -1)
|
|
self.t_max = ifnone(t_max, float("inf"))
|
|
self.measure_total_length = measure_total_length
|
|
self.enabled = self.t_min > 0 or self.t_max < float("inf")
|
|
|
|
def __call__(self, example) -> bool:
|
|
if not self.enabled or isinstance(example, Cut):
|
|
return True # does not apply to Cuts
|
|
assert isinstance(example, Formattable), (
|
|
f"TokenCountFilter can only be applied to data examples that derive Formattable class. "
|
|
f"Formattable objects define properties input_length, output_length, and total_length that "
|
|
f"allow us to select the right sequence length for filtering. We got: {example}"
|
|
)
|
|
try:
|
|
length = example.total_length if self.measure_total_length else example.input_length
|
|
except (AttributeError, AssertionError) as e:
|
|
raise RuntimeError(
|
|
f"Cannot measure token count for example: {example} "
|
|
f"-- did you forget to apply prompt formatting? If instantiating Lhotse dataloader, "
|
|
f"make sure you provided 'prompt_format' option and passed the tokenizer."
|
|
) from e
|
|
return self.t_min <= length <= self.t_max
|
|
|
|
|
|
class TokenPerSecondFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's num_tokens (sum of len(tokens) for each supervision)
|
|
is in range [tps_min, tps_max] and ``False`` otherwise.
|
|
Acts as a pass-through for objects of other type than Cut.
|
|
"""
|
|
|
|
def __init__(self, tps_min: float | None, tps_max: float | None) -> None:
|
|
self.tps_min = ifnone(tps_min, -1)
|
|
if isinstance(tps_max, Sequence):
|
|
tps_max = float("inf") # filtering handled in bucketing filter
|
|
self.tps_max = ifnone(tps_max, float("inf"))
|
|
assert tps_min <= tps_max, f"{tps_min=} {tps_max=}"
|
|
self.enabled = tps_min > 0 or tps_max < float("inf")
|
|
|
|
def __call__(self, example) -> bool:
|
|
if not isinstance(example, Cut) or not self.enabled:
|
|
return True # pass-through for non-audio examples.
|
|
tps = _measure_tps(example)
|
|
return self.tps_min <= tps <= self.tps_max
|
|
|
|
|
|
class TokenPerTokenFilter:
|
|
"""
|
|
Callable, returns ``True`` if a cut's num_tokens (sum of len(tokens) for each supervision)
|
|
is in range [tps_min, tps_max] and ``False`` otherwise.
|
|
Acts as a pass-through for audio examples (Cuts).
|
|
"""
|
|
|
|
def __init__(self, tpt_min: float | None, tpt_max: float | None) -> None:
|
|
self.tpt_min = ifnone(tpt_min, -1)
|
|
if isinstance(tpt_max, Sequence):
|
|
tpt_max = float("inf") # filtering handled in bucketing filter
|
|
self.tpt_max = ifnone(tpt_max, float("inf"))
|
|
assert tpt_min <= tpt_max, f"{tpt_min=} {tpt_max=}"
|
|
self.enabled = tpt_min > 0 or tpt_max < float("inf")
|
|
|
|
def __call__(self, example) -> bool:
|
|
if isinstance(example, Cut) or not self.enabled:
|
|
return True # pass-through for non-text examples.
|
|
tpt = example.answer_ids.shape[0] / example.context_ids.shape[0]
|
|
return self.tpt_min <= tpt <= self.tpt_max
|
|
|
|
|
|
class BucketingFilter:
|
|
"""
|
|
Filters out examples that did not fit into any of the buckets.
|
|
Intended mainly for 2D bucketing. This filter is only active when
|
|
the constraint passed to it is of type ``FixedBucketBatchSizeConstraint2D``,
|
|
and is otherwise disabled.
|
|
"""
|
|
|
|
def __init__(self, sampling_constraint: SamplingConstraint) -> None:
|
|
self.constraint = sampling_constraint
|
|
self.enabled = isinstance(self.constraint, FixedBucketBatchSizeConstraint2D)
|
|
|
|
def __call__(self, example) -> bool:
|
|
if not self.enabled:
|
|
return True
|
|
return self.constraint.select_bucket(self.constraint.max_seq_len_buckets, example) is not None
|
|
|
|
|
|
def _measure_tokens(cut: Cut) -> int:
|
|
if hasattr(cut, "input_ids"):
|
|
return len(cut.input_ids) # tokenized with prompt formatter
|
|
supervisions_with_tokens = [s for s in cut.supervisions if hasattr(s, "tokens")]
|
|
assert len(supervisions_with_tokens) > 0, (
|
|
"Cannot measure the number of tokens with untokenized supervisions. "
|
|
"Did you forget to provide the tokenizer argument to get_lhotse_dataloader_from_config() method?"
|
|
)
|
|
return sum(len(s.tokens) for s in supervisions_with_tokens)
|
|
|
|
|
|
def _measure_tps(cut: Cut) -> float:
|
|
num_tokens = _measure_tokens(cut)
|
|
return num_tokens / cut.duration
|