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,53 @@
|
||||
#!/usr/bin/env 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.
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from nemo.collections.common.tokenizers import CanaryTokenizer
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("output_dir", type=click.Path())
|
||||
def main(output_dir: str) -> None:
|
||||
"""
|
||||
Builds the special tokens tokenizer for NVIDIA Canary-1B model.
|
||||
It's intended to be used with CanaryTokenizer (a specialized AggregateTokenizer)
|
||||
under name ``spl_tokens``.
|
||||
"""
|
||||
CanaryTokenizer.build_special_tokenizer(
|
||||
[
|
||||
"<|endoftext|>",
|
||||
"<|startoftranscript|>",
|
||||
"<|transcribe|>",
|
||||
"<|translate|>",
|
||||
"<|nopnc|>",
|
||||
"<|pnc|>",
|
||||
"<|nospeech|>",
|
||||
]
|
||||
+ [
|
||||
"<|en|>",
|
||||
"<|es|>",
|
||||
"<|de|>",
|
||||
"<|fr|>",
|
||||
]
|
||||
+ [f"<|spltoken{i}|>" for i in range(16)],
|
||||
model_dir=output_dir,
|
||||
force_rebuild=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env 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.
|
||||
import math
|
||||
|
||||
import click
|
||||
|
||||
from nemo.collections.common.tokenizers import CanaryTokenizer
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("output_dir", type=click.Path())
|
||||
def main(output_dir: str) -> None:
|
||||
"""
|
||||
Builds the special tokens tokenizer for NVIDIA Canary-2.0 model.
|
||||
It's intended to be used with CanaryTokenizer (a specialized AggregateTokenizer)
|
||||
under name ``spl_tokens``.
|
||||
"""
|
||||
|
||||
tokens = (
|
||||
[
|
||||
# Generic special tokens
|
||||
"<|endoftext|>",
|
||||
"<|startoftranscript|>",
|
||||
"<|nopnc|>",
|
||||
"<|pnc|>",
|
||||
"<|nospeech|>",
|
||||
"<|startofcontext|>",
|
||||
"<|itn|>",
|
||||
"<|noitn|>",
|
||||
"<|timestamp|>",
|
||||
"<|notimestamp|>",
|
||||
"<|diarize|>",
|
||||
"<|nodiarize|>",
|
||||
"<|spkchange|>",
|
||||
"<|audioseparator|>",
|
||||
"<|emo:undefined|>",
|
||||
"<|emo:neutral|>",
|
||||
"<|emo:happy|>",
|
||||
"<|emo:sad|>",
|
||||
"<|emo:angry|>",
|
||||
]
|
||||
# Language special tokens
|
||||
+ [
|
||||
"<|unklang|>",
|
||||
]
|
||||
+ ISO_LANGS
|
||||
# Timestamp frame special tokens
|
||||
+ [f"<|{i}|>" for i in range(900)]
|
||||
# Speaker indicator special tokens
|
||||
+ [f"<|spk{i}|>" for i in range(16)]
|
||||
)
|
||||
|
||||
num_tokens = len(tokens) + 3 # count "<pad>", "<unk>", "_" too
|
||||
print(f"We have {num_tokens} special tokens.")
|
||||
final_num_tokens = next_multiple_of_64(num_tokens)
|
||||
num_extra_tokens = final_num_tokens - num_tokens
|
||||
print(f"Adding extra {num_extra_tokens} unused special tokens for a total vocab size of {final_num_tokens}")
|
||||
|
||||
tokens += [
|
||||
# Timestamp related special tokens
|
||||
f"<|spltoken{i}|>"
|
||||
for i in range(num_extra_tokens)
|
||||
]
|
||||
|
||||
tokenizer = CanaryTokenizer.build_special_tokenizer(
|
||||
tokens=tokens,
|
||||
model_dir=output_dir,
|
||||
force_rebuild=True,
|
||||
)
|
||||
|
||||
assert tokenizer.vocab_size == 1152, tokenizer.vocab_size
|
||||
|
||||
|
||||
def next_multiple_of_64(n):
|
||||
return ((n + 63) // 64) * 64
|
||||
|
||||
|
||||
ISO_LANGS = [
|
||||
"<|aa|>",
|
||||
"<|ab|>",
|
||||
"<|af|>",
|
||||
"<|ak|>",
|
||||
"<|sq|>",
|
||||
"<|am|>",
|
||||
"<|ar|>",
|
||||
"<|an|>",
|
||||
"<|hy|>",
|
||||
"<|as|>",
|
||||
"<|av|>",
|
||||
"<|ae|>",
|
||||
"<|ay|>",
|
||||
"<|az|>",
|
||||
"<|bm|>",
|
||||
"<|ba|>",
|
||||
"<|eu|>",
|
||||
"<|be|>",
|
||||
"<|bn|>",
|
||||
"<|bi|>",
|
||||
"<|bs|>",
|
||||
"<|br|>",
|
||||
"<|bg|>",
|
||||
"<|my|>",
|
||||
"<|ca|>",
|
||||
"<|ch|>",
|
||||
"<|ce|>",
|
||||
"<|ny|>",
|
||||
"<|zh|>",
|
||||
"<|cu|>",
|
||||
"<|cv|>",
|
||||
"<|kw|>",
|
||||
"<|co|>",
|
||||
"<|cr|>",
|
||||
"<|hr|>",
|
||||
"<|cs|>",
|
||||
"<|da|>",
|
||||
"<|dv|>",
|
||||
"<|nl|>",
|
||||
"<|dz|>",
|
||||
"<|en|>",
|
||||
"<|eo|>",
|
||||
"<|et|>",
|
||||
"<|ee|>",
|
||||
"<|fo|>",
|
||||
"<|fj|>",
|
||||
"<|fi|>",
|
||||
"<|fr|>",
|
||||
"<|fy|>",
|
||||
"<|ff|>",
|
||||
"<|gd|>",
|
||||
"<|gl|>",
|
||||
"<|lg|>",
|
||||
"<|ka|>",
|
||||
"<|de|>",
|
||||
"<|el|>",
|
||||
"<|kl|>",
|
||||
"<|gn|>",
|
||||
"<|gu|>",
|
||||
"<|ht|>",
|
||||
"<|ha|>",
|
||||
"<|he|>",
|
||||
"<|hz|>",
|
||||
"<|hi|>",
|
||||
"<|ho|>",
|
||||
"<|hu|>",
|
||||
"<|is|>",
|
||||
"<|io|>",
|
||||
"<|ig|>",
|
||||
"<|id|>",
|
||||
"<|ia|>",
|
||||
"<|ie|>",
|
||||
"<|iu|>",
|
||||
"<|ik|>",
|
||||
"<|ga|>",
|
||||
"<|it|>",
|
||||
"<|ja|>",
|
||||
"<|jv|>",
|
||||
"<|kn|>",
|
||||
"<|kr|>",
|
||||
"<|ks|>",
|
||||
"<|kk|>",
|
||||
"<|km|>",
|
||||
"<|ki|>",
|
||||
"<|rw|>",
|
||||
"<|ky|>",
|
||||
"<|kv|>",
|
||||
"<|kg|>",
|
||||
"<|ko|>",
|
||||
"<|kj|>",
|
||||
"<|ku|>",
|
||||
"<|lo|>",
|
||||
"<|la|>",
|
||||
"<|lv|>",
|
||||
"<|li|>",
|
||||
"<|ln|>",
|
||||
"<|lt|>",
|
||||
"<|lu|>",
|
||||
"<|lb|>",
|
||||
"<|mk|>",
|
||||
"<|mg|>",
|
||||
"<|ms|>",
|
||||
"<|ml|>",
|
||||
"<|mt|>",
|
||||
"<|gv|>",
|
||||
"<|mi|>",
|
||||
"<|mr|>",
|
||||
"<|mh|>",
|
||||
"<|mn|>",
|
||||
"<|na|>",
|
||||
"<|nv|>",
|
||||
"<|nd|>",
|
||||
"<|nr|>",
|
||||
"<|ng|>",
|
||||
"<|ne|>",
|
||||
"<|no|>",
|
||||
"<|nb|>",
|
||||
"<|nn|>",
|
||||
"<|oc|>",
|
||||
"<|oj|>",
|
||||
"<|or|>",
|
||||
"<|om|>",
|
||||
"<|os|>",
|
||||
"<|pi|>",
|
||||
"<|ps|>",
|
||||
"<|fa|>",
|
||||
"<|pl|>",
|
||||
"<|pt|>",
|
||||
"<|pa|>",
|
||||
"<|qu|>",
|
||||
"<|ro|>",
|
||||
"<|rm|>",
|
||||
"<|rn|>",
|
||||
"<|ru|>",
|
||||
"<|se|>",
|
||||
"<|sm|>",
|
||||
"<|sg|>",
|
||||
"<|sa|>",
|
||||
"<|sc|>",
|
||||
"<|sr|>",
|
||||
"<|sn|>",
|
||||
"<|sd|>",
|
||||
"<|si|>",
|
||||
"<|sk|>",
|
||||
"<|sl|>",
|
||||
"<|so|>",
|
||||
"<|st|>",
|
||||
"<|es|>",
|
||||
"<|su|>",
|
||||
"<|sw|>",
|
||||
"<|ss|>",
|
||||
"<|sv|>",
|
||||
"<|tl|>",
|
||||
"<|ty|>",
|
||||
"<|tg|>",
|
||||
"<|ta|>",
|
||||
"<|tt|>",
|
||||
"<|te|>",
|
||||
"<|th|>",
|
||||
"<|bo|>",
|
||||
"<|ti|>",
|
||||
"<|to|>",
|
||||
"<|ts|>",
|
||||
"<|tn|>",
|
||||
"<|tr|>",
|
||||
"<|tk|>",
|
||||
"<|tw|>",
|
||||
"<|ug|>",
|
||||
"<|uk|>",
|
||||
"<|ur|>",
|
||||
"<|uz|>",
|
||||
"<|ve|>",
|
||||
"<|vi|>",
|
||||
"<|vo|>",
|
||||
"<|wa|>",
|
||||
"<|cy|>",
|
||||
"<|wo|>",
|
||||
"<|xh|>",
|
||||
"<|ii|>",
|
||||
"<|yi|>",
|
||||
"<|yo|>",
|
||||
"<|za|>",
|
||||
"<|zu|>",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
# Scripts for creation of synthetic code-switched data from monolingual sources
|
||||
Follow the 2 steps listed below in order -
|
||||
|
||||
|
||||
1. Create the (intermediate) manifest file using `code_switching_manifest_creation.py`. It's usage is as follows:
|
||||
|
||||
`python code_switching_manifest_creation.py --manifest_language1 <absolute path of Language 1's manifest file> --manifest_language2 <absolute path of Language 2's manifest file> --manifest_save_path <absolute path to save the created manifest> --id_language1 <language code for language 1 (e.g. en)> --id_language2 <language code for language 2 (e.g. es)> --max_sample_duration_sec <maximum duration of generated sample in seconds> --min_sample_duration_sec <maximum duration of generated sample in seconds> --dataset_size_required_hrs <size of generated synthetic dataset required in hrs>`
|
||||
|
||||
Estimated runtime for dataset_size_required_hrs=10,000 is ~2 mins
|
||||
|
||||
2. Create the synthetic audio data and the corresponding manifest file using `code_switching_audio_data_creation.py` It's usage is as follows:
|
||||
|
||||
`python code_switching_audio_data_creation.py --manifest_path <absolute path to intermediate CS manifest generated in step 1> --audio_save_folder_path <absolute path to directory where you want to save the synthesized audios> --manifest_save_path <absolute path to save the created manifest> --audio_normalized_amplitude <scaled normalized amplitude desired> --cs_data_sampling_rate <desired sampling rate for generated audios> --sample_beginning_pause_msec <pause to be added to the beginning of the generated sample in milli seconds> --sample_joining_pause_msec <pause to be added between segments while joining, in milli seconds> --sample_end_pause_msec <pause to be added to the end of the generated sample in milli seconds> --is_lid_manifest <boolean to create manifest in the multi-sample lid format for the text field, true by default> --workers <number of worker processes>`
|
||||
|
||||
Example of the multi-sample LID format: ```[{“str”:“esta muestra ” “lang”:”es”},{“str”:“was generated synthetically”: “lang”:”en”}]```
|
||||
|
||||
Estimated runtime for generating a 10,000 hour corpus is ~40 hrs with a single worker
|
||||
@@ -0,0 +1,288 @@
|
||||
# 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 argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
from joblib import Parallel, delayed
|
||||
from scipy.io import wavfile
|
||||
from tqdm import tqdm
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
|
||||
parser = argparse.ArgumentParser(description='Create synthetic code-switching data audio data from monolingual data')
|
||||
parser.add_argument("--manifest_path", default=None, type=str, help='Path to CS indermediate manifest', required=True)
|
||||
parser.add_argument(
|
||||
"--audio_save_folder_path",
|
||||
default=None,
|
||||
type=str,
|
||||
help='Path to directory where created synthetic set would be saved',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_save_path", default=None, type=str, help='Path to save the created manifest', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_normalized_amplitude", default=15000, type=int, help='Normalized amplitdue of audio samples'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cs_data_sampling_rate",
|
||||
default=16000,
|
||||
type=int,
|
||||
help='Desired sampling rate for the audios in the generated dataset',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_beginning_pause_msec",
|
||||
default=20,
|
||||
type=int,
|
||||
help='Pause to be added at the beginning of the sample (msec)',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_joining_pause_msec",
|
||||
default=100,
|
||||
type=int,
|
||||
help='Pause to be added between different phrases of the sample (msec)',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_end_pause_msec", default=20, type=int, help='Pause to be added at the end of the sample (msec)'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--is_lid_manifest",
|
||||
default=True,
|
||||
type=bool,
|
||||
help='If true, generate manifest in the multi-sample lid format, else the standard manifest format',
|
||||
)
|
||||
parser.add_argument("--workers", default=1, type=int, help='Number of worker processes')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def split_list(input_list: list, num_splits: int):
|
||||
"""
|
||||
Args:
|
||||
input_list: the input list to split
|
||||
num_splits: number of splits required
|
||||
|
||||
Returns:
|
||||
iterator of split lists
|
||||
|
||||
"""
|
||||
k, m = divmod(len(input_list), num_splits)
|
||||
return (input_list[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(num_splits))
|
||||
|
||||
|
||||
def combine_manifests(manifest_save_path: str, num_split: int):
|
||||
"""
|
||||
Args:
|
||||
manifest_save_path: absolute path to save the combined manifest
|
||||
num_splits: number of splits of manifest
|
||||
|
||||
Returns:
|
||||
num_samples_combined: the total number of samples in the generated dataset
|
||||
"""
|
||||
num_samples_combined = 0
|
||||
base_directory = os.path.dirname(manifest_save_path)
|
||||
|
||||
with open(manifest_save_path, 'w') as outfile:
|
||||
for i in range(num_split):
|
||||
split_manifest_path = base_directory + '/temp_' + str(i) + '.json'
|
||||
data_split = read_manifest(split_manifest_path)
|
||||
|
||||
for elem in data_split:
|
||||
s = json.dumps(elem)
|
||||
outfile.write(s + '\n')
|
||||
num_samples_combined += 1
|
||||
|
||||
# removing the intermediate file
|
||||
os.remove(split_manifest_path)
|
||||
|
||||
return num_samples_combined
|
||||
|
||||
|
||||
def create_cs_data(
|
||||
intermediate_cs_manifest_list: list,
|
||||
audio_save_folder: str,
|
||||
manfest_save_path: str,
|
||||
audio_amplitude_normalization: int,
|
||||
pause_beg_msec: int,
|
||||
pause_join_msec: int,
|
||||
pause_end_msec: int,
|
||||
cs_data_sampling_rate: int,
|
||||
is_lid_manifest: bool,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
intermediate_cs_manifest_list: the intermediate cs manifest obtained from code_switching_manifest_creation.py as a list
|
||||
audio_save_folder: Absolute path to save the generated audio samples
|
||||
manfest_save_path: Absolute path to save the corresponding manifest
|
||||
audio_amplitude_normalization: The amplitude to scale to after normalization
|
||||
pause_beg_msec: Pause to be added at the beginning of the sample (msec)
|
||||
pause_join_msec: Pause to be added between different phrases of the sample (msec)
|
||||
pause_end_msec: Pause to be added at the end of the sample (msec)
|
||||
cs_data_sampling_rate: Desired sampling rate of the generated samples
|
||||
is_lid_manifest: If true, generate manifest in the multi-sample lid format, else the standard manifest format
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
|
||||
fs = cs_data_sampling_rate
|
||||
incorrect_sample_flag = 0
|
||||
|
||||
with open(manfest_save_path, 'w') as outfile:
|
||||
for data in tqdm(intermediate_cs_manifest_list):
|
||||
|
||||
combined_audio = []
|
||||
|
||||
staring_pause = np.zeros(int(pause_beg_msec * fs / 1000))
|
||||
combined_audio += list(staring_pause)
|
||||
|
||||
text_entry_list = []
|
||||
for index in range(len(data['lang_ids'])):
|
||||
|
||||
phrase_entry = {}
|
||||
# dictionary to store the phrase information which will be added to the complete sentence
|
||||
|
||||
data_sample, fs_sample = librosa.load(data['paths'][index], sr=fs)
|
||||
# Alternative- fs_sample, data_sample = wavfile.read(data['paths'][index])
|
||||
|
||||
if fs_sample != fs:
|
||||
logging.error('Sampling rate error inside create_cs_data function')
|
||||
exit
|
||||
|
||||
# Remove leading and trailing zeros
|
||||
data_sample = np.trim_zeros(data_sample)
|
||||
|
||||
# take care of empty arrays: rare
|
||||
if data_sample.size == 0:
|
||||
incorrect_sample_flag = 1
|
||||
continue
|
||||
|
||||
# normalizing data
|
||||
data_sample_norm = (
|
||||
data_sample
|
||||
/ np.maximum(np.abs(data_sample.max()), np.abs(data_sample.min()))
|
||||
* audio_amplitude_normalization
|
||||
)
|
||||
|
||||
combined_audio += list(data_sample_norm)
|
||||
|
||||
phrase_entry['str'] = data['texts'][index]
|
||||
phrase_entry['lang'] = data['lang_ids'][index]
|
||||
|
||||
text_entry_list.append(phrase_entry)
|
||||
|
||||
# adding small pause between semgments
|
||||
if index != (len(data['lang_ids']) - 1):
|
||||
pause = np.zeros(int(pause_join_msec * fs / 1000))
|
||||
combined_audio += list(pause)
|
||||
|
||||
if incorrect_sample_flag == 1:
|
||||
incorrect_sample_flag = 0
|
||||
continue
|
||||
|
||||
ending_pause = np.zeros(int(pause_end_msec * fs / 1000))
|
||||
combined_audio += list(ending_pause)
|
||||
|
||||
sample_id = data['uid']
|
||||
audio_file_path = audio_save_folder + '/' + str(sample_id) + ".wav"
|
||||
|
||||
# saving audio
|
||||
wavfile.write(audio_file_path, fs, np.array(combined_audio).astype(np.int16))
|
||||
# Alternative- librosa.output.write_wav(audio_file_path, combined_audio, fs)
|
||||
|
||||
metadata_json = {}
|
||||
metadata_json['audio_filepath'] = audio_file_path
|
||||
metadata_json['duration'] = float(len(combined_audio) / fs)
|
||||
if is_lid_manifest:
|
||||
metadata_json['text'] = text_entry_list
|
||||
else:
|
||||
metadata_json['text'] = ' '.join(data['texts'])
|
||||
|
||||
metadata_json['language_ids'] = data['lang_ids']
|
||||
metadata_json['original_texts'] = data['texts']
|
||||
metadata_json['original_paths'] = data['paths']
|
||||
metadata_json['original_durations'] = data['durations']
|
||||
|
||||
s = json.dumps(metadata_json)
|
||||
outfile.write(s + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
cs_intermediate_manifest_path = args.manifest_path
|
||||
audio_save_folder = args.audio_save_folder_path
|
||||
manifest_save_path = args.manifest_save_path
|
||||
audio_amplitude_normalization = args.audio_normalized_amplitude
|
||||
pause_beg_msec = args.sample_beginning_pause_msec
|
||||
pause_join_msec = args.sample_joining_pause_msec
|
||||
pause_end_msec = args.sample_end_pause_msec
|
||||
cs_data_sampling_rate = args.cs_data_sampling_rate
|
||||
is_lid_manifest = args.is_lid_manifest
|
||||
num_process = args.workers
|
||||
|
||||
# Sanity Checks
|
||||
if (cs_intermediate_manifest_path is None) or (not os.path.exists(cs_intermediate_manifest_path)):
|
||||
logging.error('Please provide correct CS manifest (obtained from code_switching_manifest_creation.py)')
|
||||
exit
|
||||
|
||||
if (audio_save_folder is None) or (not os.path.exists(audio_save_folder)):
|
||||
logging.error('audio_save_folder_path is incorrect or does not exist')
|
||||
exit
|
||||
|
||||
if manifest_save_path is None:
|
||||
logging.error('Please provide valid manifest_save_path')
|
||||
exit
|
||||
|
||||
# Reading data
|
||||
logging.info('Reading manifests')
|
||||
intermediate_cs_manifest = read_manifest(cs_intermediate_manifest_path)
|
||||
|
||||
# Spliting the data
|
||||
data_split = split_list(intermediate_cs_manifest, num_process)
|
||||
|
||||
# Creating Audio data
|
||||
logging.info('Creating synthetic audio data')
|
||||
base_directory = os.path.dirname(manifest_save_path)
|
||||
|
||||
Parallel(n_jobs=num_process)(
|
||||
delayed(create_cs_data)(
|
||||
split_manifest,
|
||||
audio_save_folder,
|
||||
base_directory + '/temp_' + str(idx) + '.json',
|
||||
audio_amplitude_normalization,
|
||||
pause_beg_msec,
|
||||
pause_join_msec,
|
||||
pause_end_msec,
|
||||
cs_data_sampling_rate,
|
||||
is_lid_manifest,
|
||||
)
|
||||
for idx, split_manifest in enumerate(data_split)
|
||||
)
|
||||
|
||||
# Combining manifests
|
||||
num_samples_combined = combine_manifests(manifest_save_path, num_process)
|
||||
|
||||
print("Synthetic CS audio data saved at :", audio_save_folder)
|
||||
print("Synthetic CS manifest saved at :", manifest_save_path)
|
||||
print("Total number of samples in the generated dataset :", str(num_samples_combined))
|
||||
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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 argparse
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
|
||||
# Checks -
|
||||
# (Recommendation) Please normalize the text for each language (avoid numbers, special characters, punctuation)
|
||||
# Please ensure that the audio_filepaths are absolute locations
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='Create synthetic code-switching data manifest from monolingual data')
|
||||
|
||||
parser.add_argument("--manifest_language1", default=None, type=str, help='Manifest file for language 1', required=True)
|
||||
parser.add_argument("--manifest_language2", default=None, type=str, help='Manifest file for language 2', required=True)
|
||||
parser.add_argument(
|
||||
"--manifest_save_path", default=None, type=str, help='Path to save created CS indermediate manifest', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id_language1", default=None, type=str, help='Identifier for language 1, eg: en, es, hi', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id_language2", default=None, type=str, help='Identifier for language 2, eg: en, es, hi', required=True
|
||||
)
|
||||
parser.add_argument("--max_sample_duration_sec", default=19, type=int, help='Maximum duration of sample (sec)')
|
||||
parser.add_argument("--min_sample_duration_sec", default=16, type=int, help='Minimum duration of sample (sec)')
|
||||
parser.add_argument("--dataset_size_required_hrs", default=1, type=int, help='Duration of dataset required (hrs)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def create_cs_manifest(
|
||||
data_lang_0: list,
|
||||
data_lang_1: list,
|
||||
lid_lang_0: str,
|
||||
lid_lang_1: str,
|
||||
max_sample_duration_sec: int,
|
||||
min_sample_duration_sec: int,
|
||||
data_requirement_hrs: int,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
data_lang_0: Manifest entries from first langauge
|
||||
data_lang_1: Manifest entries from second langauge
|
||||
lid_lang_0: Language ID marker for first langauge
|
||||
lid_lang_1: Language ID marker for second langauge
|
||||
max_sample_duration_sec: Maximum permissible duration of generated CS sample in sec
|
||||
min_sample_duration_sec: Minimum permissible duration of generated CS sample in sec
|
||||
data_requirement_hrs: Required size of generated corpus
|
||||
|
||||
Returns:
|
||||
Created synthetic CS manifest as list
|
||||
|
||||
"""
|
||||
|
||||
total_duration = 0
|
||||
constructed_data = []
|
||||
sample_id = 0
|
||||
|
||||
num_samples_lang0 = len(data_lang_0)
|
||||
num_samples_lang1 = len(data_lang_1)
|
||||
|
||||
while total_duration < (data_requirement_hrs * 3600):
|
||||
|
||||
created_sample_duration_sec = 0
|
||||
created_sample_dict = {}
|
||||
created_sample_dict['lang_ids'] = []
|
||||
created_sample_dict['texts'] = []
|
||||
created_sample_dict['paths'] = []
|
||||
created_sample_dict['durations'] = []
|
||||
|
||||
while created_sample_duration_sec < min_sample_duration_sec:
|
||||
|
||||
lang_selection = random.randint(0, 1)
|
||||
|
||||
if lang_selection == 0:
|
||||
index = random.randint(0, num_samples_lang0 - 1)
|
||||
sample = data_lang_0[index]
|
||||
lang_id = lid_lang_0
|
||||
else:
|
||||
index = random.randint(0, num_samples_lang1 - 1)
|
||||
sample = data_lang_1[index]
|
||||
lang_id = lid_lang_1
|
||||
|
||||
if (created_sample_duration_sec + sample['duration']) > max_sample_duration_sec:
|
||||
continue
|
||||
else:
|
||||
created_sample_duration_sec += sample['duration']
|
||||
created_sample_dict['lang_ids'].append(lang_id)
|
||||
created_sample_dict['texts'].append(sample['text'])
|
||||
created_sample_dict['paths'].append(sample['audio_filepath'])
|
||||
created_sample_dict['durations'].append(sample['duration'])
|
||||
|
||||
created_sample_dict['total_duration'] = created_sample_duration_sec
|
||||
|
||||
# adding a uid which will be used to save the generated audio file later
|
||||
created_sample_dict['uid'] = sample_id
|
||||
sample_id += 1
|
||||
|
||||
constructed_data.append(created_sample_dict)
|
||||
total_duration += created_sample_duration_sec
|
||||
|
||||
return constructed_data
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
manifest0 = args.manifest_language1
|
||||
manifest1 = args.manifest_language2
|
||||
lid0 = args.id_language1
|
||||
lid1 = args.id_language2
|
||||
min_sample_duration = args.min_sample_duration_sec
|
||||
max_sample_duration = args.max_sample_duration_sec
|
||||
dataset_requirement = args.dataset_size_required_hrs
|
||||
manifest_save_path = args.manifest_save_path
|
||||
|
||||
# Sanity Checks
|
||||
if (manifest0 is None) or (not os.path.exists(manifest0)):
|
||||
logging.error('Manifest for language 1 is incorrect')
|
||||
exit
|
||||
|
||||
if (manifest1 is None) or (not os.path.exists(manifest1)):
|
||||
logging.error('Manifest for language 2 is incorrect')
|
||||
exit
|
||||
|
||||
if lid0 is None:
|
||||
logging.error('Please provide correct language code for language 1')
|
||||
exit
|
||||
|
||||
if lid1 is None:
|
||||
logging.error('Please provide correct language code for language 2')
|
||||
exit
|
||||
|
||||
if manifest_save_path is None:
|
||||
logging.error('Please provide correct manifest save path')
|
||||
exit
|
||||
|
||||
if min_sample_duration >= max_sample_duration:
|
||||
logging.error('Please ensure max_sample_duration > min_sample_duration')
|
||||
exit
|
||||
|
||||
# Reading data
|
||||
logging.info('Reading manifests')
|
||||
data_language0 = read_manifest(manifest0)
|
||||
data_language1 = read_manifest(manifest1)
|
||||
|
||||
# Creating the CS data Manifest
|
||||
logging.info('Creating CS manifest')
|
||||
constructed_data = create_cs_manifest(
|
||||
data_language0, data_language1, lid0, lid1, max_sample_duration, min_sample_duration, dataset_requirement
|
||||
)
|
||||
|
||||
# Saving Manifest
|
||||
logging.info('saving manifest')
|
||||
write_manifest(manifest_save_path, constructed_data)
|
||||
|
||||
print("Synthetic CS manifest saved at :", manifest_save_path)
|
||||
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,308 @@
|
||||
# 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
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
|
||||
from nemo.collections.asr.models import ASRModel, EncDecRNNTModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_benchmarking_utils import (
|
||||
apply_confidence_parameters,
|
||||
run_confidence_benchmark,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging, model_utils
|
||||
|
||||
"""
|
||||
Get confidence metrics and curve plots for a given model, dataset, and confidence parameters.
|
||||
|
||||
# Arguments
|
||||
model_path: Path to .nemo ASR checkpoint
|
||||
pretrained_name: Name of pretrained ASR model (from NGC registry)
|
||||
dataset_manifest: Path to dataset JSON manifest file (in NeMo format)
|
||||
output_dir: Output directory to store a report and curve plot directories
|
||||
|
||||
batch_size: batch size during inference
|
||||
num_workers: number of workers during inference
|
||||
|
||||
cuda: Optional int to enable or disable execution of model on certain CUDA device
|
||||
amp: Bool to decide if Automatic Mixed Precision should be used during inference
|
||||
audio_type: Str filetype of the audio. Supported = wav, flac, mp3
|
||||
|
||||
target_level: Word- or token-level confidence. Supported = word, token, auto (for computing both word and token)
|
||||
confidence_cfg: Config with confidence parameters
|
||||
grid_params: Dictionary with lists of parameters to iteratively benchmark on
|
||||
|
||||
# Usage
|
||||
ASR model can be specified by either "model_path" or "pretrained_name".
|
||||
Data for transcription are defined with "dataset_manifest".
|
||||
Results are returned as a benchmark report and curve plots.
|
||||
|
||||
python benchmark_asr_confidence.py \
|
||||
model_path=null \
|
||||
pretrained_name=null \
|
||||
dataset_manifest="" \
|
||||
output_dir="" \
|
||||
batch_size=64 \
|
||||
num_workers=8 \
|
||||
cuda=0 \
|
||||
amp=True \
|
||||
target_level="word" \
|
||||
confidence_cfg.exclude_blank=False \
|
||||
'grid_params="{\"aggregation\": [\"min\", \"prod\"], \"alpha\": [0.33, 0.5]}"'
|
||||
"""
|
||||
|
||||
|
||||
def get_experiment_params(cfg):
|
||||
"""Get experiment parameters from a confidence config and generate the experiment name.
|
||||
|
||||
Returns:
|
||||
List of experiment parameters.
|
||||
String with the experiment name.
|
||||
"""
|
||||
blank = "no_blank" if cfg.exclude_blank else "blank"
|
||||
duration = "duration" if cfg.tdt_include_duration else "no_duration"
|
||||
aggregation = cfg.aggregation
|
||||
method_name = cfg.method_cfg.name
|
||||
alpha = cfg.method_cfg.alpha
|
||||
if method_name == "entropy":
|
||||
entropy_type = cfg.method_cfg.entropy_type
|
||||
entropy_norm = cfg.method_cfg.entropy_norm
|
||||
experiment_param_list = [
|
||||
aggregation,
|
||||
str(cfg.exclude_blank),
|
||||
str(cfg.tdt_include_duration),
|
||||
method_name,
|
||||
entropy_type,
|
||||
entropy_norm,
|
||||
str(alpha),
|
||||
]
|
||||
experiment_str = "-".join([aggregation, blank, duration, method_name, entropy_type, entropy_norm, str(alpha)])
|
||||
else:
|
||||
experiment_param_list = [
|
||||
aggregation,
|
||||
str(cfg.exclude_blank),
|
||||
str(cfg.tdt_include_duration),
|
||||
method_name,
|
||||
"-",
|
||||
"-",
|
||||
str(alpha),
|
||||
]
|
||||
experiment_str = "-".join([aggregation, blank, duration, method_name, str(alpha)])
|
||||
return experiment_param_list, experiment_str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfidenceBenchmarkingConfig:
|
||||
# Required configs
|
||||
model_path: Optional[str] = None # Path to a .nemo file
|
||||
pretrained_name: Optional[str] = None # Name of a pretrained model
|
||||
dataset_manifest: str = MISSING
|
||||
output_dir: str = MISSING
|
||||
|
||||
# General configs
|
||||
batch_size: int = 32
|
||||
num_workers: int = 4
|
||||
|
||||
# Set `cuda` to int to define CUDA device. If 'None', will look for CUDA
|
||||
# device anyway, and do inference on CPU only if CUDA device is not found.
|
||||
# If `cuda` is a negative number, inference will be on CPU only.
|
||||
cuda: Optional[int] = None
|
||||
amp: bool = False
|
||||
audio_type: str = "wav"
|
||||
|
||||
# Confidence configs
|
||||
target_level: str = "auto" # Choices: "word", "token", "auto" (for both word- and token-level confidence)
|
||||
confidence_cfg: ConfidenceConfig = field(
|
||||
default_factory=lambda: ConfidenceConfig(preserve_word_confidence=True, preserve_token_confidence=True)
|
||||
)
|
||||
grid_params: Optional[str] = None # a dictionary with lists of parameters to iteratively benchmark on
|
||||
|
||||
|
||||
@hydra_runner(config_name="ConfidenceBenchmarkingConfig", schema=ConfidenceBenchmarkingConfig)
|
||||
def main(cfg: ConfidenceBenchmarkingConfig):
|
||||
torch.set_grad_enabled(False)
|
||||
|
||||
logging.info(f'Hydra config: {OmegaConf.to_yaml(cfg)}')
|
||||
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg)
|
||||
|
||||
if cfg.model_path is None and cfg.pretrained_name is None:
|
||||
raise ValueError("Both cfg.model_path and cfg.pretrained_name cannot be None!")
|
||||
|
||||
# setup GPU
|
||||
if cfg.cuda is None:
|
||||
if torch.cuda.is_available():
|
||||
device = [0] # use 0th CUDA device
|
||||
accelerator = 'gpu'
|
||||
else:
|
||||
device = 1
|
||||
accelerator = 'cpu'
|
||||
else:
|
||||
device = [cfg.cuda]
|
||||
accelerator = 'gpu'
|
||||
|
||||
map_location = torch.device('cuda:{}'.format(device[0]) if accelerator == 'gpu' else 'cpu')
|
||||
|
||||
# setup model
|
||||
if cfg.model_path is not None:
|
||||
# restore model from .nemo file path
|
||||
model_cfg = ASRModel.restore_from(restore_path=cfg.model_path, return_config=True)
|
||||
classpath = model_cfg.target # original class path
|
||||
imported_class = model_utils.import_class_by_path(classpath) # type: ASRModel
|
||||
logging.info(f"Restoring model : {imported_class.__name__}")
|
||||
asr_model = imported_class.restore_from(
|
||||
restore_path=cfg.model_path, map_location=map_location
|
||||
) # type: ASRModel
|
||||
else:
|
||||
# restore model by name
|
||||
asr_model = ASRModel.from_pretrained(
|
||||
model_name=cfg.pretrained_name, map_location=map_location
|
||||
) # type: ASRModel
|
||||
|
||||
trainer = pl.Trainer(devices=device, accelerator=accelerator)
|
||||
asr_model.set_trainer(trainer)
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
# Check if ctc or rnnt model
|
||||
is_rnnt = isinstance(asr_model, EncDecRNNTModel)
|
||||
|
||||
# Check that the model has the `change_decoding_strategy` method
|
||||
if not hasattr(asr_model, 'change_decoding_strategy'):
|
||||
raise RuntimeError("The asr_model you are using must have the `change_decoding_strategy` method.")
|
||||
|
||||
# get filenames and reference texts from manifest
|
||||
filepaths = []
|
||||
reference_texts = []
|
||||
if os.stat(cfg.dataset_manifest).st_size == 0:
|
||||
logging.error(f"The input dataset_manifest {cfg.dataset_manifest} is empty. Exiting!")
|
||||
return None
|
||||
manifest_dir = Path(cfg.dataset_manifest).parent
|
||||
with open(cfg.dataset_manifest, 'r') as f:
|
||||
for line in f:
|
||||
item = json.loads(line)
|
||||
audio_file = Path(item['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
filepaths.append(str(audio_file.absolute()))
|
||||
reference_texts.append(item['text'])
|
||||
|
||||
# do grid-based benchmarking if grid_params is provided, otherwise a regular one
|
||||
work_dir = Path(cfg.output_dir)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
report_legend = (
|
||||
",".join(
|
||||
[
|
||||
"model_type",
|
||||
"aggregation",
|
||||
"blank",
|
||||
"duration",
|
||||
"method_name",
|
||||
"entropy_type",
|
||||
"entropy_norm",
|
||||
"alpha",
|
||||
"target_level",
|
||||
"auc_roc",
|
||||
"auc_pr",
|
||||
"auc_nt",
|
||||
"nce",
|
||||
"ece",
|
||||
"auc_yc",
|
||||
"std_yc",
|
||||
"max_yc",
|
||||
]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
model_typename = "RNNT" if is_rnnt else "CTC"
|
||||
report_file = work_dir / Path("report.csv")
|
||||
if cfg.grid_params:
|
||||
asr_model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(fused_batch_size=-1, strategy="greedy_batch", confidence_cfg=cfg.confidence_cfg)
|
||||
if is_rnnt
|
||||
else CTCDecodingConfig(confidence_cfg=cfg.confidence_cfg)
|
||||
)
|
||||
params = json.loads(cfg.grid_params)
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
logging.info(f"==============================Running a benchmarking with grid search=========================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"Results will be written to:\nreport file `{report_file}`\nand plot directories near the file")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
with open(report_file, "tw", encoding="utf-8") as f:
|
||||
f.write(report_legend)
|
||||
f.flush()
|
||||
for i, hp in enumerate(hp_grid):
|
||||
logging.info(f"Run # {i + 1}, grid: `{hp}`")
|
||||
asr_model.change_decoding_strategy(apply_confidence_parameters(asr_model.cfg.decoding, hp))
|
||||
param_list, experiment_name = get_experiment_params(asr_model.cfg.decoding.confidence_cfg)
|
||||
plot_dir = work_dir / Path(experiment_name)
|
||||
results = run_confidence_benchmark(
|
||||
asr_model,
|
||||
cfg.target_level,
|
||||
filepaths,
|
||||
reference_texts,
|
||||
cfg.batch_size,
|
||||
cfg.num_workers,
|
||||
plot_dir,
|
||||
cfg.amp,
|
||||
)
|
||||
for level, result in results.items():
|
||||
f.write(f"{model_typename},{','.join(param_list)},{level},{','.join([str(r) for r in result])}\n")
|
||||
f.flush()
|
||||
else:
|
||||
asr_model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(fused_batch_size=-1, strategy="greedy_batch", confidence_cfg=cfg.confidence_cfg)
|
||||
if is_rnnt
|
||||
else CTCDecodingConfig(confidence_cfg=cfg.confidence_cfg)
|
||||
)
|
||||
param_list, experiment_name = get_experiment_params(asr_model.cfg.decoding.confidence_cfg)
|
||||
plot_dir = work_dir / Path(experiment_name)
|
||||
|
||||
logging.info(f"==============================Running a single benchmarking===================================")
|
||||
logging.info(f"Results will be written to:\nreport file `{report_file}`\nand plot directory `{plot_dir}`")
|
||||
|
||||
with open(report_file, "tw", encoding="utf-8") as f:
|
||||
f.write(report_legend)
|
||||
f.flush()
|
||||
results = run_confidence_benchmark(
|
||||
asr_model,
|
||||
cfg.batch_size,
|
||||
cfg.num_workers,
|
||||
cfg.target_level,
|
||||
filepaths,
|
||||
reference_texts,
|
||||
plot_dir,
|
||||
cfg.amp,
|
||||
)
|
||||
for level, result in results.items():
|
||||
f.write(f"{model_typename},{','.join(param_list)},{level},{','.join([str(r) for r in result])}\n")
|
||||
logging.info(f"===========================================Done===============================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,413 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Python wrapper over HuggingFace Datasets to create preprocessed NeMo ASR Datasets.
|
||||
|
||||
List of HuggingFace datasets : https://huggingface.co/datasets
|
||||
(Please filter by task: automatic-speech-recognition)
|
||||
|
||||
# Setup
|
||||
After installation of huggingface datasets (pip install datasets), some datasets might require authentication
|
||||
- for example Mozilla Common Voice. You should go to the above link, register as a user and generate an API key.
|
||||
|
||||
## Authenticated Setup Steps
|
||||
|
||||
Website steps:
|
||||
- Visit https://huggingface.co/settings/profile
|
||||
- Visit "Access Tokens" on list of items.
|
||||
- Create new token - provide a name for the token and "read" access is sufficient.
|
||||
- PRESERVE THAT TOKEN API KEY. You can copy that key for next step.
|
||||
- Visit the HuggingFace Dataset page for Mozilla Common Voice
|
||||
- There should be a section that asks you for your approval.
|
||||
- Make sure you are logged in and then read that agreement.
|
||||
- If and only if you agree to the text, then accept the terms.
|
||||
|
||||
Code steps:
|
||||
- Now on your machine, run `huggingface-cli login`
|
||||
- Paste your preserved HF TOKEN API KEY (from above).
|
||||
|
||||
Now you should be logged in. When running the script, dont forget to set `use_auth_token=True` !
|
||||
|
||||
# Usage
|
||||
The script supports two modes, but the offline mode is the preferred mechanism. The drawback of the offline mode
|
||||
is that it requires 3 copies of the dataset to exist simultanously -
|
||||
|
||||
1) The .arrow files for HF cache
|
||||
2) The extracted dataset in HF cache
|
||||
3) The preprocessed audio files preserved in the output_dir provided in the script.
|
||||
|
||||
Due to this, make sure your HDD is large enough to store the processed dataset !
|
||||
|
||||
## Usage - Offline Mode
|
||||
|
||||
python convert_hf_dataset_to_nemo.py \
|
||||
output_dir=<Path to some storage drive that will hold preprocessed audio files> \
|
||||
path=<`path` argument in HF datasets, cannot be null> \
|
||||
name=<`name` argument in HF datasets, can be null> \
|
||||
split=<`split` argument in HF datasets, can be null> \
|
||||
use_auth_token=<Can be `True` or `False` depending on whether the dataset requires authentication>
|
||||
|
||||
This will create an output directory of multiple sub-folders containing the preprocessed .wav files,
|
||||
along with a nemo compatible JSON manifest file.
|
||||
|
||||
NOTE:
|
||||
The JSON manifest itself is not preprocessed ! You should perform text normalization, and cleanup
|
||||
inconsistent text by using NeMo Text Normalization tool and Speech Data Explorer toolkit !
|
||||
|
||||
## Usage - Streaming Mode
|
||||
|
||||
NOTE:
|
||||
This mode is not well supported. It trades of speed for storage by only having one copy of the dataset in
|
||||
output_dir, however the speed of processing is around 10x slower than offline mode. Some datasets (such as MCV)
|
||||
fail to run entirely.
|
||||
|
||||
DO NOT USE if you have sufficient disk space.
|
||||
|
||||
python convert_hf_dataset_to_nemo.py \
|
||||
... all the arguments from above \
|
||||
streaming=True
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from typing import Optional
|
||||
|
||||
import hydra
|
||||
import librosa
|
||||
import soundfile
|
||||
import tqdm
|
||||
from datasets import Audio, Dataset, IterableDataset, load_dataset
|
||||
from hydra.conf import HydraConf, RunDir
|
||||
from hydra.core.config_store import ConfigStore
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
|
||||
@dataclass
|
||||
class HFDatasetConversionConfig:
|
||||
# Nemo Dataset info
|
||||
output_dir: str # path to output directory where the files will be saved
|
||||
|
||||
# HF Dataset info
|
||||
path: str # HF dataset path
|
||||
name: Optional[str] = None # name of the dataset subset
|
||||
split: Optional[str] = None # split of the dataset subset
|
||||
use_auth_token: bool = False # whether authentication token should be passed or not (Required for MCV)
|
||||
|
||||
# NeMo dataset conversion
|
||||
sampling_rate: int = 16000
|
||||
streaming: bool = False # Whether to use Streaming dataset API. [NOT RECOMMENDED]
|
||||
num_proc: int = -1
|
||||
ensure_ascii: bool = True # When saving the JSON entry, whether to ensure ascii.
|
||||
|
||||
# Placeholders. Generated internally.
|
||||
resolved_output_dir: str = ''
|
||||
split_output_dir: Optional[str] = None
|
||||
|
||||
hydra: HydraConf = field(default_factory=lambda: HydraConf(run=RunDir(dir=".")))
|
||||
|
||||
|
||||
def prepare_output_dirs(cfg: HFDatasetConversionConfig):
|
||||
"""
|
||||
Prepare output directories and subfolders as needed.
|
||||
Also prepare the arguments of the config with these directories.
|
||||
"""
|
||||
output_dir = os.path.abspath(cfg.output_dir)
|
||||
output_dir = os.path.join(output_dir, cfg.path)
|
||||
|
||||
if cfg.name is not None:
|
||||
output_dir = os.path.join(output_dir, cfg.name)
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
cfg.resolved_output_dir = output_dir
|
||||
cfg.split_output_dir = None
|
||||
|
||||
|
||||
def infer_dataset_segments(batch):
|
||||
"""
|
||||
Helper method to run in batch mode over a mapped Dataset.
|
||||
|
||||
Infers the path of the subdirectories for the dataset, removing {extracted/HASH}.
|
||||
|
||||
Returns:
|
||||
A cleaned list of path segments
|
||||
"""
|
||||
segments = []
|
||||
segment, path = os.path.split(batch['audio']['path'])
|
||||
segments.insert(0, path)
|
||||
while segment not in ('', os.path.sep):
|
||||
segment, path = os.path.split(segment)
|
||||
segments.insert(0, path)
|
||||
|
||||
if 'extracted' in segments:
|
||||
index_of_basedir = segments.index("extracted")
|
||||
segments = segments[(index_of_basedir + 1 + 1) :] # skip .../extracted/{hash}/
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def prepare_audio_filepath(audio_filepath):
|
||||
"""
|
||||
Helper method to run in batch mode over a mapped Dataset.
|
||||
|
||||
Prepares the audio filepath and its subdirectories. Remaps the extension to .wav file.
|
||||
|
||||
Args:
|
||||
audio_filepath: String path to the audio file.
|
||||
|
||||
Returns:
|
||||
Cleaned filepath renamed to be a wav file.
|
||||
"""
|
||||
audio_basefilepath = os.path.split(audio_filepath)[0]
|
||||
if not os.path.exists(audio_basefilepath):
|
||||
os.makedirs(audio_basefilepath, exist_ok=True)
|
||||
|
||||
# Remove temporary fmt file
|
||||
if os.path.exists(audio_filepath):
|
||||
os.remove(audio_filepath)
|
||||
|
||||
# replace any ext with .wav
|
||||
audio_filepath, ext = os.path.splitext(audio_filepath)
|
||||
audio_filepath = audio_filepath + '.wav'
|
||||
|
||||
# Remove previous run file
|
||||
if os.path.exists(audio_filepath):
|
||||
os.remove(audio_filepath)
|
||||
return audio_filepath
|
||||
|
||||
|
||||
def build_map_dataset_to_nemo_func(cfg: HFDatasetConversionConfig, basedir):
|
||||
"""
|
||||
Helper method to run in batch mode over a mapped Dataset.
|
||||
|
||||
Creates a function that can be passed to Dataset.map() containing the config and basedir.
|
||||
Useful to map a HF dataset to NeMo compatible format in an efficient way for offline processing.
|
||||
|
||||
Returns:
|
||||
A function pointer which can be used for Dataset.map()
|
||||
"""
|
||||
|
||||
def map_dataset_to_nemo(batch):
|
||||
# Write audio file to correct path
|
||||
if cfg.streaming:
|
||||
batch['audio_filepath'] = batch['audio']['path'].split("::")[0].replace("zip://", "")
|
||||
else:
|
||||
segments = infer_dataset_segments(batch)
|
||||
audio_filepath = os.path.join(*segments)
|
||||
batch['audio_filepath'] = audio_filepath
|
||||
|
||||
batch['audio_filepath'] = os.path.abspath(os.path.join(basedir, batch['audio_filepath']))
|
||||
audio_filepath = batch['audio_filepath']
|
||||
audio_filepath = prepare_audio_filepath(audio_filepath)
|
||||
batch['audio_filepath'] = audio_filepath # update filepath with prepared path
|
||||
|
||||
soundfile.write(audio_filepath, batch['audio']['array'], samplerate=cfg.sampling_rate, format='wav')
|
||||
|
||||
batch['duration'] = librosa.get_duration(y=batch['audio']['array'], sr=batch['audio']['sampling_rate'])
|
||||
return batch
|
||||
|
||||
return map_dataset_to_nemo
|
||||
|
||||
|
||||
def convert_offline_dataset_to_nemo(
|
||||
dataset: Dataset,
|
||||
cfg: HFDatasetConversionConfig,
|
||||
basedir: str,
|
||||
manifest_filepath: str,
|
||||
):
|
||||
"""
|
||||
Converts a HF dataset to a audio-preprocessed Nemo dataset in Offline mode.
|
||||
Also writes out a nemo compatible manifest file.
|
||||
|
||||
Args:
|
||||
dataset: Iterable HF Dataset.
|
||||
cfg: HFDatasetConvertionConfig.
|
||||
basedir: Base output directory.
|
||||
manifest_filepath: Filepath of manifest.
|
||||
"""
|
||||
num_proc = cfg.num_proc
|
||||
if num_proc < 0:
|
||||
num_proc = max(1, os.cpu_count() // 2)
|
||||
|
||||
dataset = dataset.map(build_map_dataset_to_nemo_func(cfg, basedir), num_proc=num_proc)
|
||||
ds_iter = iter(dataset)
|
||||
|
||||
with open(manifest_filepath, 'w') as manifest_f:
|
||||
for idx, sample in enumerate(
|
||||
tqdm.tqdm(
|
||||
ds_iter, desc=f'Processing {cfg.path} (split : {cfg.split}):', total=len(dataset), unit=' samples'
|
||||
)
|
||||
):
|
||||
# remove large components from sample
|
||||
del sample['audio']
|
||||
if 'file' in sample:
|
||||
del sample['file']
|
||||
manifest_f.write(f"{json.dumps(sample, ensure_ascii=cfg.ensure_ascii)}\n")
|
||||
|
||||
|
||||
def convert_streaming_dataset_to_nemo(
|
||||
dataset: IterableDataset, cfg: HFDatasetConversionConfig, basedir: str, manifest_filepath: str
|
||||
):
|
||||
"""
|
||||
Converts a HF dataset to a audio-preprocessed Nemo dataset in Streaming mode.
|
||||
Also writes out a nemo compatible manifest file.
|
||||
|
||||
Args:
|
||||
dataset: Iterable HF Dataset.
|
||||
cfg: HFDatasetConvertionConfig.
|
||||
basedir: Base output directory.
|
||||
manifest_filepath: Filepath of manifest.
|
||||
"""
|
||||
# Disable until fix https://github.com/huggingface/datasets/pull/3556 is merged
|
||||
# dataset = dataset.map(build_map_dataset_to_nemo_func(cfg, basedir))
|
||||
|
||||
ds_iter = iter(dataset)
|
||||
|
||||
with open(manifest_filepath, 'w') as manifest_f:
|
||||
for idx, sample in enumerate(
|
||||
tqdm.tqdm(ds_iter, desc=f'Processing {cfg.path} (split: {cfg.split}):', unit=' samples')
|
||||
):
|
||||
|
||||
audio_filepath = sample['audio']['path'].split("::")[0].replace("zip://", "")
|
||||
audio_filepath = os.path.abspath(os.path.join(basedir, audio_filepath))
|
||||
audio_filepath = prepare_audio_filepath(audio_filepath)
|
||||
|
||||
soundfile.write(audio_filepath, sample['audio']['array'], samplerate=cfg.sampling_rate, format='wav')
|
||||
|
||||
manifest_line = {
|
||||
'audio_filepath': audio_filepath,
|
||||
'text': sample['text'],
|
||||
'duration': librosa.get_duration(sample['audio']['array'], sr=cfg.sampling_rate),
|
||||
}
|
||||
|
||||
# remove large components from sample
|
||||
del sample['audio']
|
||||
del sample['text']
|
||||
if 'file' in sample:
|
||||
del sample['file']
|
||||
|
||||
manifest_line.update(sample)
|
||||
|
||||
manifest_f.write(f"{json.dumps(sample, ensure_ascii=cfg.ensure_ascii)}\n")
|
||||
|
||||
|
||||
def process_dataset(dataset: IterableDataset, cfg: HFDatasetConversionConfig):
|
||||
"""
|
||||
Top level method that processes a given IterableDataset to Nemo compatible dataset.
|
||||
It also writes out a nemo compatible manifest file.
|
||||
|
||||
Args:
|
||||
dataset: HF Dataset.
|
||||
cfg: HFDatasetConvertionConfig
|
||||
"""
|
||||
dataset = dataset.cast_column("audio", Audio(cfg.sampling_rate, mono=True))
|
||||
|
||||
# for Common Voice, "sentence" is used instead of "text" to store the transcript.
|
||||
if 'sentence' in dataset.features:
|
||||
dataset = dataset.rename_column("sentence", "text")
|
||||
|
||||
if cfg.split_output_dir is None:
|
||||
basedir = cfg.resolved_output_dir
|
||||
manifest_filename = f"{cfg.path.replace('/', '_')}_manifest.json"
|
||||
else:
|
||||
basedir = cfg.split_output_dir
|
||||
split = os.path.split(cfg.split_output_dir)[-1]
|
||||
manifest_filename = f"{split}_{cfg.path.replace('/', '_')}_manifest.json"
|
||||
|
||||
if not os.path.exists(cfg.split_output_dir):
|
||||
os.makedirs(cfg.split_output_dir, exist_ok=True)
|
||||
|
||||
cfg.split = split
|
||||
|
||||
manifest_filepath = os.path.abspath(os.path.join(basedir, manifest_filename))
|
||||
|
||||
if cfg.streaming:
|
||||
convert_streaming_dataset_to_nemo(dataset, cfg, basedir=basedir, manifest_filepath=manifest_filepath)
|
||||
else:
|
||||
convert_offline_dataset_to_nemo(dataset, cfg, basedir=basedir, manifest_filepath=manifest_filepath)
|
||||
|
||||
print()
|
||||
print("Dataset conversion finished !")
|
||||
|
||||
|
||||
@hydra.main(config_name='hfds_config', config_path=None)
|
||||
def main(cfg: HFDatasetConversionConfig):
|
||||
# Convert dataclass to omegaconf
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg)
|
||||
|
||||
# Prepare output subdirs
|
||||
prepare_output_dirs(cfg)
|
||||
|
||||
# Load dataset in offline/streaming mode
|
||||
dataset = None
|
||||
try:
|
||||
dataset = load_dataset(
|
||||
path=cfg.path,
|
||||
name=cfg.name,
|
||||
split=cfg.split,
|
||||
cache_dir=None,
|
||||
streaming=cfg.streaming,
|
||||
token=cfg.use_auth_token,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
"HuggingFace datasets failed due to some reason (stack trace below). \nFor certain datasets (eg: MCV), "
|
||||
"it may be necessary to login to the huggingface-cli (via `huggingface-cli login`).\n"
|
||||
"Once logged in, you need to set `use_auth_token=True` when calling this script.\n\n"
|
||||
"Traceback error for reference :\n"
|
||||
)
|
||||
print(traceback.format_exc())
|
||||
exit(1)
|
||||
|
||||
# Multiple datasets were provided at once, process them one by one into subdirs.
|
||||
if isinstance(dataset, dict):
|
||||
print()
|
||||
print("Multiple splits found for dataset", cfg.path, ":", list(dataset.keys()))
|
||||
|
||||
keys = list(dataset.keys())
|
||||
for key in keys:
|
||||
ds_split = dataset[key]
|
||||
print(f"Processing split {key} for dataset {cfg.path}")
|
||||
|
||||
cfg.split_output_dir = os.path.join(cfg.resolved_output_dir, key)
|
||||
process_dataset(ds_split, cfg)
|
||||
|
||||
del dataset[key], ds_split
|
||||
|
||||
# reset the split output directory
|
||||
cfg.split_output_dir = None
|
||||
|
||||
else:
|
||||
# Single dataset was found, process into resolved directory.
|
||||
print("Single split found for dataset", cfg.path, "| Split chosen =", cfg.split)
|
||||
|
||||
if cfg.split is not None:
|
||||
cfg.split_output_dir = os.path.join(cfg.resolved_output_dir, cfg.split)
|
||||
|
||||
process_dataset(dataset, cfg)
|
||||
|
||||
|
||||
# Register the dataclass as a valid config
|
||||
ConfigStore.instance().store(name='hfds_config', node=HFDatasetConversionConfig)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Convert .nemo checkpoints that were trained with ``preprocessor.use_torchaudio=True``
|
||||
to the current format (non-torchaudio FilterbankFeatures).
|
||||
|
||||
After torchaudio was removed as a dependency (PR #15211), models trained with the
|
||||
torchaudio-based preprocessor (FilterbankFeaturesTA) fail to load because the
|
||||
state dict keys no longer match:
|
||||
|
||||
Old (torchaudio):
|
||||
preprocessor.featurizer._mel_spec_extractor.spectrogram.window
|
||||
preprocessor.featurizer._mel_spec_extractor.mel_scale.fb
|
||||
|
||||
New (current):
|
||||
preprocessor.featurizer.window
|
||||
preprocessor.featurizer.fb
|
||||
|
||||
This script renames those keys and also sets ``use_torchaudio: false`` in the model
|
||||
config so that the correct featurizer class is instantiated on load.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python convert_torchaudio_nemo.py --nemo_file model.nemo --output_file model_converted.nemo
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import torch
|
||||
import yaml
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
|
||||
MODEL_CONFIG_YAML = "model_config.yaml"
|
||||
MODEL_WEIGHTS_CKPT = "model_weights.ckpt"
|
||||
|
||||
# Old torchaudio key suffix -> new key suffix
|
||||
KEY_MIGRATION = {
|
||||
"featurizer._mel_spec_extractor.spectrogram.window": "featurizer.window",
|
||||
"featurizer._mel_spec_extractor.mel_scale.fb": "featurizer.fb",
|
||||
}
|
||||
|
||||
|
||||
def migrate_state_dict(state_dict: dict) -> tuple[dict, list[tuple[str, str]]]:
|
||||
"""Rename torchaudio-era keys. Returns (new_state_dict, list of (old, new) renames)."""
|
||||
renames = []
|
||||
for key in list(state_dict.keys()):
|
||||
for old_suffix, new_suffix in KEY_MIGRATION.items():
|
||||
if key.endswith(old_suffix):
|
||||
new_key = key[: -len(old_suffix)] + new_suffix
|
||||
if "featurizer.fb" in new_suffix:
|
||||
state_dict[new_key] = state_dict.pop(key).T.unsqueeze(0)
|
||||
else:
|
||||
state_dict[new_key] = state_dict.pop(key)
|
||||
renames.append((key, new_key))
|
||||
break
|
||||
return state_dict, renames
|
||||
|
||||
|
||||
def migrate_config(cfg: dict) -> bool:
|
||||
"""Set ``use_torchaudio: false`` in the preprocessor config. Returns True if changed."""
|
||||
preprocessor = cfg.get("preprocessor", {})
|
||||
if preprocessor.get("use_torchaudio", False):
|
||||
preprocessor["use_torchaudio"] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def convert_nemo_file(nemo_path: str, output_path: str) -> None:
|
||||
"""Extract, migrate, and repack a .nemo archive."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# --- Unpack --------------------------------------------------------
|
||||
# Older checkpoints may be gzipped; newer ones are plain tar.
|
||||
try:
|
||||
tar = tarfile.open(nemo_path, "r:")
|
||||
except tarfile.ReadError:
|
||||
tar = tarfile.open(nemo_path, "r:gz")
|
||||
with tar:
|
||||
safe_extract(tar, tmpdir)
|
||||
|
||||
# --- Migrate state dict --------------------------------------------
|
||||
weights_path = os.path.join(tmpdir, MODEL_WEIGHTS_CKPT)
|
||||
if not os.path.isfile(weights_path):
|
||||
raise FileNotFoundError(
|
||||
f"Could not find {MODEL_WEIGHTS_CKPT} inside the .nemo archive. "
|
||||
"Are you sure this is a valid .nemo file?"
|
||||
)
|
||||
|
||||
state_dict = torch.load(weights_path, map_location="cpu", weights_only=True)
|
||||
state_dict, renames = migrate_state_dict(state_dict)
|
||||
if not renames:
|
||||
print("No torchaudio keys found in state dict — nothing to migrate.")
|
||||
return
|
||||
|
||||
for old, new in renames:
|
||||
print(f" Renamed: {old} -> {new}")
|
||||
|
||||
torch.save(state_dict, weights_path)
|
||||
|
||||
# --- Migrate config ------------------------------------------------
|
||||
config_path = os.path.join(tmpdir, MODEL_CONFIG_YAML)
|
||||
if os.path.isfile(config_path):
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
if migrate_config(cfg):
|
||||
print(" Config: set use_torchaudio=false")
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False)
|
||||
|
||||
# --- Repack --------------------------------------------------------
|
||||
with tarfile.open(output_path, "w:") as tar:
|
||||
tar.add(tmpdir, arcname=".")
|
||||
|
||||
print(f"\nConverted checkpoint saved to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert .nemo checkpoints from torchaudio preprocessor format to the current format.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nemo_file",
|
||||
required=True,
|
||||
help="Path to the source .nemo file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_file",
|
||||
required=True,
|
||||
help="Path to write the converted .nemo file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isfile(args.nemo_file):
|
||||
raise FileNotFoundError(f"File not found: {args.nemo_file}")
|
||||
|
||||
convert_nemo_file(args.nemo_file, args.output_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 glob
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import hydra
|
||||
from hydra.core.config_store import ConfigStore
|
||||
from joblib import Parallel, delayed
|
||||
from omegaconf import MISSING
|
||||
|
||||
try:
|
||||
from wds2idx import IndexCreator
|
||||
|
||||
INDEX_CREATOR_AVAILABLE = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
INDEX_CREATOR_AVAILABLE = False
|
||||
|
||||
"""
|
||||
python create_dali_tarred_dataset_index.py \
|
||||
tar_dir=<path to the directory which contains tarred dataset> \
|
||||
workers=-1
|
||||
|
||||
"""
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DALITarredIndexConfig:
|
||||
tar_dir: str = MISSING # Path to the existing dataset's manifest
|
||||
workers: int = -1 # number of worker processes
|
||||
|
||||
|
||||
def process_index_path(tar_paths, index_dir):
|
||||
"""
|
||||
Appends the folder `{index_dir}` to the filepath of all tarfiles.
|
||||
Example:
|
||||
/X/Y/Z/audio_0.tar -> /X/Y/Z/{index_dir}/audio_0.index
|
||||
"""
|
||||
index_paths = []
|
||||
for path in tar_paths:
|
||||
basepath, filename = os.path.split(path)
|
||||
path = filename.replace('.tar', '.index')
|
||||
path = os.path.join(basepath, path)
|
||||
base, name = os.path.split(path)
|
||||
index_path = os.path.join(index_dir, name)
|
||||
index_paths.append(index_path)
|
||||
|
||||
return index_paths
|
||||
|
||||
|
||||
def build_index(tarpath, indexfile):
|
||||
with IndexCreator(tarpath, indexfile) as index:
|
||||
index.create_index()
|
||||
|
||||
|
||||
@hydra.main(config_path=None, config_name='index_config', version_base="1.1")
|
||||
def main(cfg: DALITarredIndexConfig):
|
||||
if not INDEX_CREATOR_AVAILABLE:
|
||||
logging.error("`wds2idx` is not installed. Please install NVIDIA DALI >= 1.11")
|
||||
exit(1)
|
||||
|
||||
tar_files = list(glob.glob(os.path.join(cfg.tar_dir, "*.tar")))
|
||||
|
||||
index_dir = os.path.join(cfg.tar_dir, "dali_index")
|
||||
if not os.path.exists(index_dir):
|
||||
os.makedirs(index_dir, exist_ok=True)
|
||||
|
||||
index_paths = process_index_path(tar_files, index_dir)
|
||||
|
||||
with Parallel(n_jobs=cfg.workers, verbose=len(tar_files)) as parallel:
|
||||
_ = parallel(delayed(build_index)(tarpath, indexfile) for tarpath, indexfile in zip(tar_files, index_paths))
|
||||
|
||||
logging.info("Finished constructing index files !")
|
||||
|
||||
|
||||
ConfigStore.instance().store(name='index_config', node=DALITarredIndexConfig)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
# 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 contextlib import contextmanager
|
||||
from typing import Sequence
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from omegaconf import DictConfig, ListConfig, OmegaConf
|
||||
|
||||
from nemo.collections.common.data.lhotse.cutset import get_parser_fn
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("input_cfgs", type=click.Path(exists=True, dir_okay=False), nargs=-1)
|
||||
@click.argument("output_cfg", type=click.Path())
|
||||
@click.option(
|
||||
"-t",
|
||||
"--temperature",
|
||||
type=float,
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Temperature for re-weighting datasets. 1 is a neutral value. "
|
||||
"Lower temperature over-samples smaller datasets, and vice versa. "
|
||||
"Can be specified multiple times to apply a different temperature to each group level in the YAML config.",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--strategy",
|
||||
type=click.Choice(["num_hours", "num_examples"]),
|
||||
default="num_hours",
|
||||
help="Strategy for choosing weights for each dataset.",
|
||||
)
|
||||
def estimate_data_weights(input_cfgs: str, output_cfg: str, temperature: list[float], strategy: str):
|
||||
"""
|
||||
Read a YAML specification of datasets from INPUT_CFGS, compute their weights, and save the result in OUTPUT_CFG.
|
||||
The weight for each entry is determined by the number of hours in a given dataset.
|
||||
|
||||
If more than one config is provided as input, we will concatenate them and output a single merged config.
|
||||
|
||||
Optionally, apply temperature re-weighting to balance the datasets (specify TEMPERATURE lesser than 1).
|
||||
"""
|
||||
data = ListConfig([])
|
||||
for icfg in input_cfgs:
|
||||
data.extend(OmegaConf.load(icfg))
|
||||
temperature = parse_temperature(temperature)
|
||||
validate(data)
|
||||
count(data, weight_key=strategy)
|
||||
aggregate_group_weights(data)
|
||||
reweight(data, temperature=temperature)
|
||||
OmegaConf.save(data, output_cfg)
|
||||
|
||||
|
||||
def validate(entry: DictConfig | ListConfig, _level: int = 0):
|
||||
if isinstance(entry, ListConfig):
|
||||
for subentry in entry:
|
||||
validate(subentry, _level + 1)
|
||||
return
|
||||
|
||||
assert "type" in entry, f"Invalid YAML data config at nesting level {_level}: missing key 'type' in entry={entry}"
|
||||
|
||||
if entry.type == "group":
|
||||
for subentry in entry["input_cfg"]:
|
||||
validate(subentry, _level + 1)
|
||||
|
||||
|
||||
def count(entry: DictConfig | ListConfig, weight_key: str) -> None:
|
||||
if isinstance(entry, ListConfig):
|
||||
for subentry in entry:
|
||||
count(subentry, weight_key=weight_key)
|
||||
return
|
||||
if entry.type == "group":
|
||||
for subentry in entry["input_cfg"]:
|
||||
count(subentry, weight_key=weight_key)
|
||||
return
|
||||
|
||||
with quick_iter_options(entry):
|
||||
iterable, is_tarred = get_parser_fn(entry.type)(entry)
|
||||
stats = {"num_hours": 0.0, "num_examples": 0}
|
||||
for example in iterable:
|
||||
if hasattr(example, "duration"):
|
||||
stats["num_hours"] += example.duration
|
||||
stats["num_examples"] += 1
|
||||
stats["num_hours"] /= 3600.0
|
||||
|
||||
if weight_key == "num_hours" and stats[weight_key] == 0.0:
|
||||
raise RuntimeError(
|
||||
f"Cannot set weights based on 'num_hours': at least one dataset has examples without 'duration' property. "
|
||||
f"Details: {entry=}"
|
||||
)
|
||||
|
||||
entry["weight"] = stats[weight_key]
|
||||
|
||||
|
||||
def aggregate_group_weights(entry: DictConfig | ListConfig) -> None:
|
||||
if isinstance(entry, ListConfig):
|
||||
for subentry in entry:
|
||||
aggregate_group_weights(subentry)
|
||||
return
|
||||
|
||||
if entry.type != "group":
|
||||
return
|
||||
|
||||
for subentry in entry["input_cfg"]:
|
||||
if "weight" not in subentry:
|
||||
aggregate_group_weights(subentry)
|
||||
|
||||
entry.weight = sum(subentry["weight"] for subentry in entry["input_cfg"])
|
||||
|
||||
|
||||
def reweight(entry: DictConfig | ListConfig, temperature: None | float | list[float]) -> None:
|
||||
if not temperature or (isinstance(entry, DictConfig) and entry.type != "group"):
|
||||
return
|
||||
|
||||
if isinstance(temperature, Sequence):
|
||||
temperature, *next_temperatures = temperature
|
||||
else:
|
||||
next_temperatures = temperature
|
||||
|
||||
if isinstance(entry, ListConfig):
|
||||
for subentry in entry:
|
||||
reweight(subentry, temperature=next_temperatures)
|
||||
new_weights = temperature_reweighting([se.weight for se in entry], temperature=temperature)
|
||||
for se, nw in zip(entry, new_weights):
|
||||
se.weight = nw
|
||||
return
|
||||
|
||||
for subentry in entry["input_cfg"]:
|
||||
reweight(subentry, temperature=next_temperatures)
|
||||
|
||||
new_weights = temperature_reweighting([se.weight for se in entry["input_cfg"]], temperature=temperature)
|
||||
for se, nw in zip(entry["input_cfg"], new_weights):
|
||||
se.weight = nw
|
||||
|
||||
|
||||
def temperature_reweighting(weights: list[float], temperature: float = 1.0):
|
||||
"""(w_i ^ alpha / sum(w_i ^ alpha))"""
|
||||
weights = np.asarray(weights) ** temperature
|
||||
return (weights / weights.sum()).tolist()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def quick_iter_options(entry: DictConfig):
|
||||
entry.metadata_only = True
|
||||
entry.force_finite = True
|
||||
yield entry
|
||||
del entry["metadata_only"]
|
||||
del entry["force_finite"]
|
||||
|
||||
|
||||
def parse_temperature(value: list[float]) -> float | list[float] | None:
|
||||
match value:
|
||||
case 0:
|
||||
return None
|
||||
case 1:
|
||||
return value[0]
|
||||
case _:
|
||||
return value
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
estimate_data_weights()
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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 argparse
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
|
||||
from lhotse.cut import Cut
|
||||
from lhotse.dataset.sampling.dynamic_bucketing import estimate_duration_buckets
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.common.data.lhotse.cutset import read_cutset_from_config
|
||||
from nemo.collections.common.data.lhotse.dataloader import LhotseDataLoadingConfig
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Estimate duration bins for Lhotse dynamic bucketing using a sample of the input dataset. "
|
||||
"The dataset is read either from one or more manifest files and supports data weighting.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
help='Data input. Options: '
|
||||
'1) "path.json" - any single NeMo manifest; '
|
||||
'2) "[[path1.json],[path2.json],...]" - any collection of NeMo manifests; '
|
||||
'3) "[[path1.json,weight1],[path2.json,weight2],...]" - any collection of weighted NeMo manifests; '
|
||||
'4) "input_cfg.yaml" - a new option supporting input configs, same as in model training \'input_cfg\' arg; '
|
||||
'5) "path/to/shar_data" - a path to Lhotse Shar data directory; '
|
||||
'6) "key=val" - in case none of the previous variants cover your case: "key" is the key you\'d use in NeMo training config with its corresponding value ',
|
||||
)
|
||||
parser.add_argument("-b", "--buckets", type=int, default=30, help="The desired number of buckets.")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--num_examples",
|
||||
type=int,
|
||||
default=-1,
|
||||
help="The number of examples (utterances) to estimate the bins. -1 means use all data "
|
||||
"(be careful: it could be iterated over infinitely).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--min_duration",
|
||||
type=float,
|
||||
default=-float("inf"),
|
||||
help="If specified, we'll filter out utterances shorter than this.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--max_duration",
|
||||
type=float,
|
||||
default=float("inf"),
|
||||
help="If specified, we'll filter out utterances longer than this.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-q", "--quiet", type=bool, default=False, help="When specified, only print the estimated duration bins."
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if '=' in args.input:
|
||||
inp_arg = args.input
|
||||
elif args.input.endswith(".yaml"):
|
||||
inp_arg = f"input_cfg={args.input}"
|
||||
elif Path(args.input).is_dir():
|
||||
inp_arg = f"shar_path={args.input}"
|
||||
else:
|
||||
inp_arg = f"manifest_filepath={args.input}"
|
||||
config = OmegaConf.merge(
|
||||
OmegaConf.structured(LhotseDataLoadingConfig),
|
||||
OmegaConf.from_dotlist([inp_arg, "metadata_only=true"]),
|
||||
)
|
||||
cuts, _ = read_cutset_from_config(config)
|
||||
min_dur, max_dur = args.min_duration, args.max_duration
|
||||
nonaudio, discarded, tot = 0, 0, 0
|
||||
observed_max_dur = 0
|
||||
|
||||
def duration_ok(cut) -> bool:
|
||||
nonlocal nonaudio, discarded, tot, observed_max_dur
|
||||
tot += 1
|
||||
if not isinstance(cut, Cut):
|
||||
nonaudio += 1
|
||||
return False
|
||||
if not (min_dur <= cut.duration <= max_dur):
|
||||
discarded += 1
|
||||
return False
|
||||
observed_max_dur = max(cut.duration, observed_max_dur)
|
||||
return True
|
||||
|
||||
cuts = cuts.filter(duration_ok)
|
||||
if (N := args.num_examples) > 0:
|
||||
cuts = islice(cuts, N)
|
||||
duration_bins = estimate_duration_buckets(cuts, num_buckets=args.buckets)
|
||||
duration_bins = f"[{','.join(str(round(b, ndigits=5)) for b in duration_bins)}]"
|
||||
if args.quiet:
|
||||
print(duration_bins)
|
||||
return
|
||||
if discarded:
|
||||
ratio = discarded / tot
|
||||
print(f"Note: we discarded {discarded}/{tot} ({ratio:.2%}) utterances due to min/max duration filtering.")
|
||||
if nonaudio:
|
||||
print(f"Note: we discarded {nonaudio} non-audio examples found during iteration.")
|
||||
print(f"Used {tot - nonaudio - discarded} examples for the estimation.")
|
||||
print("Use the following options in your config:")
|
||||
print(f"\tnum_buckets={args.buckets}")
|
||||
print(f"\tbucket_duration_bins={duration_bins}")
|
||||
print(f"\tmax_duration={observed_max_dur}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,415 @@
|
||||
# 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 argparse
|
||||
import ast
|
||||
import math
|
||||
import warnings
|
||||
from functools import partial
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from lhotse.cut import Cut
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.common.data import apply_prompt_format_fn
|
||||
from nemo.collections.common.data.lhotse.cutset import read_cutset_from_config
|
||||
from nemo.collections.common.data.lhotse.dataloader import LhotseDataLoadingConfig, tokenize
|
||||
from nemo.collections.common.data.lhotse.sampling import DurationFilter, FixedBucketBatchSizeConstraint2D
|
||||
from nemo.collections.common.prompts.formatter import PromptFormatter
|
||||
from nemo.collections.common.tokenizers import (
|
||||
AggregateTokenizer,
|
||||
CanaryTokenizer,
|
||||
SentencePieceTokenizer,
|
||||
TokenizerSpec,
|
||||
)
|
||||
from nemo.collections.common.tokenizers.aggregate_tokenizer import TokenizerWrapper
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Estimate duration bins for Lhotse dynamic bucketing using a sample of the input dataset. "
|
||||
"The dataset is read either from one or more manifest files and supports data weighting. "
|
||||
"Unlike estimate_duration_bins.py, this script prepares the setup for 2D bucketing. "
|
||||
"This means that each main bucket for audio duration is sub-divided into sub-buckets "
|
||||
"for the number of output tokens (supporting BPE and Aggregated tokenizers). "
|
||||
"2D bucketing is especially useful for encoder-decoder models where input audio duration is often "
|
||||
"not sufficient to stratify the sampling with an optimal GPU utilization.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
help='Data input. Options: '
|
||||
'1) "path.json" - any single NeMo manifest; '
|
||||
'2) "[[path1.json],[path2.json],...]" - any collection of NeMo manifests; '
|
||||
'3) "[[path1.json,weight1],[path2.json,weight2],...]" - any collection of weighted NeMo manifests; '
|
||||
'4) "input_cfg.yaml" - a new option supporting input configs, same as in model training \'input_cfg\' arg; '
|
||||
'5) "path/to/shar_data" - a path to Lhotse Shar data directory; '
|
||||
'6) "key=val" - in case none of the previous variants cover your case: "key" is the key you\'d use in NeMo training config with its corresponding value ',
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--tokenizer",
|
||||
nargs="+",
|
||||
required=True,
|
||||
help="Path to one or more SPE tokenizers. More than one means we'll use AggregateTokenizer and --langs argument must also be used. When provided, we'll estimate a 2D distribution for input and output sequence lengths.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a", "--langs", nargs="+", help="Language names for each of AggregateTokenizer sub-tokenizers."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--buckets",
|
||||
type=int,
|
||||
default=30,
|
||||
help="The desired number of buckets (dim0 => covers input sequence length / audio duration).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--sub-buckets",
|
||||
type=int,
|
||||
default=2,
|
||||
help="The desired number of sub-buckets (dim1 => covers output sequence length / num_tokens).",
|
||||
)
|
||||
parser.add_argument("--text-field", default="text", help="The key in manifests to read transcripts from.")
|
||||
parser.add_argument("--lang-field", default="lang", help="The key in manifests to read language from.")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--num_examples",
|
||||
type=int,
|
||||
default=-1,
|
||||
help="The number of examples (utterances) to estimate the bins. -1 means use all data "
|
||||
"(be careful: it could be iterated over infinitely).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--min_duration",
|
||||
type=float,
|
||||
default=-float("inf"),
|
||||
help="If specified, we'll filter out utterances shorter than this.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--max_duration",
|
||||
type=float,
|
||||
default=float("inf"),
|
||||
help="If specified, we'll filter out utterances longer than this.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_tps", type=float, default=None, help="Deprecated. TPS is automatically determined per bucket."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token_outlier_threshold",
|
||||
type=float,
|
||||
default=4.0,
|
||||
help="The lower this is, the more outliers in transcript token count will be filtered out. "
|
||||
"By default allow token counts at 4 sigma away from distribution mean, computed separately for every bucket.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-q", "--quiet", type=bool, default=False, help="When specified, only print the estimated duration bins."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--prompt-format",
|
||||
type=str,
|
||||
help="When specified, we'll use a prompt formatter in addition to the tokenizer for the purpose of estimating token count bins. "
|
||||
"This is useful for accurate 2D bucket estimation with models such as EncDecMultiTaskModel (Canary-1B), "
|
||||
"or any model where the label sequence consists of a user prompt and a model's response.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--prompt",
|
||||
type=str,
|
||||
help="Prompt slots provided as a Python list of dicts. It is used together with --prompt-format option."
|
||||
"For example, with Canary-1B you may use: [{'role':'user','slots':{'source_lang':'en','target_lang':'en','task':'asr','pnc':'yes'}]",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def sort_two_arrays(A, B):
|
||||
joint = np.rec.fromarrays([A, B])
|
||||
joint.sort()
|
||||
return joint.f0, joint.f1
|
||||
|
||||
|
||||
def estimate_duration_buckets(
|
||||
cuts: Iterable[Cut],
|
||||
num_buckets: int,
|
||||
num_subbuckets: int,
|
||||
max_tps: float,
|
||||
max_duration: float,
|
||||
token_outlier_threshold: float,
|
||||
quiet: bool,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""
|
||||
This function is based on lhotse.dataset.sampling.dynamic_bucketing.estimate_duration_buckets.
|
||||
It extends it to a 2D bucketing case.
|
||||
"""
|
||||
assert num_buckets > 1
|
||||
|
||||
constraint = FixedBucketBatchSizeConstraint2D([(0.0, 0.0)], [0])
|
||||
|
||||
# Gather the duration and token count statistics for the dataset.
|
||||
sizes = []
|
||||
num_tokens = []
|
||||
for c in cuts:
|
||||
dur, toks = constraint.measure_length(c)
|
||||
sizes.append(dur)
|
||||
num_tokens.append(toks)
|
||||
sizes = np.array(sizes, dtype=np.float32)
|
||||
num_tokens = np.array(num_tokens, dtype=np.int32)
|
||||
sizes, num_tokens = sort_two_arrays(sizes, num_tokens)
|
||||
|
||||
# We are building buckets with equal duration (empirically leads to more even bucket exhaustion over time).
|
||||
# We need to determine how much duration to allocate per bucket.
|
||||
size_per_bucket = sizes.sum() / num_buckets
|
||||
|
||||
if not quiet:
|
||||
print("Duration distribution:")
|
||||
print(pd.Series(sizes).describe(percentiles=[0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.995, 0.999]))
|
||||
if math.isinf(max_duration):
|
||||
max_duration = round(sizes[-1], 3) # Round to 3 decimal places to be consistent for the output format.
|
||||
|
||||
bins = []
|
||||
tps_thresholds = []
|
||||
bin_indexes = [0]
|
||||
tot = 0.0
|
||||
|
||||
def _estimate_token_buckets(max_bucket_duration, start_idx, end_idx, corr_subbuckets=None):
|
||||
# Since this is 2D bucketing, apply the same bin creation logic
|
||||
# for the second dimension (i.e. token count) as for the first dimension (duration).
|
||||
# That means we aim to have each bucket contain roughly the same number of tokens.
|
||||
# Note that this estimation is biased towards more padding if you have
|
||||
# a lot of zero-token examples (e.g. non-speech).
|
||||
nonlocal bins
|
||||
|
||||
if not corr_subbuckets:
|
||||
corr_subbuckets = num_subbuckets
|
||||
|
||||
# Start by discarding outlier examples as defined by token-per-second (TPS) attribute.
|
||||
# We empirically determined high TPS examples to cause severe OOMs limiting batch sizes.
|
||||
# We cap the TPS for each top-level bucket at 4 standard deviations of TPS.
|
||||
# Examples exceeding that TPS value will be discarded during sampling at training time.
|
||||
num_tokens_bucket_all = num_tokens[start_idx:end_idx]
|
||||
sizes_bucket_all = sizes[start_idx:end_idx]
|
||||
non_outlier_indexes = find_non_outliers_z_score(
|
||||
num_tokens_bucket_all / sizes_bucket_all, threshold=token_outlier_threshold
|
||||
)
|
||||
num_tokens_bucket = num_tokens_bucket_all[non_outlier_indexes]
|
||||
sizes_bucket = sizes_bucket_all[non_outlier_indexes]
|
||||
max_tps_bucket = (num_tokens_bucket / sizes_bucket).max()
|
||||
num_tokens_bucket, sizes_bucket = sort_two_arrays(num_tokens_bucket, sizes_bucket)
|
||||
if not quiet:
|
||||
outlier_tps = np.delete(num_tokens_bucket_all / sizes_bucket_all, non_outlier_indexes)
|
||||
print(
|
||||
f"[bucket <= {max_bucket_duration:.2f}s] [{num_tokens_bucket.min()} - {num_tokens_bucket.max()}] [approx-max-tps: {max_tps_bucket:.2f}] Discarded {end_idx - start_idx - len(num_tokens_bucket)} max token outliers",
|
||||
end=" ",
|
||||
)
|
||||
if len(outlier_tps) > 0:
|
||||
print(f"min-outlier: {outlier_tps.min():.2f}, max-outlier: {outlier_tps.max():.2f}).", end="")
|
||||
print()
|
||||
|
||||
tokens_per_subbucket = num_tokens_bucket.sum() / corr_subbuckets
|
||||
tot_toks = 0
|
||||
# Iterate over token counts, and whenever we hit tokens_per_subbucket, create a new 2D bucket bin.
|
||||
for num_toks, size in zip(num_tokens_bucket, sizes_bucket):
|
||||
# Threshold hit: we are creating a new (max_duration, max_num_tokens) bin.
|
||||
if tot_toks > tokens_per_subbucket:
|
||||
bins.append((max_bucket_duration, num_toks))
|
||||
tps_thresholds.append(max_tps_bucket)
|
||||
tot_toks = 0
|
||||
tot_toks += num_toks
|
||||
bins.append((max_bucket_duration, num_toks))
|
||||
tps_thresholds.append(max_tps_bucket)
|
||||
|
||||
duration_bins = []
|
||||
|
||||
# Iterate over data, and whenever we hit size_per_bucket, register it as a new duration bucket.
|
||||
for binidx, size in enumerate(sizes):
|
||||
if tot > size_per_bucket:
|
||||
size = round(size, 3) # Round to 3 decimal places to be consistent for the output format.
|
||||
duration_bins.append(size)
|
||||
bin_indexes.append(binidx)
|
||||
tot = 0.0
|
||||
tot += size
|
||||
|
||||
if not quiet:
|
||||
print(f"Initial duration_bins={duration_bins}")
|
||||
|
||||
skipped_buckets = 1
|
||||
start_idx = 0
|
||||
|
||||
# Iterate over newly created duration bins to handle cases where some bins have the same value —
|
||||
# this usually happens when the data is skewed.
|
||||
# If we detect such bins, we skip estimating token buckets for that particular bin.
|
||||
# Instead, we keep track of how many bins got skipped because they had the same duration.
|
||||
# Then, when we finally hit a bin with a different duration, we treat all those skipped bins as one "combined" bin.
|
||||
# For that combined bin, we create more subbuckets — specifically, the number of skipped bins × `num_subbuckets` (set by the user).
|
||||
#
|
||||
# Example of durations bins created from skewed duration distribution: [5, 20, 30, 30, 30, 40]
|
||||
# Here, we'd end up making token subbuckets for: [5, 20, 40]
|
||||
# where [20, 40] bucket will have 4 times more subbuckets (as we combined 4 buckets into 1) than usual bucket in that settings.
|
||||
|
||||
for i, (duration_bin, binidx) in enumerate(zip(duration_bins, bin_indexes[1:])):
|
||||
if (i != len(duration_bins) - 1 and duration_bins[i + 1] == duration_bin) or (
|
||||
i == len(duration_bins) - 1 and max_duration == duration_bin
|
||||
):
|
||||
skipped_buckets += 1
|
||||
continue
|
||||
_estimate_token_buckets(
|
||||
max_bucket_duration=duration_bin,
|
||||
start_idx=start_idx,
|
||||
end_idx=binidx,
|
||||
corr_subbuckets=num_subbuckets * skipped_buckets,
|
||||
)
|
||||
start_idx = binidx
|
||||
skipped_buckets = 1
|
||||
|
||||
# Estimate an extra 2D bin set for global max duration.
|
||||
# Also, if the last value in duration_bins is equal to max_duration,
|
||||
# we need to make sure we properly handle any previously "skipped" buckets that ended at this max value.
|
||||
_estimate_token_buckets(
|
||||
max_bucket_duration=max_duration,
|
||||
start_idx=start_idx,
|
||||
end_idx=len(sizes),
|
||||
corr_subbuckets=num_subbuckets * skipped_buckets,
|
||||
)
|
||||
return bins, tps_thresholds
|
||||
|
||||
|
||||
def find_non_outliers_z_score(data, threshold=4):
|
||||
# Note: we don't apply abs() here because we only filter the upper end of the distribution.
|
||||
# We don't mind low-token-counts for bucketing purposes.
|
||||
z_scores = (data - np.mean(data)) / np.std(data)
|
||||
return np.where(z_scores <= threshold)
|
||||
|
||||
|
||||
def load_tokenizer(paths: list[str], langs: list[str] = None, is_canary: bool = True) -> TokenizerSpec:
|
||||
if len(paths) == 1:
|
||||
tok = SentencePieceTokenizer(paths[0])
|
||||
else:
|
||||
assert langs is not None and len(paths) == len(
|
||||
langs
|
||||
), f"Cannot create AggregateTokenizer; each tokenizer must have assigned a language via --langs option (we got --tokenizers={paths} and --langs={langs})"
|
||||
if is_canary:
|
||||
tokcls = CanaryTokenizer
|
||||
else:
|
||||
tokcls = AggregateTokenizer
|
||||
tok = tokcls({lang: SentencePieceTokenizer(p) for lang, p in zip(langs, paths)})
|
||||
return tok
|
||||
|
||||
|
||||
def apply_tokenizer(cut, tokenizer=None, prompt: PromptFormatter = None):
|
||||
if prompt is not None:
|
||||
encoded = apply_prompt_format_fn(cut, prompt)
|
||||
cut.supervisions[0].tokens = encoded["input_ids"]
|
||||
|
||||
elif tokenizer is not None:
|
||||
cut = tokenize(cut, TokenizerWrapper(tokenizer))
|
||||
|
||||
return cut
|
||||
|
||||
|
||||
class RejectionsCounter:
|
||||
def __init__(self, predicate: Callable, message: str):
|
||||
self.predicate = predicate
|
||||
self.message = message
|
||||
self.total = 0
|
||||
self.rejected = 0
|
||||
|
||||
def __call__(self, example) -> bool:
|
||||
ans = self.predicate(example)
|
||||
self.total += 1
|
||||
if not ans:
|
||||
self.rejected += 1
|
||||
return ans
|
||||
|
||||
def print_report(self) -> None:
|
||||
if self.rejected:
|
||||
print(f"{self.message} | Rejected {self.rejected}/{self.total} examples.")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if not args.quiet:
|
||||
pd.set_option('display.float_format', lambda x: '%.2f' % x)
|
||||
|
||||
if args.max_tps is not None:
|
||||
warnings.warn(
|
||||
"The option --max_tps has been deprecated in favor of "
|
||||
"automatic TPS determination that's variable across buckets."
|
||||
)
|
||||
|
||||
tokenizer = None
|
||||
prompt = None
|
||||
if args.tokenizer is not None:
|
||||
tokenizer = load_tokenizer(
|
||||
paths=args.tokenizer,
|
||||
langs=args.langs,
|
||||
is_canary=args.prompt_format is not None and 'canary' in args.prompt_format,
|
||||
)
|
||||
if args.prompt_format is not None:
|
||||
prompt_defaults = None
|
||||
if args.prompt is not None:
|
||||
prompt_defaults = ast.literal_eval(args.prompt)
|
||||
prompt = PromptFormatter.resolve(args.prompt_format)(tokenizer, defaults=prompt_defaults)
|
||||
|
||||
if '=' in args.input:
|
||||
inp_arg = args.input
|
||||
elif args.input.endswith(".yaml"):
|
||||
inp_arg = f"input_cfg={args.input}"
|
||||
elif Path(args.input).is_dir():
|
||||
inp_arg = f"shar_path={args.input}"
|
||||
else:
|
||||
inp_arg = f"manifest_filepath={args.input}"
|
||||
config = OmegaConf.merge(
|
||||
OmegaConf.structured(LhotseDataLoadingConfig),
|
||||
OmegaConf.from_dotlist(
|
||||
[inp_arg, "metadata_only=true", f"text_field={args.text_field}", f"lang_field={args.lang_field}"]
|
||||
),
|
||||
)
|
||||
cuts, _ = read_cutset_from_config(config)
|
||||
duration_filter = RejectionsCounter(DurationFilter(args.min_duration, args.max_duration), "Duration filtering")
|
||||
cuts = cuts.filter(duration_filter)
|
||||
cuts = cuts.map(partial(apply_tokenizer, tokenizer=tokenizer, prompt=prompt))
|
||||
if (N := args.num_examples) > 0:
|
||||
cuts = islice(cuts, N)
|
||||
|
||||
duration_bins, tps_thresholds = estimate_duration_buckets(
|
||||
cuts,
|
||||
num_buckets=args.buckets,
|
||||
num_subbuckets=args.sub_buckets,
|
||||
max_duration=args.max_duration,
|
||||
max_tps=args.max_tps,
|
||||
token_outlier_threshold=args.token_outlier_threshold,
|
||||
quiet=args.quiet,
|
||||
)
|
||||
duration_bins = "[" + ','.join(f"[{b:.3f},{sb:d}]" for b, sb in duration_bins) + "]"
|
||||
tps_thresholds = "[" + ",".join(f"{t:.2f}" for t in tps_thresholds) + "]"
|
||||
if not args.quiet:
|
||||
duration_filter.print_report()
|
||||
print("Use the following options in your config:")
|
||||
print(f"\tuse_bucketing=1")
|
||||
print(f"\tnum_buckets={args.buckets}")
|
||||
print(f"\tbucket_duration_bins={duration_bins}")
|
||||
print(f"The max_tps setting below is optional, use it if your data has low quality long transcript outliers:")
|
||||
print(f"\tmax_tps={tps_thresholds}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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 functools import partial
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import lhotse
|
||||
import torch.utils.data
|
||||
from lhotse import CutSet, MonoCut
|
||||
from lhotse.audio.backend import LibsndfileBackend
|
||||
from lhotse.dataset import DynamicCutSampler, IterableDatasetWrapper
|
||||
from lhotse.shar import JsonlShardWriter, TarWriter
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.common.data.lhotse import read_cutset_from_config
|
||||
from nemo.collections.common.data.lhotse.dataloader import LhotseDataLoadingConfig
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("manifest_filepath")
|
||||
@click.argument("tarred_audio_filepaths")
|
||||
@click.argument("filtered_manifest_filepath")
|
||||
@click.argument("output_dir", type=click.Path())
|
||||
@click.option(
|
||||
"-f",
|
||||
"--output-format",
|
||||
type=click.Choice(["lhotse_shar", "nemo_tarred"]),
|
||||
default="lhotse_shar",
|
||||
help="Which format should we use to save the filtered tarred data.",
|
||||
)
|
||||
@click.option("-s", "--shard-size", type=int, default=1000, help="Desired number of examples per output shard.")
|
||||
def filter_tarred(
|
||||
manifest_filepath: str,
|
||||
tarred_audio_filepaths: str,
|
||||
filtered_manifest_filepath: str,
|
||||
output_dir: str,
|
||||
output_format: str,
|
||||
shard_size: int,
|
||||
):
|
||||
"""
|
||||
Given an existing tarred dataset and manifests that point to a subset of examples,
|
||||
create a new tarred dataset corresponding to the subset.
|
||||
|
||||
This is useful if you want to "re-tar" an existing tarred dataset in order to efficiently
|
||||
read some subset of it.
|
||||
"""
|
||||
lhotse.set_dill_enabled(True)
|
||||
all_cuts = read_cutset(manifest_filepath, tarred_audio_filepaths)
|
||||
keep_cuts = {cut.id: cut for cut in read_cutset(filtered_manifest_filepath)}
|
||||
filtered_cuts = bg_load(
|
||||
all_cuts.filter(lambda c: c.id in keep_cuts).map(partial(attach_custom, cuts_with_custom=keep_cuts))
|
||||
)
|
||||
if not '://' in output_dir: # we support object store writing too
|
||||
Path(output_dir).mkdir(exist_ok=True, parents=True)
|
||||
if output_format == "lhotse_shar":
|
||||
filtered_cuts.to_shar(output_dir=output_dir, fields={"recording": "flac"}, shard_size=shard_size)
|
||||
elif output_format == "nemo_tarred":
|
||||
export_to_nemo_tarred(cuts=filtered_cuts, output_dir=output_dir, shard_size=shard_size)
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported output format: '{output_format}'")
|
||||
|
||||
|
||||
def read_cutset(src: str, tar: str | None = None) -> CutSet:
|
||||
inp_arg = ["force_finite=true"]
|
||||
if tar is not None:
|
||||
inp_arg += [f"manifest_filepath={src}", f"tarred_audio_filepaths={tar}"]
|
||||
else:
|
||||
inp_arg += ["metadata_only=true"]
|
||||
if src.endswith(".yaml"):
|
||||
inp_arg += [f"input_cfg={src}"]
|
||||
elif Path(src).is_dir():
|
||||
inp_arg += [f"shar_path={src}"]
|
||||
else:
|
||||
inp_arg += [f"manifest_filepath={src}"]
|
||||
config = OmegaConf.merge(
|
||||
OmegaConf.structured(LhotseDataLoadingConfig),
|
||||
OmegaConf.from_dotlist(inp_arg),
|
||||
)
|
||||
cuts, _ = read_cutset_from_config(config)
|
||||
return cuts
|
||||
|
||||
|
||||
def export_to_nemo_tarred(cuts: CutSet, output_dir: str, shard_size: int) -> None:
|
||||
with (
|
||||
TarWriter(pattern=f"{output_dir}/audio_%d.tar", shard_size=shard_size) as aw,
|
||||
JsonlShardWriter(pattern=f"{output_dir}/manifest_%d.jsonl", shard_size=shard_size) as mw,
|
||||
):
|
||||
for cut in cuts:
|
||||
assert (
|
||||
isinstance(cut, MonoCut) and len(cut.supervisions) == 1
|
||||
), f"Export to nemo_tarred format is possible only for mono cuts with a single supervision, but we got: {cut}"
|
||||
# Prepare audio for writing.
|
||||
audio_name = f"{cut.id}.flac"
|
||||
audio = BytesIO()
|
||||
LibsndfileBackend().save_audio(audio, cut.load_audio(), sampling_rate=cut.sampling_rate, format="flac")
|
||||
audio.seek(0)
|
||||
# Prepare manifest for writing.
|
||||
ans = {"audio_filepath": audio_name, "duration": cut.duration}
|
||||
if cut.supervisions[0].text:
|
||||
ans["text"] = cut.supervisions[0].text
|
||||
if cut.supervisions[0].language:
|
||||
ans["lang"] = cut.supervisions[0].language
|
||||
if cut.custom is not None:
|
||||
# Ensure if we export anything custom, these are only simple built-in types compatible with JSON.
|
||||
ans.update({k: v for k, v in cut.custom.items() if isinstance(v, (int, float, str, list, dict))})
|
||||
# Set the right shard_id.
|
||||
shard_id = max(0, mw.num_shards - 1)
|
||||
if mw.num_items > 0 and mw.num_items % mw.shard_size == 0:
|
||||
shard_id += 1
|
||||
ans["shard_id"] = shard_id
|
||||
# Write both items.
|
||||
aw.write(audio_name, audio)
|
||||
mw.write(ans)
|
||||
|
||||
|
||||
def attach_custom(cut, cuts_with_custom):
|
||||
custom = cuts_with_custom[cut.id].custom
|
||||
if custom is not None:
|
||||
cut.custom.update(custom)
|
||||
return cut
|
||||
|
||||
|
||||
class Identity(torch.utils.data.Dataset):
|
||||
def __getitem__(self, x):
|
||||
cut = x[0]
|
||||
for k in ["dataloading_info", "shard_id"]:
|
||||
cut.custom.pop(k, None)
|
||||
return cut
|
||||
|
||||
|
||||
def bg_load(cuts: CutSet) -> CutSet:
|
||||
return CutSet(
|
||||
torch.utils.data.DataLoader(
|
||||
IterableDatasetWrapper(Identity(), DynamicCutSampler(cuts, max_cuts=1)),
|
||||
batch_size=None,
|
||||
num_workers=1,
|
||||
prefetch_factor=10,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
filter_tarred()
|
||||
Executable
+548
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env 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.
|
||||
|
||||
import importlib
|
||||
import math
|
||||
import sys
|
||||
from numbers import Number
|
||||
|
||||
import click
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
from lhotse import compute_num_samples
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from nemo.collections.asr.models.asr_model import ASRModel
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, MaskType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class ProfilingBatchGenerator:
|
||||
"""
|
||||
ProfilingBatchGenerator is used to generate artificial mini-batches for model training
|
||||
and tracking the progress of batch size optimization.
|
||||
|
||||
The high-level usage API is the following::
|
||||
|
||||
>>> gen = ProfilingBatchGenerator(schema)
|
||||
... finished = False
|
||||
... while not finished:
|
||||
... batch = gen(input_seq_len, output_seq_len)
|
||||
... try:
|
||||
... training_step(model, batch)
|
||||
... oom = False
|
||||
... except torch.cuda.OutOfMemoryError:
|
||||
... oom = True
|
||||
... finished = gen.advance(oom)
|
||||
... solution = gen.max_batch_size # The solution of the search problem.
|
||||
... gen.reset() # Can re-use for other sequence lengths now.
|
||||
|
||||
The search terminates once the difference between max working batch size and min OOM batch size
|
||||
divided by the latter is smaller than ``rel_gap_thresh`` that difference amounts to a single element.
|
||||
For example, a max working batch size is 96 and min OOM batch size is 100 indicates a gap of 0.04,
|
||||
which would terminate the search with threshold of 0.05.
|
||||
|
||||
In order to generate mini-batches compatible with a given model, the generator:
|
||||
|
||||
* accepts a ``schema`` argument in its constructor, and
|
||||
|
||||
* accepts input/output sequence lengths in each call to generate a mini-batch.
|
||||
|
||||
``schema`` has the following structure::
|
||||
|
||||
|
||||
>>> {
|
||||
... "cls": tuple | MyBatchType,
|
||||
... "inputs": [
|
||||
... {
|
||||
... "type": NeuralType(...) | Literal["dummy"],
|
||||
... "seq_length": Literal["input", "output"],
|
||||
... "vocab_size": int, # optional, required only for LabelsType
|
||||
... "name": str, # optional, indicates kwarg
|
||||
... },
|
||||
... ...,
|
||||
... ]
|
||||
... }
|
||||
|
||||
``cls`` indicates how we should construct the mini-batch. Typically you can just use ``tuple`` for most
|
||||
batch schemas. However, if the model expects a specific, e.g., dataclass, you can tell ``ProfilingBatchGenerator``
|
||||
to use it. The mini-batch object will be constructed using the items in ``inputs``.
|
||||
|
||||
Each element of ``inputs`` specifies a NeMo NeuralType which needs to have a defined ``elements_type``.
|
||||
The supported types are ``AudioSignal``, ``LengthsType`` and ``LabelsType``.
|
||||
If "type" is not a NeuralType, we interpret that as a placeholder tensor that's not relevant but expected
|
||||
by the model/batch constructor. In addition, ``"seq_length"`` key is used to determine whether we should apply
|
||||
input or output sequence length to a given tensor.
|
||||
|
||||
Optional keys:
|
||||
|
||||
* ``vocab_size`` is required for ``LabelsType`` so that we can generate proper label values.
|
||||
|
||||
* ``name`` is required if objects of ``cls`` have to be constructed using keyword arguments.
|
||||
|
||||
A simple schema example for a model using audio/lengths tensor pair (unsupervised/self-supervised)::
|
||||
|
||||
>>> {
|
||||
... "cls": tuple,
|
||||
... "inputs": [
|
||||
... {"type": NeuralType(("B", "T"), AudioSignal()), "seq_length": "input"},
|
||||
... {"type": NeuralType(("B"), LengthsType()), "seq_length": "input"},
|
||||
... ]
|
||||
... }
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: dict,
|
||||
start_batch_size: int = 32,
|
||||
rel_gap_thresh: float = 0.05,
|
||||
device: str = "cuda",
|
||||
):
|
||||
self.schema = schema
|
||||
self.start_batch_size = start_batch_size
|
||||
self.rel_gap_thresh = rel_gap_thresh
|
||||
self.device = device
|
||||
self.reset()
|
||||
|
||||
def __call__(self, input_seq_length: int, output_seq_length: int):
|
||||
B = self._current
|
||||
select_seq_length = {"input": input_seq_length, "output": output_seq_length}
|
||||
batch = []
|
||||
names = []
|
||||
for item in self.schema["inputs"]:
|
||||
nt = item["type"]
|
||||
if isinstance(nt, str) and nt == "constant":
|
||||
if isinstance(val := item["value"], str) and val == "batch":
|
||||
tnsr = torch.tensor([B], dtype=torch.long, device=self.device)
|
||||
else:
|
||||
tnsr = torch.tensor([val], dtype=torch.long, device=self.device)
|
||||
elif not isinstance(nt, NeuralType): # placeholder
|
||||
tnsr = torch.tensor([])
|
||||
elif isinstance(nt.elements_type, AudioSignal):
|
||||
seq_length = select_seq_length[item["seq_length"]]
|
||||
tnsr = torch.randn(B, seq_length, dtype=torch.float32, device=self.device)
|
||||
elif isinstance(nt.elements_type, LengthsType):
|
||||
seq_length = select_seq_length[item["seq_length"]]
|
||||
tnsr = torch.ones(B, dtype=torch.long, device=self.device) * seq_length
|
||||
elif isinstance(nt.elements_type, LabelsType):
|
||||
seq_length = select_seq_length[item["seq_length"]]
|
||||
tnsr = torch.randint(0, item["vocab_size"], size=(B, seq_length), device=self.device)
|
||||
elif isinstance(nt.elements_type, MaskType):
|
||||
seq_length = select_seq_length[item["seq_length"]]
|
||||
tnsr = torch.ones(B, seq_length, device=self.device)
|
||||
else:
|
||||
raise RuntimeError("Unexpected item in oomptimizer schema: {item}")
|
||||
batch.append(tnsr)
|
||||
names.append(item.get("name"))
|
||||
args = [elem for name, elem in zip(names, batch) if name is None]
|
||||
kwargs = {name: elem for name, elem in zip(names, batch) if name is not None}
|
||||
if not kwargs and self.schema["cls"] == tuple:
|
||||
return tuple(args)
|
||||
return self.schema["cls"](*args, **kwargs)
|
||||
|
||||
@property
|
||||
def max_batch_size(self) -> int | None:
|
||||
"""
|
||||
Return the solution of the batch size search problem.
|
||||
It will keep returning None until the search is done.
|
||||
"""
|
||||
if (
|
||||
self._max_ok is not None
|
||||
and self._min_err is not None
|
||||
and (self.current_rel_gap <= self.rel_gap_thresh or self._min_err - self._max_ok <= 1)
|
||||
):
|
||||
return self._max_ok
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_rel_gap(self) -> float | None:
|
||||
"""
|
||||
Return the current gap between the largest batch that works and the smallest batch that triggers OOM.
|
||||
The gap is defined as the batch size difference divided by the larger element.
|
||||
E.g., if the best found batch size is 95 and the smallest that triggers OOM is 100, the gap is 0.05.
|
||||
"""
|
||||
if self._min_err is None or self._max_ok is None:
|
||||
return None
|
||||
return (self._min_err - self._max_ok) / self._min_err
|
||||
|
||||
def reset(self):
|
||||
"""Reset the generator to prepare it for a new search."""
|
||||
self._current = self.start_batch_size
|
||||
self._max_ok = None # max batch size that works
|
||||
self._min_err = None # min batch size that doesn't work
|
||||
|
||||
def advance(self, oom: bool) -> bool:
|
||||
"""
|
||||
Adjusts the current batch size based on the outcome.
|
||||
Returns a bool indicating whether the calibration is complete.
|
||||
"""
|
||||
if self.max_batch_size is not None:
|
||||
return True
|
||||
|
||||
if oom:
|
||||
# Training step failed with OOM.
|
||||
# Update the minimum known batch size that causes an error.
|
||||
self._min_err = min(float("inf") if self._min_err is None else self._min_err, self._current)
|
||||
# Training step failed on OOM
|
||||
if self._max_ok is None:
|
||||
# We haven't found a batch size that works yet, keep going 2x down.
|
||||
self._current = round(self._current / 2)
|
||||
else:
|
||||
# Try the middle-point between the known extremes.
|
||||
self._current = round((self._max_ok + self._min_err) / 2)
|
||||
else:
|
||||
# Training step successful.
|
||||
# Update the maximum known batch size that works.
|
||||
self._max_ok = max(-1 if self._max_ok is None else self._max_ok, self._current)
|
||||
if self._min_err is None:
|
||||
# We haven't found a batch size that causes an error yet, keep going 2x higher
|
||||
self._current *= 2
|
||||
else:
|
||||
# Try the middle-point between the known extremes.
|
||||
self._current = round((self._max_ok + self._min_err) / 2)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class FloatList(click.Option):
|
||||
"""Support passing bucket duration bins as [1.1,2.5,5.6,...]"""
|
||||
|
||||
name = "list[float]"
|
||||
|
||||
def type_cast_value(self, ctx, value):
|
||||
if isinstance(value, list) and all(isinstance(v, float) for v in value):
|
||||
return value
|
||||
try:
|
||||
import ast
|
||||
|
||||
ans = ast.literal_eval(value)
|
||||
if isinstance(ans[0], list):
|
||||
ans = [tuple(item) for item in ans]
|
||||
return ans
|
||||
except ValueError:
|
||||
raise click.BadParameter(value)
|
||||
|
||||
|
||||
@click.command(context_settings={'show_default': True})
|
||||
@click.option(
|
||||
"-n",
|
||||
"--pretrained-name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Name of a pretrained model to use, e.g. 'nvidia/canary-1b'.",
|
||||
)
|
||||
@click.option(
|
||||
"-m",
|
||||
"--module-name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Full path to NeMo's module corresponding to CONFIG_PATH, e.g. 'nemo.collections.asr.models.EncDecMultiTaskModel'.",
|
||||
)
|
||||
@click.option(
|
||||
"-c", "--config-path", type=str, default=None, help="Path to the training configuration file for MODULE_NAME."
|
||||
)
|
||||
@click.option("-o", "--optimizer-name", type=str, default="adamw", help="Name of optimizer to use.")
|
||||
@click.option(
|
||||
"-b",
|
||||
"--buckets",
|
||||
cls=FloatList,
|
||||
default=[5.0, 10.0, 15.0, 20.0, 25.0, 30.0],
|
||||
help="List of upper-bound bucket bins (i.e. first bucket is [0.0 - item0), second bucket is [item0 - item1), etc.). "
|
||||
"We also support a nested list for 2D bucketing, e.g. [[2.0, 10],[2.0,20],[4.5,15],[4.5,30],...], "
|
||||
"where each item is a pair of (max_input_seq_len, max_output_seq_len) for a given bucket.",
|
||||
)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=0.05,
|
||||
help="Search stopping criterion in range [0, 1], lower is more precise. Interpret as the uncerainty gap, i.e. (min_oom_batch_size - max_ok_batch_size) / min_oom_batch_size.",
|
||||
)
|
||||
@click.option("-s", "--start-batch-size", type=int, default=32, help="Initial batch size to start the search from.")
|
||||
@click.option(
|
||||
"-r",
|
||||
"--ratio",
|
||||
type=int,
|
||||
default=12, # conservative estimate towards longer transcripts
|
||||
help="The output_sequence_length to input_sequence_length ratio for the purpose of determing the maximum output sequence lengths. "
|
||||
"The interpretation depends on input and output modalities. Examples: for audio->text it's tokens per second. "
|
||||
"For text->audio it's seconds per token. For audio->audio it's output seconds per input second. "
|
||||
"For text->text it's output tokens per input token. "
|
||||
"In general larger ratio means longer output sequences and increased memory consumption. "
|
||||
"The default value is set adequately for automatic speech recognition. "
|
||||
"This argument is ignored when 2D buckets are provided to --buckets option.",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--memory-fraction",
|
||||
type=float,
|
||||
default=0.9,
|
||||
help="Limits the use of CUDA memory for this process to MEMORY_FRACTION of the total device memory. "
|
||||
"By default we force 5% memory to be unused to account for non-training-loop related CUDA memory usage"
|
||||
"in actual training scripts.",
|
||||
)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--device",
|
||||
default="cuda:0",
|
||||
help="Device string to be passed to torch.device; due to MEMORY_FRACTION option, "
|
||||
"it must specify the device index (e.g. cuda:0). "
|
||||
"You can also leave the default index and select a specific GPU using env var CUDA_VISIBLE_DEVICES=<idx>",
|
||||
)
|
||||
@click.option(
|
||||
"-y",
|
||||
"--dtype",
|
||||
default="bfloat16",
|
||||
help="Float precision to use for computation (used together with autocast).",
|
||||
)
|
||||
@click.option(
|
||||
"--ddp/--no-ddp",
|
||||
type=bool,
|
||||
default=True,
|
||||
help="Whether we should simulate DDP GPU RAM usage. Stores an extra copy of the model in GPU memory. Enabled by default.",
|
||||
)
|
||||
def oomptimizer(
|
||||
pretrained_name: str | None,
|
||||
module_name: str | None,
|
||||
config_path: str | None,
|
||||
optimizer_name: str,
|
||||
buckets: list[float],
|
||||
threshold: float,
|
||||
start_batch_size: int,
|
||||
ratio: int,
|
||||
memory_fraction: float,
|
||||
device: str,
|
||||
dtype: str,
|
||||
ddp: bool,
|
||||
):
|
||||
"""
|
||||
OOMptimizer finds the optimal batch sizes for training your model with bucketing dataloading.
|
||||
It performs a search over batch sizes until it converges by measuring the GPU memory usage for
|
||||
a model's training step and optimizer update.
|
||||
|
||||
\b
|
||||
There are two main usage patterns: for using a pretrained model or an untrained model configuration.
|
||||
The latter is more flexible but requires the user to provide two separate arguments. Examples:
|
||||
* python oomptimizer.py --pretrained-name nvidia/canary-1b
|
||||
* python oomptimizer.py --module-name nemo.collections.asr.models.EncDecMultiTaskModel \
|
||||
--config-path examples/asr/conf/speech_multitask/fast-conformer_aed.yaml
|
||||
|
||||
Dynamic bucketing is notoriously difficult to tune as you risk running into CUDA OOM many steps into the training.
|
||||
In order to simplify finding the optimal settings, OOMptimizer scans each bucket to find the maximum possible
|
||||
batch size that doesn't trigger a CUDA OOM.
|
||||
|
||||
\b
|
||||
The suggested workflow is the following:
|
||||
1) Run scripts/speech_recognition/estimate_duration_bins.py to get the duration distribution of your data.
|
||||
(consider running estimate_duration_bins_2d.py for models with a strong dependency on output sequence length
|
||||
such as attention-encoder-decoder models).
|
||||
2) Run OOMptimizer to find the optimal batch sizes for your specific model, optimizer, and GPU.
|
||||
3) Use these optimal settings in your actual training script and enjoy optimal GPU utilization OOM-free.
|
||||
|
||||
In the unlikely event that OOMptimizer bucket batch sizes are still leading to OOMs,
|
||||
please try a lower setting of the MEMORY_FRACTION option, e.g. 0.75 (75% of GPU memory).
|
||||
This may be required in very complex setups where there are additional GPU RAM loads that can't be anticipated
|
||||
through the combination of training_step and optimizer update.
|
||||
"""
|
||||
if all(opt is None for opt in (pretrained_name, module_name, config_path)):
|
||||
click.secho(
|
||||
"You need to provide either PRETRAINED_NAME or the pair of MODULE_NAME and CONFIG_PATH.", fg="yellow"
|
||||
)
|
||||
sys.exit(1)
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
torch.cuda.set_per_process_memory_fraction(memory_fraction, device)
|
||||
|
||||
trainer = pl.Trainer(barebones=True)
|
||||
trainer.log_every_n_steps = 1000000
|
||||
model_clones = []
|
||||
for _ in range(2 if ddp else 1):
|
||||
if pretrained_name is not None:
|
||||
assert (
|
||||
config_path is None and module_name is None
|
||||
), "--pretrained-name cannot be used together with --module-name/--config-path"
|
||||
click.echo(f"Intializing ASR model from pretrained checkpoint {pretrained_name}.")
|
||||
if pretrained_name.endswith('.nemo'):
|
||||
model = ASRModel.restore_from(pretrained_name, trainer=trainer).to(device)
|
||||
else:
|
||||
model = ASRModel.from_pretrained(pretrained_name, trainer=trainer).to(device)
|
||||
else:
|
||||
assert config_path is not None, "--module-name requires --config-path to be specified as well."
|
||||
assert module_name is not None, "--config-path requires --module-name to be specified as well."
|
||||
cfg = OmegaConf.load(config_path)
|
||||
namespace, name = module_name.rsplit('.', maxsplit=1)
|
||||
model_cls = getattr(importlib.import_module(namespace), name)
|
||||
model = model_cls(cfg=cfg.model, trainer=trainer).to(device)
|
||||
model_clones.append(model)
|
||||
model = model_clones[-1]
|
||||
|
||||
if not hasattr(model, "oomptimizer_schema"):
|
||||
click.secho(
|
||||
f"We read model of type {type(model)} which doesn't seem to support OOMptimizer "
|
||||
f"(we could not find the property .oomptimizer_schema).",
|
||||
fg="red",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
schema = model.oomptimizer_schema
|
||||
|
||||
click.echo("Setting up the optimizers.")
|
||||
optimizer, _ = model.setup_optimization({"name": optimizer_name, "lr": 1e-7, "weight_decay": 0.0})
|
||||
|
||||
is_2d_bucketing = all(
|
||||
isinstance(item, (list, tuple)) and len(item) == 2 and all(isinstance(v, Number) for v in item)
|
||||
for item in buckets
|
||||
)
|
||||
# Determine modality for input and output.
|
||||
modalities = [
|
||||
(
|
||||
"text"
|
||||
if any(
|
||||
isinstance(item["type"], NeuralType)
|
||||
and isinstance(item["type"].elements_type, LabelsType)
|
||||
and item["seq_length"] == direction
|
||||
for item in schema["inputs"]
|
||||
if item["type"] != "dummy"
|
||||
)
|
||||
else "audio"
|
||||
)
|
||||
for direction in ("input", "output")
|
||||
]
|
||||
|
||||
def get_max_seq_lens(buckets):
|
||||
|
||||
def _determine_lens_for_bucket(bin):
|
||||
if is_2d_bucketing:
|
||||
input_len, output_len = bin
|
||||
else:
|
||||
input_len = bin
|
||||
output_len = math.ceil(ratio * input_len)
|
||||
sampling_rate = getattr(
|
||||
model, "sample_rate", 16000
|
||||
) # TODO: may need to extend schema for broader model coverage
|
||||
match modalities:
|
||||
case "audio", "audio":
|
||||
return (
|
||||
compute_num_samples(input_len, sampling_rate=sampling_rate),
|
||||
compute_num_samples(output_len, sampling_rate=sampling_rate),
|
||||
)
|
||||
case "audio", "text":
|
||||
return (compute_num_samples(input_len, sampling_rate=sampling_rate), output_len)
|
||||
case "text", "audio":
|
||||
return (
|
||||
input_len,
|
||||
compute_num_samples(output_len, sampling_rate=sampling_rate),
|
||||
)
|
||||
case "text", "text":
|
||||
return input_len, output_len
|
||||
case _:
|
||||
raise RuntimeError(f"Unexpected modality combination: {_}")
|
||||
|
||||
return [_determine_lens_for_bucket(bin) for bin in buckets]
|
||||
|
||||
click.echo("Starting profiling.")
|
||||
max_seq_lens = get_max_seq_lens(buckets)
|
||||
gen = ProfilingBatchGenerator(schema=schema, start_batch_size=start_batch_size, rel_gap_thresh=threshold)
|
||||
profile = {}
|
||||
|
||||
# Iterate buckets from the largest to the smallest sequences. This usually ends up creating
|
||||
# a tiny bit smaller batches, likely due to worse memory fragmentation.
|
||||
with torch.autocast("cuda", getattr(torch, dtype)):
|
||||
for bucket, (seq_len_in, seq_len_out) in reversed(list(zip(buckets, max_seq_lens))):
|
||||
click.echo(f"The current sequence lengths are: input={seq_len_in} output={seq_len_out}.")
|
||||
gen.reset()
|
||||
batch_idx = 0
|
||||
|
||||
def step():
|
||||
click.echo(
|
||||
f"\t[BEGIN step] [CUDA RAM CURRENT: {torch.cuda.memory_allocated() / (1024 * 1024):.1f}MB] [CUDA RAM MAX: {torch.cuda.max_memory_allocated() / (1024*1024):.1f}MB]"
|
||||
)
|
||||
batch = gen(seq_len_in, seq_len_out)
|
||||
oom = False
|
||||
try:
|
||||
click.echo(f"\tCurrent gap: {gen.current_rel_gap}... ", nl=False)
|
||||
optimizer.zero_grad()
|
||||
out = model.training_step(batch, batch_idx)
|
||||
out['loss'].sum().backward()
|
||||
optimizer.step()
|
||||
except torch.cuda.OutOfMemoryError as e:
|
||||
click.secho(f"OOM!", fg="yellow")
|
||||
oom = True
|
||||
except RuntimeError as e:
|
||||
if "cuFFT error: CUFFT_INTERNAL_ERROR" not in str(e):
|
||||
raise
|
||||
click.secho(f"OOM!", fg="yellow")
|
||||
oom = True
|
||||
else:
|
||||
click.secho(f"OK!", fg="green")
|
||||
finally:
|
||||
click.echo(
|
||||
f"\t[END step] [CUDA RAM CURRENT: {torch.cuda.memory_allocated() / (1024 * 1024):.1f}MB] [CUDA RAM MAX: {torch.cuda.max_memory_allocated() / (1024*1024):.1f}MB]"
|
||||
)
|
||||
del batch
|
||||
# Note: We could call empty_cache() to free up some more memory on the GPU,
|
||||
# but we have found out empirically that this causes a mismatched condition
|
||||
# between OOMptimizer and the actual training. During training, there is some
|
||||
# degree of memory fragmentation and it's better to simulate that in OOMptimizer.
|
||||
# torch.cuda.memory.empty_cache()
|
||||
torch.cuda.reset_max_memory_allocated()
|
||||
return oom
|
||||
|
||||
oom = step()
|
||||
while not (finished := gen.advance(oom)):
|
||||
click.echo("\t" + "=" * 80)
|
||||
oom = step()
|
||||
|
||||
click.secho(
|
||||
f"=> Optimal setting for bucket={bucket} (input={seq_len_in} output={seq_len_out}) is max_batch_size={gen.max_batch_size}",
|
||||
fg="green",
|
||||
)
|
||||
profile[(bucket, seq_len_in, seq_len_out)] = gen.max_batch_size
|
||||
gen.start_batch_size = gen.max_batch_size * 2
|
||||
|
||||
# Reverse the profile to be ascendingly sorted again.
|
||||
profile = dict(reversed(list(profile.items())))
|
||||
|
||||
click.echo("The 1st stage profile is:")
|
||||
for (bucket, seq_len_in, seq_len_out), bs in profile.items():
|
||||
click.echo(f"Bucket={bucket} (input={seq_len_in} output={seq_len_out}) => max_batch_size={bs}")
|
||||
|
||||
if is_2d_bucketing:
|
||||
# 2D bucketing doesn't support bucket merging.
|
||||
final_profile = [["[" + ",".join(map(str, b)) + "]", bs] for (b, _, __), bs in profile.items()]
|
||||
else:
|
||||
click.echo("Bucket merging stage...")
|
||||
final_profile = []
|
||||
for idx, ((bucket, seq_len_in, seq_len_out), bs) in enumerate(profile.items()):
|
||||
if idx == 0:
|
||||
final_profile.append([bucket, bs])
|
||||
continue
|
||||
if bs == final_profile[-1][1]:
|
||||
click.echo(f"Merging bucket {idx} with bucket {idx-1} due to identical batch sizes.")
|
||||
final_profile[-1][0] = bucket
|
||||
continue
|
||||
final_profile.append([bucket, bs])
|
||||
|
||||
click.secho(f"The profile was created with the following settings:")
|
||||
click.secho(f"* using {memory_fraction:.1%} of available GPU RAM.")
|
||||
click.secho(f"* {'' if ddp else 'not '}simulating DDP memory overhead.")
|
||||
click.secho(f"* using AMP with dtype={dtype}.")
|
||||
click.secho("The final profile is:", bold=True)
|
||||
click.secho("\tbucket_duration_bins=[" + ",".join(str(seqlen) for seqlen, bs in final_profile) + "]", bold=True)
|
||||
click.secho("\tbucket_batch_size=[" + ",".join(str(bs) for seqlen, bs in final_profile) + "]", bold=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
oomptimizer()
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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 json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import hydra
|
||||
from convert_to_tarred_audio_dataset import ASRTarredDatasetBuilder, ASRTarredDatasetMetadata
|
||||
from hydra.core.config_store import ConfigStore
|
||||
from joblib import Parallel, delayed
|
||||
from omegaconf import MISSING
|
||||
from tqdm import tqdm
|
||||
|
||||
"""
|
||||
# Partial Tarred Audio Dataset Creator
|
||||
|
||||
## Overview
|
||||
|
||||
This script facilitates the creation of tarred and sharded audio datasets from existing tarred manifests. It allows you to select specific shards from a manifest file and then tar them separately.
|
||||
|
||||
This is useful in several scenarios:
|
||||
- When you only need to process a specific subset of shards (e.g., for debugging or incremental dataset preparation).
|
||||
- When you want to parallelize shard creation across multiple SLURM jobs to accelerate the dataset generation process and overcome per-job time limits.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ensure that the `convert_to_tarred_audio_dataset` script is correctly configured and run with the `--only_manifests` flag to generate the necessary manifest files.
|
||||
- Make sure the paths to the manifest and metadata files are correct and accessible.
|
||||
|
||||
## Usage
|
||||
|
||||
### Script Execution
|
||||
|
||||
To run the script, use the following command:
|
||||
|
||||
python partial_convertion_to_tarred_audio_dataset.py \
|
||||
# the path to the tarred manifest file that contains the entries for the shards you want to process. This option is mandatory.
|
||||
--tarred_manifest_filepath=<path to the tarred manifest file > \
|
||||
# any other optional argument
|
||||
--output_dir=<output directory for tarred shards> \
|
||||
--shards_to_tar=<shard IDs to be tarred> \
|
||||
--num_workers=-1 \
|
||||
--dataset_metadata_filepath=<dataset metadata YAML filepath>
|
||||
|
||||
Example:
|
||||
python partial_convertion_to_tarred_audio_dataset.py \
|
||||
tarred_manifest_filepath="path/to/manifest.json" \
|
||||
shards_to_tar="0:3"
|
||||
"""
|
||||
|
||||
|
||||
def select_shards(manifest_filepath: str, shards_to_tar: str, slice_with_offset: bool = False):
|
||||
"""
|
||||
Selects and returns a subset of shards from the tarred manifest file.
|
||||
|
||||
Args:
|
||||
manifest_filepath (str): The path to the tarred manifest file.
|
||||
shards_to_tar (str): A range or list of shard IDs to select, e.g., "0:5" or "0,1,2".
|
||||
slice_with_offset (bool, optional): If True, slices entries based on audio offsets. Defaults to False.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the manifest file does not exist.
|
||||
KeyError: If `slice_with_offset` is enabled but required fields are missing in the manifest entries.
|
||||
|
||||
Returns:
|
||||
Dict[int, List[Dict[str, any]]]: A dictionary where the keys are shard IDs and the values are lists of entries for those shards.
|
||||
"""
|
||||
shard_ids = []
|
||||
if shards_to_tar != "all":
|
||||
if ":" not in shards_to_tar:
|
||||
shard_ids = [int(shards_to_tar)]
|
||||
else:
|
||||
start_shard_idx, end_shard_idx = map(
|
||||
lambda x: int(x.strip()) if x.strip() else None, shards_to_tar.split(":")
|
||||
)
|
||||
shard_ids = list(range(start_shard_idx, end_shard_idx))
|
||||
|
||||
entries_to_shard = {}
|
||||
with open(manifest_filepath, 'r') as manifest:
|
||||
for line in tqdm(manifest, desc="Selecting shards"):
|
||||
entry = json.loads(line)
|
||||
if shards_to_tar == "all" or entry['shard_id'] in shard_ids:
|
||||
if entry['shard_id'] not in entries_to_shard:
|
||||
entries_to_shard[entry['shard_id']] = []
|
||||
|
||||
if slice_with_offset:
|
||||
if 'abs_audio_filepath' not in entry or 'source_audio_offset' not in entry:
|
||||
raise KeyError(
|
||||
f"`slice_with_offset` is enabled, but `abs_audio_filepath` and/or `source_audio_offset` are not found in the entry:\n{entry}."
|
||||
)
|
||||
entry['audio_filepath'] = entry.pop('abs_audio_filepath')
|
||||
entry['offset'] = entry.pop('source_audio_offset')
|
||||
|
||||
entries_to_shard[entry['shard_id']].append(entry)
|
||||
|
||||
return entries_to_shard
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartialASRTarredDatasetConfig:
|
||||
"""
|
||||
Configuration class for creating partial tarred audio dataset shards.
|
||||
|
||||
Attributes:
|
||||
tarred_manifest_filepath (str): The path to the tarred manifest file.
|
||||
output_dir (Optional[str]): Directory where the output tarred shards will be saved.
|
||||
shards_to_tar (Optional[str]): A range or list of shard IDs to tar.
|
||||
num_workers (int): Number of parallel workers to use for tar file creation.
|
||||
dataset_metadata_filepath (Optional[str]): Path to the dataset metadata YAML file.
|
||||
dataset_metadata (ASRTarredDatasetMetadata): Dataset metadata configuration.
|
||||
"""
|
||||
|
||||
tarred_manifest_filepath: str = MISSING
|
||||
output_dir: Optional[str] = None
|
||||
shards_to_tar: Optional[str] = "all"
|
||||
num_workers: int = 1
|
||||
dataset_metadata_filepath: Optional[str] = None
|
||||
dataset_metadata: ASRTarredDatasetMetadata = field(default=ASRTarredDatasetMetadata)
|
||||
slice_with_offset: bool = False
|
||||
|
||||
|
||||
def create_shards(cfg: PartialASRTarredDatasetConfig):
|
||||
"""
|
||||
Creates tarred shards based on the provided configuration.
|
||||
|
||||
Args:
|
||||
cfg (PartialASRTarredDatasetConfig): The configuration object containing paths, shard IDs, and metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If the `tarred_manifest_filepath` is None.
|
||||
FileNotFoundError: If the tarred manifest file or dataset metadata file does not exist.
|
||||
|
||||
Notes:
|
||||
- Reads the tarred manifest file and selects the specified shards.
|
||||
- Creates tarred shards in parallel using the `ASRTarredDatasetBuilder`.
|
||||
- The `dataset_metadata_filepath` is inferred if not provided.
|
||||
"""
|
||||
if cfg.tarred_manifest_filepath is None:
|
||||
raise ValueError("The `tarred_manifest_filepath` cannot be `None`. Please check your configuration.")
|
||||
|
||||
if not os.path.exists(cfg.tarred_manifest_filepath):
|
||||
raise FileNotFoundError(
|
||||
f"The `tarred_manifest_filepath` was not found: {cfg.tarred_manifest_filepath}. Please verify that the filepath is correct."
|
||||
)
|
||||
|
||||
if cfg.dataset_metadata_filepath is None:
|
||||
cfg.dataset_metadata_filepath = os.path.join(os.path.dirname(cfg.tarred_manifest_filepath), "metadata.yaml")
|
||||
|
||||
if cfg.output_dir is None:
|
||||
cfg.output_dir = os.path.dirname(cfg.tarred_manifest_filepath)
|
||||
|
||||
if not os.path.exists(cfg.dataset_metadata_filepath):
|
||||
raise FileNotFoundError(
|
||||
f"The `dataset_metadata_filepath` was not found: {cfg.dataset_metadata_filepath}. Please verify that the filepath is correct."
|
||||
)
|
||||
else:
|
||||
cfg.dataset_metadata = ASRTarredDatasetMetadata.from_file(cfg.dataset_metadata_filepath)
|
||||
|
||||
entries_to_shard = select_shards(
|
||||
cfg.tarred_manifest_filepath, cfg.shards_to_tar, cfg.dataset_metadata.dataset_config.slice_with_offset
|
||||
)
|
||||
|
||||
builder = ASRTarredDatasetBuilder()
|
||||
builder.configure(cfg.dataset_metadata.dataset_config)
|
||||
|
||||
with Parallel(n_jobs=cfg.num_workers, verbose=len(entries_to_shard)) as parallel:
|
||||
# Call parallel tarfile construction
|
||||
_ = parallel(
|
||||
delayed(builder._create_shard)(
|
||||
entries=entries_to_shard[shard_id],
|
||||
target_dir=cfg.output_dir,
|
||||
shard_id=shard_id,
|
||||
)
|
||||
for shard_id in entries_to_shard
|
||||
)
|
||||
|
||||
|
||||
@hydra.main(config_path=None, config_name='partial_tar_config')
|
||||
def main(cfg: PartialASRTarredDatasetConfig):
|
||||
create_shards(cfg)
|
||||
|
||||
|
||||
ConfigStore.instance().store(name='partial_tar_config', node=PartialASRTarredDatasetConfig)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user