mirror of
https://github.com/storytold/storyteller-ml.git
synced 2026-10-18 07:41:03 +00:00
Introduce code from Rust monorepo. This is a mess
This commit is contained in:
+318
@@ -0,0 +1,318 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Execution :
|
||||
|
||||
python create_tarred_tokenized_text_lm_dataset.py \
|
||||
--text_path=<comma seperated text filepaths> \
|
||||
--data_root=<path to output directory> \
|
||||
--tokenizer_name="bert-base-cased" \
|
||||
--tokenizer_vocab_file=<path to vocab file for tokenizer> \
|
||||
--num_shards=64 \
|
||||
--chunk_size=8192 \
|
||||
--chunk_write_buffer=512 \
|
||||
--lower_case \
|
||||
--log
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.nlp.modules.common.tokenizer_utils import get_tokenizer
|
||||
|
||||
parser = argparse.ArgumentParser(description='Tarred Tokenized dataset for text language modelling')
|
||||
|
||||
# Data path arguments
|
||||
parser.add_argument('--text_path', required=True, default=None, type=str, help='Text paths, seperated by commas')
|
||||
parser.add_argument('--data_root', required=True, default=None, type=str, help='Output directory')
|
||||
|
||||
# General arguments
|
||||
parser.add_argument(
|
||||
'--chunk_write_buffer',
|
||||
default=128,
|
||||
type=int,
|
||||
help='Number of chunks of `chunk_size` to buffer for parallel tokenization and serial write to disk',
|
||||
)
|
||||
parser.add_argument('--lower_case', action='store_true', help='Whether to lower case the corpus')
|
||||
parser.add_argument('--log', action='store_true', help='Whether to print logs to terminal')
|
||||
|
||||
# Tokenizer arguments
|
||||
parser.add_argument('--tokenizer_name', required=False, default=None, type=str, help='Tokenizer name for resolution')
|
||||
parser.add_argument(
|
||||
'--tokenizer_model', required=False, default=None, type=str, help='Path to tokenizer model for sentencepiece'
|
||||
)
|
||||
parser.add_argument('--tokenizer_vocab_file', required=False, type=str, default=None, help='Path to a vocab file')
|
||||
parser.add_argument(
|
||||
'--tokenizer_special_tokens', default=None, type=str, nargs='+', help='List of special tokens for the tokenizer'
|
||||
)
|
||||
|
||||
# Tarred dataset arguments
|
||||
parser.add_argument('--num_shards', default=1, type=int, help='Number of shards for the tarfile')
|
||||
parser.add_argument('--chunk_size', default=8192, type=int, help='Number of rows of data concatenated into a vector')
|
||||
|
||||
parser.set_defaults(log=False, lower_case=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def __build_dataset_from_text(texts: str, lower_case: bool, chunk_size: int):
|
||||
if ',' in texts:
|
||||
texts = texts.split(',')
|
||||
else:
|
||||
texts = [texts]
|
||||
|
||||
num_lines = 0
|
||||
text_dataset = []
|
||||
|
||||
for text in texts:
|
||||
with open(text, 'r') as in_reader:
|
||||
reader = tqdm(iter(lambda: in_reader.readline(), ''), desc="Read 0 lines", unit=' lines')
|
||||
|
||||
for i, line in enumerate(reader):
|
||||
# Clean text line
|
||||
line = line.replace("\n", "").strip()
|
||||
|
||||
if lower_case:
|
||||
line = line.lower()
|
||||
|
||||
if line:
|
||||
text_dataset.append(line)
|
||||
|
||||
num_lines += 1
|
||||
if num_lines % 100000 == 0:
|
||||
reader.set_description(f"Read {num_lines} lines")
|
||||
|
||||
if num_lines % chunk_size == 0:
|
||||
yield text_dataset, num_lines
|
||||
|
||||
# Empty cache
|
||||
text_dataset = []
|
||||
|
||||
logging.info(f"Finished extracting manifest : {text}")
|
||||
|
||||
logging.info("Finished extracting all manifests ! Number of sentences : {}".format(num_lines))
|
||||
|
||||
if len(text_dataset) != 0:
|
||||
yield text_dataset, num_lines
|
||||
|
||||
|
||||
def __tokenize_str(texts, tokenizer):
|
||||
tokenized_text = []
|
||||
for text in texts:
|
||||
tok_text = tokenizer.text_to_ids(text)
|
||||
tokenized_text.extend(tok_text)
|
||||
return tokenized_text
|
||||
|
||||
|
||||
def __tokenize_text(
|
||||
text_paths, tokenizer, tokenized_cachedir, lower_case: bool = False, chunk_size=8192, write_buffer: int = -1
|
||||
):
|
||||
if write_buffer < 1:
|
||||
write_buffer = max(os.cpu_count() - write_buffer, 1)
|
||||
|
||||
logging.info(f"Using write chunk buffer of size {write_buffer}")
|
||||
|
||||
if not os.path.exists(tokenized_cachedir):
|
||||
os.makedirs(tokenized_cachedir)
|
||||
|
||||
# global parameters
|
||||
global_chunk_idx = 0
|
||||
chunk_paths = []
|
||||
chunk_lens = []
|
||||
|
||||
# buffer parameters
|
||||
data_cache = []
|
||||
chunk_idx = 0
|
||||
|
||||
text_generator = iter(__build_dataset_from_text(text_paths, lower_case=lower_case, chunk_size=chunk_size))
|
||||
global_num_lines = 0
|
||||
last_batch = False
|
||||
|
||||
with joblib.Parallel(n_jobs=-2, verbose=10) as parallel:
|
||||
|
||||
while True:
|
||||
try:
|
||||
data, num_lines = next(text_generator)
|
||||
data_cache.append(data)
|
||||
|
||||
global_num_lines = num_lines
|
||||
except StopIteration:
|
||||
last_batch = True
|
||||
|
||||
# Update counters
|
||||
chunk_idx += 1
|
||||
|
||||
if (chunk_idx == write_buffer) or last_batch:
|
||||
# write the chunks into disk after parallel tokenization
|
||||
tokenized_data_list = parallel(
|
||||
joblib.delayed(__tokenize_str)(chunk, tokenizer) for chunk in data_cache
|
||||
)
|
||||
|
||||
# Sequential write cache
|
||||
for chunk in tokenized_data_list:
|
||||
fp = os.path.join(tokenized_cachedir, f"chunk_{global_chunk_idx}.npy")
|
||||
chunk = np.asarray(chunk, dtype=np.int64)
|
||||
np.save(fp, chunk, allow_pickle=False)
|
||||
|
||||
chunk_paths.append(fp)
|
||||
chunk_lens.append(len(chunk))
|
||||
|
||||
global_chunk_idx += 1
|
||||
|
||||
logging.info(f"Wrote a total of {global_chunk_idx} chunks to file...")
|
||||
|
||||
# reset buffers
|
||||
data_cache.clear()
|
||||
del data_cache
|
||||
|
||||
data_cache = []
|
||||
chunk_idx = 0
|
||||
|
||||
if last_batch:
|
||||
logging.info("Finished tokenizing last chunk")
|
||||
break
|
||||
|
||||
logging.info(
|
||||
f"Chunking {global_num_lines} rows into {global_num_lines // chunk_size} tasks (each chunk contains {chunk_size} elements)"
|
||||
)
|
||||
return chunk_paths, chunk_lens
|
||||
|
||||
|
||||
def __create_chunk(data_root, chunk_path, shard_id, compute_metrics=False):
|
||||
"""Creates a tarball containing the tokenized text chunks.
|
||||
"""
|
||||
tar = tarfile.open(os.path.join(data_root, f'text_{shard_id}.tar'), mode='a')
|
||||
|
||||
# We squash the filename since we do not preserve directory structure of tokenized text in the tarball.
|
||||
base, ext = os.path.splitext(chunk_path)
|
||||
base = base.replace(os.pathsep, '_')
|
||||
# Need the following replacement as long as WebDataset splits on first period
|
||||
base = base.replace('.', '_')
|
||||
squashed_filename = f'{base}{ext}'
|
||||
tar.add(chunk_path, arcname=squashed_filename)
|
||||
|
||||
tar.close()
|
||||
|
||||
if compute_metrics:
|
||||
data = np.load(chunk_path, allow_pickle=False)
|
||||
chunk_len = len(data)
|
||||
return (chunk_len,)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def __write_tarred_tokenized_text_dataset(data_root, num_shards, chunk_paths, chunk_lens):
|
||||
num_chunks = len(chunk_paths)
|
||||
|
||||
if chunk_lens is not None:
|
||||
num_text = sum(chunk_lens)
|
||||
shard_counts = {chunk_id: chunk_len for chunk_id, chunk_len in enumerate(chunk_lens)}
|
||||
compute_metrics = False
|
||||
|
||||
else:
|
||||
num_text = 0
|
||||
shard_counts = {}
|
||||
compute_metrics = True
|
||||
|
||||
for chunk_id, chunk_path in enumerate(tqdm(chunk_paths, desc='Writing chunk ', unit=' chunks')):
|
||||
shard_id = chunk_id % num_shards
|
||||
metrics = __create_chunk(data_root, chunk_path, shard_id, compute_metrics=compute_metrics)
|
||||
|
||||
if metrics is not None:
|
||||
num_text += metrics[0]
|
||||
shard_counts[chunk_id] = metrics[0]
|
||||
|
||||
# write metadata
|
||||
metadata_path = os.path.join(data_root, 'metadata.json')
|
||||
with open(metadata_path, 'w') as f:
|
||||
metadata = {'num_chunks': num_chunks, 'num_text': num_text, 'shard_count': shard_counts}
|
||||
json.dump(metadata, f, indent=4)
|
||||
logging.info("Metadata writen..")
|
||||
|
||||
|
||||
def main():
|
||||
text_path = args.text_path
|
||||
data_root = args.data_root
|
||||
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
tokenized_cachedir = os.path.join(data_root, '_tokenized_dataset_cachedir')
|
||||
|
||||
if os.path.exists(tokenized_cachedir):
|
||||
logging.warning(
|
||||
f'Tokenized cache directory {tokenized_cachedir} already potentially contains files.'
|
||||
f'In such a case, please be aware that the tarfiles will be **appended** instead of overridden!'
|
||||
)
|
||||
|
||||
if not os.path.exists(data_root):
|
||||
os.makedirs(data_root)
|
||||
|
||||
chunk_paths = None
|
||||
chunk_lens = None
|
||||
|
||||
if os.path.exists(tokenized_cachedir):
|
||||
paths = glob.glob(os.path.join(tokenized_cachedir, "*.npy"))
|
||||
if len(paths) > 0:
|
||||
logging.info("Cached tokenized numpy files found, skipping re-tokenization of dataset")
|
||||
|
||||
chunk_paths = paths
|
||||
chunk_lens = None
|
||||
|
||||
if chunk_paths is None:
|
||||
if args.tokenizer_name is None:
|
||||
raise ValueError("`tokenizer_name` name is required when tokenizing the dataset for the first time.")
|
||||
|
||||
if args.tokenizer_vocab_file is None:
|
||||
raise ValueError("`tokenizer_vocab_file` is required when constructing the tokenized dataset")
|
||||
|
||||
tokenizer = get_tokenizer(
|
||||
tokenizer_name=args.tokenizer_name,
|
||||
tokenizer_model=args.tokenizer_model,
|
||||
vocab_file=args.tokenizer_vocab_file,
|
||||
special_tokens=args.tokenizer_special_tokens,
|
||||
)
|
||||
|
||||
logging.info("Built tokenizer")
|
||||
|
||||
# tokenize text data into sub-words
|
||||
chunk_paths, chunk_lens = __tokenize_text(
|
||||
text_paths=text_path,
|
||||
tokenizer=tokenizer,
|
||||
tokenized_cachedir=tokenized_cachedir,
|
||||
lower_case=args.lower_case,
|
||||
chunk_size=args.chunk_size,
|
||||
write_buffer=args.chunk_write_buffer,
|
||||
)
|
||||
logging.info(f"Tokenized dataset into sub-words and serialized cache at {tokenized_cachedir}")
|
||||
|
||||
# Write tarred dataset
|
||||
__write_tarred_tokenized_text_dataset(
|
||||
data_root, num_shards=args.num_shards, chunk_paths=chunk_paths, chunk_lens=chunk_lens
|
||||
)
|
||||
|
||||
logging.info('Done preparing tokenized dataset!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
This script would evaluate a neural language model (Transformer) trained with
|
||||
`examples/nlp/language_modeling/transformer_lm.py' as a rescorer for ASR systems.
|
||||
Given a trained TransformerLMModel `.nemo` file, this script can be used to re-score the beams obtained from a beam
|
||||
search decoder of an ASR model.
|
||||
|
||||
USAGE:
|
||||
1. Obtain `.tsv` file with beams and their corresponding scores. Scores can be from a regular beam search decoder or
|
||||
in fusion with an N-gram LM scores. For a given beam size `beam_size` and a number of examples
|
||||
for evaluation `num_eval_examples`, it should contain (`beam_size` x `num_eval_examples`) lines of
|
||||
form `beam_candidate_text \t score`. This file can be generated by `scripts/asr_language_modeling/ngram_lm/eval_beamsearch_ngram.py`.
|
||||
|
||||
2. Rescore the candidates:
|
||||
python eval_neural_rescorer.py
|
||||
--lm_model=[path to .nemo file of the LM]
|
||||
--beams_file=[path to beams .tsv file]
|
||||
--beam_size=[size of the beams]
|
||||
--eval_manifest=[path to eval manifest .json file]
|
||||
--batch_size=[batch size used for inference on the LM model]
|
||||
--alpha=[the value for the parameter rescorer_alpha]
|
||||
--beta=[the value for the parameter rescorer_beta]
|
||||
|
||||
You may find more info on how to use this script at:
|
||||
https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import editdistance
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import tqdm
|
||||
|
||||
from nemo.collections.nlp.models.language_modeling import TransformerLMModel
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class BeamScoresDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
Dataset to read the score file containing the beams and their score
|
||||
|
||||
Args:
|
||||
data_path: path to the beams file
|
||||
tokenizer: tokenizer of the LM model
|
||||
manifest_path: manifest `.json` file which contains the ground truths transcripts
|
||||
beam_size: the number of beams per sample
|
||||
max_seq_length: the maximum length of sequences
|
||||
"""
|
||||
|
||||
def __init__(self, data_path, tokenizer, manifest_path, beam_size=128, max_seq_length=256):
|
||||
self.data = pd.read_csv(data_path, delimiter="\t", header=None)
|
||||
self.tokenizer = tokenizer
|
||||
self.ground_truths = []
|
||||
with open(manifest_path, 'r') as f_orig:
|
||||
for line in f_orig:
|
||||
item = json.loads(line)
|
||||
self.ground_truths.append(item['text'])
|
||||
self.beam_size = beam_size
|
||||
self.max_seq_length = max_seq_length
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
text = str(self.data[0][idx])
|
||||
tokens = [self.tokenizer.bos_id] + self.tokenizer.text_to_ids(text) + [self.tokenizer.eos_id]
|
||||
input_ids = [self.tokenizer.pad_id] * self.max_seq_length
|
||||
input_ids[: len(tokens)] = tokens
|
||||
input_ids = np.array(input_ids)
|
||||
input_mask = (input_ids != self.tokenizer.pad_id).astype(np.float32)
|
||||
acoustic_score = self.data[1][idx]
|
||||
dist = editdistance.eval(text.split(), self.ground_truths[idx // self.beam_size].split())
|
||||
ref_len = len(self.ground_truths[idx // self.beam_size].split())
|
||||
len_in_chars = len(str(self.data[0][idx]))
|
||||
return input_ids, input_mask, acoustic_score, dist, ref_len, len_in_chars, idx
|
||||
|
||||
|
||||
def linear_search_wer(
|
||||
dists, scores1, scores2, total_len, coef_range=[0, 10], coef_steps=10000, param_name='parameter'
|
||||
):
|
||||
"""
|
||||
performs linear search to find the best coefficient when two set of scores are getting linearly fused.
|
||||
|
||||
Args:
|
||||
dists: Tesnor of the distances between the ground truth and the candidates with shape of [number of samples, beam size]
|
||||
scores1: Tensor of the first set of scores with shape of [number of samples, beam size]
|
||||
scores2: Tensor of the second set of scores with shape of [number of samples, beam size]
|
||||
total_len: The total length of all samples
|
||||
coef_range: the search range for the coefficient
|
||||
coef_steps: the number of steps that the search range would get divided into
|
||||
param_name: the name of the parameter to be used in the figure
|
||||
|
||||
Output:
|
||||
(best coefficient found, best WER achived)
|
||||
"""
|
||||
scale = scores1.mean().abs().item() / scores2.mean().abs().item()
|
||||
left = coef_range[0] * scale
|
||||
right = coef_range[1] * scale
|
||||
coefs = np.linspace(left, right, coef_steps)
|
||||
|
||||
best_wer = 10000
|
||||
best_coef = left
|
||||
wers = []
|
||||
for coef in coefs:
|
||||
scores = scores1 + coef * scores2
|
||||
wer = compute_wer(dists, scores, total_len)
|
||||
wers.append(wer)
|
||||
if wer < best_wer:
|
||||
best_wer = wer
|
||||
best_coef = coef
|
||||
|
||||
plt.plot(coefs, wers)
|
||||
plt.title(f'WER% after rescoring with different values of {param_name}')
|
||||
plt.ylabel('WER%')
|
||||
plt.xlabel(param_name)
|
||||
plt.show()
|
||||
return best_coef, best_wer
|
||||
|
||||
|
||||
def compute_wer(dists, scores, total_len):
|
||||
"""
|
||||
Sorts the candidates based on the scores and calculates the WER with the new top candidates.
|
||||
|
||||
Args:
|
||||
dists: Tensor of the distances between the ground truth and the candidates with shape of [number of samples, beam size]
|
||||
scores: Tensor of the scores for candidates with shape of [number of samples, beam size]
|
||||
total_len: The total length of all samples
|
||||
|
||||
Output:
|
||||
WER with the new scores
|
||||
"""
|
||||
indices = scores.max(dim=1, keepdim=True)[1]
|
||||
wer = dists.gather(dim=1, index=indices).sum() / total_len
|
||||
wer = wer.item()
|
||||
return wer
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("--lm_model_file", type=str, required=True, help="path to LM model .nemo file")
|
||||
parser.add_argument("--beams_file", type=str, required=True, help="path to beams .tsv file")
|
||||
parser.add_argument(
|
||||
"--eval_manifest", type=str, required=True, help="path to the evaluation `.json` manifest file"
|
||||
)
|
||||
parser.add_argument("--beam_size", type=int, required=True, help="number of beams per candidate")
|
||||
parser.add_argument("--batch_size", type=int, default=256, help="inference batch size")
|
||||
parser.add_argument("--alpha", type=float, default=None, help="parameter alpha of the fusion")
|
||||
parser.add_argument("--beta", type=float, default=None, help="parameter beta of the fusion")
|
||||
parser.add_argument(
|
||||
"--scores_output_file", default=None, type=str, help="The optional path to store the rescored beams"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device", default="cuda", type=str, help="The device to load the model onto to calculate the scores"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_amp", action="store_true", help="Whether to use AMP if available to calculate the scores"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
device = args.device
|
||||
if device.startswith("cuda") and not torch.cuda.is_available():
|
||||
logging.info(f"cuda is not available! switched to cpu.")
|
||||
device = "cpu"
|
||||
|
||||
if args.lm_model_file.endswith(".nemo"):
|
||||
logging.info("Attempting to initialize from .nemo file")
|
||||
model = TransformerLMModel.restore_from(
|
||||
restore_path=args.lm_model_file, map_location=torch.device(device)
|
||||
).eval()
|
||||
else:
|
||||
raise NotImplementedError(f"Only supports .nemo files, but got: {args.model}")
|
||||
|
||||
max_seq_length = model.encoder._embedding.position_embedding.pos_enc.shape[0]
|
||||
dataset = BeamScoresDataset(args.beams_file, model.tokenizer, args.eval_manifest, args.beam_size, max_seq_length)
|
||||
data_loader = torch.utils.data.DataLoader(dataset=dataset, batch_size=args.batch_size)
|
||||
|
||||
if args.use_amp:
|
||||
if torch.cuda.is_available() and hasattr(torch.cuda, 'amp') and hasattr(torch.cuda.amp, 'autocast'):
|
||||
logging.info("AMP is enabled!\n")
|
||||
autocast = torch.cuda.amp.autocast
|
||||
else:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def autocast():
|
||||
yield
|
||||
|
||||
logging.info(f"Rescoring with beam_size: {args.beam_size}")
|
||||
logging.info("Calculating the scores...")
|
||||
with autocast():
|
||||
with torch.no_grad():
|
||||
am_scores, lm_scores, dists, ref_lens, lens_in_chars = [], [], [], [], []
|
||||
for batch in tqdm.tqdm(data_loader):
|
||||
input_ids, input_mask, acoustic_score, dist, ref_len, len_in_chars, idx = batch
|
||||
|
||||
max_len_in_batch = input_mask.sum(dim=0).argmin().item()
|
||||
input_ids, input_mask = input_ids[:, :max_len_in_batch], input_mask[:, :max_len_in_batch]
|
||||
if torch.cuda.is_available():
|
||||
input_ids, input_mask = input_ids.to(device), input_mask.to(device)
|
||||
dist, acoustic_score, len_in_chars = (
|
||||
dist.to(device),
|
||||
acoustic_score.to(device),
|
||||
len_in_chars.to(device),
|
||||
)
|
||||
|
||||
log_probs = model.forward(input_ids[:, :-1], input_mask[:, :-1])
|
||||
target_log_probs = log_probs.gather(2, input_ids[:, 1:].unsqueeze(2)).squeeze(2)
|
||||
neural_lm_score = torch.sum(target_log_probs * input_mask[:, 1:], dim=-1)
|
||||
|
||||
am_scores.append(acoustic_score)
|
||||
lm_scores.append(neural_lm_score)
|
||||
dists.append(dist)
|
||||
ref_lens.append(ref_len)
|
||||
lens_in_chars.append(len_in_chars)
|
||||
|
||||
am_scores = torch.cat(am_scores).view(-1, args.beam_size)
|
||||
lm_scores = torch.cat(lm_scores).view(-1, args.beam_size)
|
||||
dists = torch.cat(dists).view(-1, args.beam_size)
|
||||
ref_lens = torch.cat(ref_lens).view(-1, args.beam_size)
|
||||
lens_in_chars = torch.cat(lens_in_chars).view(-1, args.beam_size).to(am_scores.dtype)
|
||||
|
||||
total_len = ref_lens[:, 0].sum()
|
||||
model_wer = dists[:, 0].sum() / total_len
|
||||
ideal_wer = dists.min(dim=1)[0].sum() / total_len
|
||||
|
||||
if args.alpha is None:
|
||||
logging.info("Linear search for alpha...")
|
||||
coef1, _ = linear_search_wer(
|
||||
dists=dists, scores1=am_scores, scores2=lm_scores, total_len=total_len, param_name='alpha'
|
||||
)
|
||||
coef1 = np.round(coef1, 3)
|
||||
logging.info(f"alpha={coef1} achieved the best WER.")
|
||||
logging.info(f"------------------------------------------------")
|
||||
else:
|
||||
coef1 = args.alpha
|
||||
|
||||
scores = am_scores + coef1 * lm_scores
|
||||
|
||||
if args.beta is None:
|
||||
logging.info("Linear search for beta...")
|
||||
coef2, _ = linear_search_wer(
|
||||
dists=dists, scores1=scores, scores2=lens_in_chars, total_len=total_len, param_name='beta'
|
||||
)
|
||||
coef2 = np.round(coef2, 3)
|
||||
logging.info(f"beta={coef2} achieved the best WER.")
|
||||
logging.info(f"------------------------------------------------")
|
||||
else:
|
||||
coef2 = args.beta
|
||||
|
||||
new_scores = am_scores + coef1 * lm_scores + coef2 * lens_in_chars
|
||||
rescored_wer = compute_wer(dists, new_scores, total_len)
|
||||
|
||||
logging.info(f"Input beams WER: {np.round(model_wer.item() * 100, 2)}%")
|
||||
logging.info(f"------------------------------------------------")
|
||||
logging.info(f" +LM rescoring WER: {np.round(rescored_wer * 100, 2)}%")
|
||||
logging.info(f" with alpha={coef1}, beta={coef2}")
|
||||
logging.info(f"------------------------------------------------")
|
||||
logging.info(f"Best possible WER: {np.round(ideal_wer.item() * 100, 2)}%")
|
||||
logging.info(f"------------------------------------------------")
|
||||
|
||||
new_scores_flatten = new_scores.flatten()
|
||||
if args.scores_output_file is not None:
|
||||
logging.info(f'Saving the candidates with their new scores at `{args.scores_output_file}`...')
|
||||
with open(args.scores_output_file, "w") as fout:
|
||||
for sample_id in range(len(dataset)):
|
||||
fout.write(f"{dataset.data[0][sample_id]}\t{new_scores_flatten[sample_id]}\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,345 @@
|
||||
# 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.
|
||||
#
|
||||
|
||||
# This script would evaluate an N-gram language model trained with KenLM library (https://github.com/kpu/kenlm) in
|
||||
# fusion with beam search decoders on top of a trained ASR model. NeMo's beam search decoders are capable of using the
|
||||
# KenLM's N-gram models to find the best candidates. This script supports both character level and BPE level
|
||||
# encodings and models which is detected automatically from the type of the model.
|
||||
# You may train the LM model with 'scripts/ngram_lm/train_kenlm.py'.
|
||||
#
|
||||
# USAGE: python eval_beamsearch_ngram.py --nemo_model_file <path to the .nemo file of the model> \
|
||||
# --input_manifest <path to the evaluation JSON manifest file \
|
||||
# --kenlm_model_file <path to the binary KenLM model> \
|
||||
# --beam_width <list of the beam widths> \
|
||||
# --beam_alpha <list of the beam alphas> \
|
||||
# --beam_width <list of the beam betas> \
|
||||
# --preds_output_folder <optional folder to store the predictions> \
|
||||
# --decoding_mode beam_search_ngram
|
||||
# ...
|
||||
#
|
||||
# You may find more info on how to use this script at:
|
||||
# https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
|
||||
# Please check train_kenlm.py to find out why we need TOKEN_OFFSET for BPE-based models
|
||||
TOKEN_OFFSET = 100
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
|
||||
import editdistance
|
||||
import kenlm_utils
|
||||
import numpy as np
|
||||
import torch
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def beam_search_eval(
|
||||
all_probs,
|
||||
target_transcripts,
|
||||
vocab,
|
||||
ids_to_text_func=None,
|
||||
preds_output_file=None,
|
||||
lm_path=None,
|
||||
beam_alpha=1.0,
|
||||
beam_beta=0.0,
|
||||
beam_width=128,
|
||||
beam_batch_size=128,
|
||||
progress_bar=True,
|
||||
):
|
||||
# creating the beam search decoder
|
||||
beam_search_lm = nemo_asr.modules.BeamSearchDecoderWithLM(
|
||||
vocab=vocab,
|
||||
beam_width=beam_width,
|
||||
alpha=beam_alpha,
|
||||
beta=beam_beta,
|
||||
lm_path=lm_path,
|
||||
num_cpus=max(os.cpu_count(), 1),
|
||||
input_tensor=False,
|
||||
)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
wer_dist_best = cer_dist_best = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
sample_idx = 0
|
||||
if preds_output_file:
|
||||
out_file = open(preds_output_file, 'w')
|
||||
|
||||
if progress_bar:
|
||||
it = tqdm(
|
||||
range(int(np.ceil(len(all_probs) / beam_batch_size))),
|
||||
desc=f"Beam search decoding with width={beam_width}, alpha={beam_alpha}, beta={beam_beta}",
|
||||
ncols=120,
|
||||
)
|
||||
else:
|
||||
it = range(int(np.ceil(len(all_probs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
# disabling type checking
|
||||
with nemo.core.typecheck.disable_checks():
|
||||
probs_batch = all_probs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
beams_batch = beam_search_lm.forward(log_probs=probs_batch, log_probs_length=None,)
|
||||
|
||||
for beams_idx, beams in enumerate(beams_batch):
|
||||
target = target_transcripts[sample_idx + beams_idx]
|
||||
target_split_w = target.split()
|
||||
target_split_c = list(target)
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
wer_dist_min = cer_dist_min = 10000
|
||||
for candidate_idx, candidate in enumerate(beams):
|
||||
if ids_to_text_func is not None:
|
||||
# For BPE encodings, need to shift by TOKEN_OFFSET to retrieve the original sub-word ids
|
||||
pred_text = ids_to_text_func([ord(c) - TOKEN_OFFSET for c in candidate[1]])
|
||||
else:
|
||||
pred_text = candidate[1]
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = editdistance.eval(target_split_w, pred_split_w)
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = editdistance.eval(target_split_c, pred_split_c)
|
||||
|
||||
wer_dist_min = min(wer_dist_min, wer_dist)
|
||||
cer_dist_min = min(cer_dist_min, cer_dist)
|
||||
|
||||
if candidate_idx == 0:
|
||||
# first candidate
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
|
||||
score = candidate[0]
|
||||
if preds_output_file:
|
||||
out_file.write('{}\t{}\n'.format(pred_text, score))
|
||||
wer_dist_best += wer_dist_min
|
||||
cer_dist_best += cer_dist_min
|
||||
sample_idx += len(probs_batch)
|
||||
|
||||
if preds_output_file:
|
||||
out_file.close()
|
||||
logging.info(f"Stored the predictions of beam search decoding at '{preds_output_file}'.")
|
||||
|
||||
if lm_path:
|
||||
logging.info(
|
||||
'WER/CER with beam search decoding and N-gram model = {:.2%}/{:.2%}'.format(
|
||||
wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
)
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
'WER/CER with beam search decoding = {:.2%}/{:.2%}'.format(
|
||||
wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
)
|
||||
)
|
||||
logging.info(
|
||||
'Best WER/CER in candidates = {:.2%}/{:.2%}'.format(wer_dist_best / words_count, cer_dist_best / chars_count)
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Evaluate an ASR model with beam search decoding and n-gram KenLM language model.'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nemo_model_file", required=True, type=str, help="The path of the '.nemo' file of the ASR model"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kenlm_model_file", required=False, default=None, type=str, help="The path of the KenLM binary model file"
|
||||
)
|
||||
parser.add_argument("--input_manifest", required=True, type=str, help="The manifest file of the evaluation set")
|
||||
parser.add_argument(
|
||||
"--preds_output_folder", default=None, type=str, help="The optional folder where the predictions are stored"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--probs_cache_file", default=None, type=str, help="The cache file for storing the outputs of the model"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acoustic_batch_size", default=16, type=int, help="The batch size to calculate log probabilities"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device", default="cuda", type=str, help="The device to load the model onto to calculate log probabilities"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_amp", action="store_true", help="Whether to use AMP if available to calculate log probabilities"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--decoding_mode",
|
||||
choices=["greedy", "beamsearch", "beamsearch_ngram"],
|
||||
default="beamsearch_ngram",
|
||||
type=str,
|
||||
help="The decoding scheme to be used for evaluation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--beam_width",
|
||||
required=True,
|
||||
type=int,
|
||||
nargs="+",
|
||||
help="The width or list of the widths for the beam search decoding",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--beam_alpha",
|
||||
required=True,
|
||||
type=float,
|
||||
nargs="+",
|
||||
help="The alpha parameter or list of the alphas for the beam search decoding",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--beam_beta",
|
||||
required=True,
|
||||
type=float,
|
||||
nargs="+",
|
||||
help="The beta parameter or list of the betas for the beam search decoding",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--beam_batch_size", default=128, type=int, help="The batch size to be used for beam search decoding"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asr_model = nemo_asr.models.EncDecCTCModelBPE.restore_from(
|
||||
args.nemo_model_file, map_location=torch.device(args.device)
|
||||
)
|
||||
|
||||
target_transcripts = []
|
||||
with open(args.input_manifest, 'r') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {args.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(data['audio_filepath'])
|
||||
|
||||
if args.probs_cache_file and os.path.exists(args.probs_cache_file):
|
||||
logging.info(f"Found a pickle file of probabilities at '{args.probs_cache_file}'.")
|
||||
logging.info(f"Loading the cached pickle file of probabilities from '{args.probs_cache_file}' ...")
|
||||
with open(args.probs_cache_file, 'rb') as probs_file:
|
||||
all_probs = pickle.load(probs_file)
|
||||
|
||||
if len(all_probs) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the probabilities file '{args.probs_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the probabilities cached file."
|
||||
)
|
||||
else:
|
||||
if args.use_amp:
|
||||
if torch.cuda.is_available() and hasattr(torch.cuda, 'amp') and hasattr(torch.cuda.amp, 'autocast'):
|
||||
logging.info("AMP is enabled!\n")
|
||||
autocast = torch.cuda.amp.autocast
|
||||
else:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def autocast():
|
||||
yield
|
||||
|
||||
with autocast():
|
||||
with torch.no_grad():
|
||||
all_logits = asr_model.transcribe(audio_file_paths, batch_size=args.acoustic_batch_size, logprobs=True)
|
||||
all_probs = [kenlm_utils.softmax(logits) for logits in all_logits]
|
||||
if args.probs_cache_file:
|
||||
logging.info(f"Writing pickle files of probabilities at '{args.probs_cache_file}'...")
|
||||
with open(args.probs_cache_file, 'wb') as f_dump:
|
||||
pickle.dump(all_probs, f_dump)
|
||||
|
||||
wer_dist_greedy = 0
|
||||
cer_dist_greedy = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
for batch_idx, probs in enumerate(all_probs):
|
||||
preds = np.argmax(probs, axis=1)
|
||||
preds_tensor = torch.tensor(preds, device='cpu').unsqueeze(0)
|
||||
pred_text = asr_model._wer.ctc_decoder_predictions_tensor(preds_tensor)[0]
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
target_split_w = target_transcripts[batch_idx].split()
|
||||
pred_split_c = list(pred_text)
|
||||
target_split_c = list(target_transcripts[batch_idx])
|
||||
|
||||
wer_dist = editdistance.eval(target_split_w, pred_split_w)
|
||||
cer_dist = editdistance.eval(target_split_c, pred_split_c)
|
||||
|
||||
wer_dist_greedy += wer_dist
|
||||
cer_dist_greedy += cer_dist
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
|
||||
logging.info('Greedy WER/CER = {:.2%}/{:.2%}'.format(wer_dist_greedy / words_count, cer_dist_greedy / chars_count))
|
||||
|
||||
encoding_level = kenlm_utils.SUPPORTED_MODELS.get(type(asr_model).__name__, None)
|
||||
if not encoding_level:
|
||||
logging.warning(
|
||||
f"Model type '{type(asr_model).__name__}' may not be supported. Would try to train a char-level LM."
|
||||
)
|
||||
encoding_level = 'char'
|
||||
|
||||
vocab = asr_model.decoder.vocabulary
|
||||
ids_to_text_func = None
|
||||
if encoding_level == "subword":
|
||||
vocab = [chr(idx + TOKEN_OFFSET) for idx in range(len(vocab))]
|
||||
ids_to_text_func = asr_model.tokenizer.ids_to_text
|
||||
# delete the model to free the memory
|
||||
del asr_model
|
||||
|
||||
if args.decoding_mode == "beamsearch_ngram":
|
||||
if not os.path.exists(args.kenlm_model_file):
|
||||
raise FileNotFoundError(f"Could not find the KenLM model file '{args.kenlm_model_file}'.")
|
||||
lm_path = args.kenlm_model_file
|
||||
else:
|
||||
lm_path = None
|
||||
|
||||
# 'greedy' decoding_mode would skip the beam search decoding
|
||||
if args.decoding_mode in ["beamsearch_ngram", "beamsearch"]:
|
||||
|
||||
params = {'beam_width': args.beam_width, 'beam_alpha': args.beam_alpha, 'beam_beta': args.beam_beta}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
logging.info(f"==============================Starting the beam search decoding===============================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
if args.preds_output_folder and not os.path.exists(args.preds_output_folder):
|
||||
os.mkdir(args.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if args.preds_output_folder:
|
||||
preds_output_file = os.path.join(
|
||||
args.preds_output_folder,
|
||||
f"preds_out_width{hp['beam_width']}_alpha{hp['beam_alpha']}_beta{hp['beam_beta']}.tsv",
|
||||
)
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
beam_search_eval(
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
vocab=vocab,
|
||||
ids_to_text_func=ids_to_text_func,
|
||||
preds_output_file=preds_output_file,
|
||||
lm_path=lm_path,
|
||||
beam_width=hp["beam_width"],
|
||||
beam_alpha=hp["beam_alpha"],
|
||||
beam_beta=hp["beam_beta"],
|
||||
beam_batch_size=args.beam_batch_size,
|
||||
progress_bar=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
git clone https://github.com/NVIDIA/OpenSeq2Seq -b ctc-decoders
|
||||
mv OpenSeq2Seq/decoders .
|
||||
rm -rf OpenSeq2Seq
|
||||
cd decoders
|
||||
./setup.sh
|
||||
cd ..
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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.
|
||||
#
|
||||
|
||||
"""
|
||||
Utility methods to be used for training N-gram LM with KenLM in train_kenlm.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from joblib import Parallel, delayed
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
# List of the supported models to be used with N-gram LM and beam search decoding
|
||||
SUPPORTED_MODELS = {
|
||||
'EncDecCTCModelBPE': 'subword',
|
||||
'EncDecCTCModel': 'char',
|
||||
}
|
||||
|
||||
|
||||
def softmax(x):
|
||||
e = np.exp(x - np.max(x))
|
||||
return e / e.sum(axis=-1).reshape([x.shape[0], 1])
|
||||
|
||||
|
||||
def read_train_file(path, lowercase: bool = False):
|
||||
lines_read = 0
|
||||
text_dataset = []
|
||||
|
||||
with open(path, 'r') as f:
|
||||
reader = tqdm(iter(lambda: f.readline(), ''), desc="Read 0 lines", unit=' lines')
|
||||
for i, line in enumerate(reader):
|
||||
if path.endswith('.json'):
|
||||
line = json.loads(line)['text']
|
||||
|
||||
line = line.replace("\n", "").strip()
|
||||
if lowercase:
|
||||
line = line.lower()
|
||||
|
||||
if line:
|
||||
text_dataset.append(line)
|
||||
|
||||
lines_read += 1
|
||||
if lines_read % 100000 == 0:
|
||||
reader.set_description(f"Read {lines_read} lines")
|
||||
|
||||
return text_dataset
|
||||
|
||||
|
||||
def tokenize_str(texts, tokenizer, offset):
|
||||
tokenized_text = []
|
||||
for text in texts:
|
||||
tok_text = tokenizer.text_to_ids(text)
|
||||
tok_text = [chr(token + offset) for token in tok_text]
|
||||
tokenized_text.append(tok_text)
|
||||
return tokenized_text
|
||||
|
||||
|
||||
def tokenize_text(data, tokenizer, path, chunk_size=8192, buffer_size=32, token_offset=100):
|
||||
dataset_len = len(data)
|
||||
print(
|
||||
f"Chunking {dataset_len} rows into {dataset_len / float(chunk_size):0.4f} tasks (each chunk contains {chunk_size} elements)"
|
||||
)
|
||||
|
||||
current_step = 0
|
||||
if os.path.exists(path):
|
||||
print(f"Deleting previous file : {path}")
|
||||
os.remove(path)
|
||||
|
||||
with Parallel(n_jobs=-2, verbose=10) as parallel:
|
||||
while True:
|
||||
start = current_step * chunk_size
|
||||
end = min((current_step + buffer_size) * chunk_size, dataset_len)
|
||||
|
||||
tokenized_data = parallel(
|
||||
delayed(tokenize_str)(data[start : start + chunk_size], tokenizer, token_offset)
|
||||
for start in range(start, end, chunk_size)
|
||||
)
|
||||
|
||||
# Write dataset
|
||||
write_dataset(tokenized_data, path)
|
||||
current_step += len(tokenized_data)
|
||||
print(f"Finished writing {len(tokenized_data)} chunks to {path}. Current chunk index = {current_step}")
|
||||
del tokenized_data
|
||||
if end >= dataset_len:
|
||||
break
|
||||
|
||||
|
||||
def write_dataset(chunks, path):
|
||||
basedir = os.path.dirname(path)
|
||||
|
||||
if not os.path.exists(basedir):
|
||||
os.makedirs(basedir, exist_ok=True)
|
||||
|
||||
with open(path, 'a+', encoding='utf-8') as f:
|
||||
for chunk_idx in tqdm(range(len(chunks)), desc='Chunk ', total=len(chunks), unit=' chunks'):
|
||||
for text in chunks[chunk_idx]:
|
||||
line = ' '.join(text)
|
||||
f.write(f"{line}\n")
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
#
|
||||
|
||||
|
||||
# This script would train an N-gram language model with KenLM library (https://github.com/kpu/kenlm) which can be used
|
||||
# with the beam search decoders on top of the ASR models. This script supports both character level and BPE level
|
||||
# encodings and models which is detected automatically from the type of the model.
|
||||
# After the N-gram model is trained, and stored in the binary format, you may use
|
||||
# 'scripts/ngram_lm/eval_beamsearch_ngram.py' to evaluate it on an ASR model.
|
||||
#
|
||||
# You need to install the KenLM library and also the beam search decoders to use this feature. Please refer
|
||||
# to 'scripts/ngram_lm/install_beamsearch_decoders.sh' on how to install them.
|
||||
#
|
||||
# USAGE: python train_kenlm.py --nemo_model_file <path to the .nemo file of the model> \
|
||||
# --train_file <path to the training text or JSON manifest file \
|
||||
# --kenlm_bin_path <path to the bin folder of KenLM library> \
|
||||
# --kenlm_model_file <path to store the binary KenLM model> \
|
||||
# --ngram_length <order of N-gram model>
|
||||
#
|
||||
# After training is done, the binary LM model is stored at the path specified by '--kenlm_model_file'.
|
||||
# You may find more info on how to use this script at:
|
||||
# https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import kenlm_utils
|
||||
import torch
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.utils import logging
|
||||
|
||||
"""
|
||||
NeMo's beam search decoders only support char-level encodings. In order to make it work with BPE-level encodings, we
|
||||
use a trick to encode the sub-word tokens of the training data as unicode characters and train a char-level KenLM.
|
||||
TOKEN_OFFSET is the offset in the unicode table to be used to encode the BPE sub-words. This encoding scheme reduces
|
||||
the required memory significantly, and the LM and its binary blob format require less storage space.
|
||||
"""
|
||||
TOKEN_OFFSET = 100
|
||||
|
||||
CHUNK_SIZE = 8192
|
||||
CHUNK_BUFFER_SIZE = 512
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Train an N-gram language model with KenLM to be used with beam search decoder of ASR models.'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--train_file",
|
||||
required=True,
|
||||
type=str,
|
||||
help="Path to the training file, it can be a text file or JSON manifest",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nemo_model_file", required=True, type=str, help="The path of the '.nemo' file of the ASR model"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kenlm_model_file", required=True, type=str, help="The path to store the KenLM binary model file"
|
||||
)
|
||||
parser.add_argument("--ngram_length", required=True, type=int, help="The order of N-gram LM")
|
||||
parser.add_argument("--kenlm_bin_path", required=True, type=str, help="The path to the bin folder of KenLM")
|
||||
parser.add_argument(
|
||||
"--do_lowercase", action='store_true', help="Whether to apply lower case conversion on the training text"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
""" TOKENIZER SETUP """
|
||||
logging.info(f"Loading nemo model '{args.nemo_model_file}' ...")
|
||||
model = nemo_asr.models.ASRModel.restore_from(args.nemo_model_file, map_location=torch.device('cpu'))
|
||||
|
||||
encoding_level = kenlm_utils.SUPPORTED_MODELS.get(type(model).__name__, None)
|
||||
if not encoding_level:
|
||||
logging.warning(
|
||||
f"Model type '{type(model).__name__}' may not be supported. Would try to train a char-level LM."
|
||||
)
|
||||
encoding_level = 'char'
|
||||
|
||||
""" DATASET SETUP """
|
||||
logging.info(f"Encoding the train file '{args.train_file}' ...")
|
||||
dataset = kenlm_utils.read_train_file(args.train_file, lowercase=args.do_lowercase)
|
||||
encoded_train_file = f"{args.kenlm_model_file}.tmp.txt"
|
||||
if encoding_level == "subword":
|
||||
kenlm_utils.tokenize_text(
|
||||
dataset,
|
||||
model.tokenizer,
|
||||
path=encoded_train_file,
|
||||
chunk_size=CHUNK_SIZE,
|
||||
buffer_size=CHUNK_BUFFER_SIZE,
|
||||
token_offset=TOKEN_OFFSET,
|
||||
)
|
||||
# --discount_fallback is needed for training KenLM for BPE-based models
|
||||
discount_arg = "--discount_fallback"
|
||||
else:
|
||||
with open(encoded_train_file, 'w', encoding='utf-8') as f:
|
||||
for line in dataset:
|
||||
f.write(f"{line}\n")
|
||||
|
||||
discount_arg = ""
|
||||
|
||||
del model
|
||||
|
||||
arpa_file = f"{args.kenlm_model_file}.tmp.arpa"
|
||||
""" LMPLZ ARGUMENT SETUP """
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, 'lmplz'),
|
||||
"-o",
|
||||
f"{args.ngram_length}",
|
||||
"--text",
|
||||
encoded_train_file,
|
||||
"--arpa",
|
||||
arpa_file,
|
||||
discount_arg,
|
||||
]
|
||||
|
||||
ret = subprocess.run(kenlm_args, capture_output=False, text=True, stdout=sys.stdout, stderr=sys.stderr)
|
||||
if ret.returncode != 0:
|
||||
raise RuntimeError("Training KenLM was not successful!")
|
||||
""" BINARY BUILD """
|
||||
logging.info(f"Running binary_build command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, "build_binary"),
|
||||
"trie",
|
||||
arpa_file,
|
||||
args.kenlm_model_file,
|
||||
]
|
||||
ret = subprocess.run(kenlm_args, capture_output=False, text=True, stdout=sys.stdout, stderr=sys.stderr)
|
||||
|
||||
if ret.returncode != 0:
|
||||
raise RuntimeError("Training KenLM was not successful!")
|
||||
|
||||
os.remove(encoded_train_file)
|
||||
logging.info(f"Deleted the temporary encoded training file '{encoded_train_file}'.")
|
||||
os.remove(arpa_file)
|
||||
logging.info(f"Deleted the arpa file '{arpa_file}'.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user