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:
+317
@@ -0,0 +1,317 @@
|
||||
# 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.common.tokenizers.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', encoding='utf-8') 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', encoding='utf-8')
|
||||
|
||||
# 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,326 @@
|
||||
# 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 inspect
|
||||
import json
|
||||
from abc import ABC
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import tqdm
|
||||
from kaldialign import edit_distance
|
||||
from transformers import AutoModelForCausalLM
|
||||
|
||||
try:
|
||||
from nemo.collections.nlp.models.language_modeling import TransformerLMModel
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
TransformerLMModel = ABC
|
||||
|
||||
from nemo.collections.common.tokenizers.tokenizer_utils import get_tokenizer
|
||||
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', encoding='utf-8') 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
|
||||
|
||||
if self.tokenizer.pad_id is not None:
|
||||
self.pad_id = self.tokenizer.pad_id
|
||||
elif self.tokenizer.eos_id is not None:
|
||||
self.pad_id = self.tokenizer.eos_id
|
||||
else:
|
||||
logging.warning(f"Using 0 as pad_id as the tokenizer has no pad_id or eos_id.")
|
||||
self.pad_id = 0
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
text = str(self.data[0][idx])
|
||||
tokens = self.tokenizer.text_to_ids(text)
|
||||
if self.tokenizer.bos_id is not None:
|
||||
tokens = [self.tokenizer.bos_id] + tokens
|
||||
if self.tokenizer.eos_id is not None:
|
||||
tokens = tokens + [self.tokenizer.eos_id]
|
||||
input_ids = [self.pad_id] * self.max_seq_length
|
||||
input_ids[: len(tokens)] = tokens
|
||||
input_ids = np.array(input_ids)
|
||||
input_mask = np.zeros(self.max_seq_length)
|
||||
input_mask[: len(tokens)] = 1
|
||||
acoustic_score = self.data[1][idx]
|
||||
dist = edit_distance(self.ground_truths[idx // self.beam_size].split(), text.split())['total']
|
||||
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 achieved)
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
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 or the name of a HuggingFace pretrained models like 'transfo-xl-wt103' or 'gpt2'",
|
||||
)
|
||||
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("--max_seq_length", default=512, help="Maximum sequence length (in tokens) for the input")
|
||||
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"):
|
||||
nemo_model = True
|
||||
logging.info("Attempting to initialize from .nemo file...")
|
||||
model = TransformerLMModel.restore_from(
|
||||
restore_path=args.lm_model_file, map_location=torch.device(device)
|
||||
).eval()
|
||||
model_tokenizer = model.tokenizer
|
||||
else:
|
||||
nemo_model = False
|
||||
logging.info("Attempting to initialize from a pretrained model from HuggingFace...")
|
||||
model = (
|
||||
AutoModelForCausalLM.from_pretrained(pretrained_model_name_or_path=args.lm_model_file, is_decoder=True)
|
||||
.to(device)
|
||||
.eval()
|
||||
)
|
||||
model_tokenizer = get_tokenizer(tokenizer_name=args.lm_model_file)
|
||||
|
||||
max_seq_length = args.max_seq_length
|
||||
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 "attention_mask" in inspect.getfullargspec(model.forward).args:
|
||||
support_att_mask = True
|
||||
else:
|
||||
support_att_mask = False
|
||||
logging.info(f"Rescoring with beam_size: {args.beam_size}")
|
||||
logging.info("Calculating the scores...")
|
||||
with torch.amp.autocast(model.device.type, enabled=args.use_amp):
|
||||
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),
|
||||
)
|
||||
# some models like Transformer-XL don't need attention_mask as input
|
||||
if support_att_mask:
|
||||
log_probs = model(input_ids=input_ids, attention_mask=input_mask)
|
||||
else:
|
||||
log_probs = model(input_ids=input_ids)
|
||||
|
||||
if not nemo_model:
|
||||
log_probs = torch.nn.functional.log_softmax(log_probs.logits, dim=-1)
|
||||
|
||||
target_log_probs = log_probs[:, :-1].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"Oracle 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", encoding='utf-8') 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,79 @@
|
||||
# Copyright (c) 2023, 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.
|
||||
#
|
||||
# Use this file to create a lexicon file for Flashlight decoding from an existing KenLM arpa file
|
||||
# A lexicon file is required for Flashlight decoding in most cases, as it acts as a map from the words
|
||||
# in you arpa file to the representation used by your ASR AM.
|
||||
# For more details, see: https://github.com/flashlight/flashlight/tree/main/flashlight/app/asr#data-preparation
|
||||
#
|
||||
# Usage: python create_lexicon_from_arpa.py --arpa /path/to/english.arpa --model /path/to/model.nemo --lower
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Utility script for generating lexicon file from a KenLM arpa file")
|
||||
parser.add_argument("--arpa", required=True, help="path to your arpa file")
|
||||
parser.add_argument("--dst", help="directory to store generated lexicon", default=None)
|
||||
parser.add_argument("--lower", action='store_true', help="Whether to lowercase the arpa vocab")
|
||||
parser.add_argument("--model", default=None, help="path to Nemo model for its tokeniser")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.arpa):
|
||||
logging.critical(f"ARPA file [ {args.arpa} ] not detected on disk, aborting!")
|
||||
exit(255)
|
||||
|
||||
if args.dst is not None:
|
||||
save_path = args.dst
|
||||
else:
|
||||
save_path = os.path.dirname(args.arpa)
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
tokenizer = None
|
||||
if args.model is not None:
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
model = ASRModel.restore_from(restore_path=args.model, map_location='cpu')
|
||||
if hasattr(model, 'tokenizer'):
|
||||
tokenizer = model.tokenizer
|
||||
else:
|
||||
logging.warning('Supplied Nemo model does not contain a tokenizer')
|
||||
|
||||
lex_file = os.path.join(save_path, os.path.splitext(os.path.basename(args.arpa))[0] + '.lexicon')
|
||||
|
||||
logging.info(f"Writing Lexicon file to: {lex_file}...")
|
||||
with open(lex_file, "w", encoding='utf_8', newline='\n') as f:
|
||||
with open(args.arpa, "r", encoding='utf_8') as arpa:
|
||||
for line in arpa:
|
||||
# verify if the line corresponds to unigram
|
||||
if not re.match(r"[-]*[0-9\.]+\t\S+\t*[-]*[0-9\.]*$", line):
|
||||
continue
|
||||
word = line.split("\t")[1]
|
||||
word = word.strip().lower() if args.lower else word.strip()
|
||||
if word == "<UNK>" or word == "<unk>" or word == "<s>" or word == "</s>":
|
||||
continue
|
||||
|
||||
if tokenizer is None:
|
||||
f.write("{w}\t{s}\n".format(w=word, s=" ".join(word)))
|
||||
else:
|
||||
w_ids = tokenizer.text_to_ids(word)
|
||||
if tokenizer.unk_id not in w_ids:
|
||||
f.write("{w}\t{s}\n".format(w=word, s=" ".join(tokenizer.text_to_tokens(word))))
|
||||
@@ -0,0 +1,406 @@
|
||||
# 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 with CTC decoder. To evaluate a model with
|
||||
# Transducer (RNN-T) decoder use another script 'scripts/asr_language_modeling/ngram_lm/eval_beamsearch_ngram_transducer.py'.
|
||||
# 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/asr_language_modeling/ngram_lm/train_kenlm.py'.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_beamsearch_ngram_ctc.py --help
|
||||
python eval_beamsearch_ngram_ctc.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_beamsearch_ngram_ctc.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, separated with commas>] \
|
||||
beam_alpha=[<list of the beam alphas, separated with commas>] \
|
||||
beam_beta=[<list of the beam betas, separated with commas>] \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null \
|
||||
decoding_mode=beamsearch_ngram
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[4,8,16,....] \
|
||||
beam_alpha=[-2.0,-1.0,...,1.0,2.0] \
|
||||
beam_beta=[-1.0,-0.5,0.0,...,1.0] \
|
||||
|
||||
# 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
|
||||
import os
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules import ctc_beam_decoding
|
||||
from nemo.collections.asr.parts.utils.transcribe_utils import PunctuationCapitalization, TextProcessingConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalBeamSearchNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with beam search decoding and n-gram KenLM language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
kenlm_model_file: Optional[str] = None # The path of the KenLM binary model file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
hyps_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
batch_size: int = 16 # The batch size
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
|
||||
# Beam Search hyperparameters
|
||||
|
||||
# The decoding scheme to be used for evaluation.
|
||||
# Can be one of ["greedy", "beamsearch", "beamsearch_ngram"]
|
||||
decoding_mode: str = "beamsearch_ngram"
|
||||
|
||||
beam_width: List[int] = field(default_factory=lambda: [128]) # The width or list of the widths for the beam search decoding
|
||||
beam_alpha: List[float] = field(default_factory=lambda: [1.0]) # The alpha parameter or list of the alphas for the beam search decoding
|
||||
beam_beta: List[float] = field(default_factory=lambda: [0.0]) # The beta parameter or list of the betas for the beam search decoding
|
||||
|
||||
decoding_strategy: str = "beam"
|
||||
decoding: ctc_beam_decoding.BeamCTCInferConfig = field(default_factory=lambda: ctc_beam_decoding.BeamCTCInferConfig(beam_size=128))
|
||||
|
||||
text_processing: Optional[TextProcessingConfig] = field(default_factory=lambda: TextProcessingConfig(
|
||||
punctuation_marks = ".,?",
|
||||
separate_punctuation = False,
|
||||
do_lowercase = False,
|
||||
rm_punctuation = False,
|
||||
))
|
||||
# fmt: on
|
||||
|
||||
|
||||
def apply_text_processing(
|
||||
punctuation_capitalization: PunctuationCapitalization, cfg: EvalBeamSearchNGramConfig, text: List[str] | str
|
||||
) -> List[str] | str:
|
||||
is_list = isinstance(text, list)
|
||||
text_arr = text if is_list else [text]
|
||||
if cfg.text_processing.do_lowercase:
|
||||
text_arr = punctuation_capitalization.do_lowercase(text_arr)
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
text_arr = punctuation_capitalization.rm_punctuation(text_arr)
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
text_arr = punctuation_capitalization.separate_punctuation(text_arr)
|
||||
|
||||
return text_arr if is_list else text_arr[0]
|
||||
|
||||
|
||||
def beam_search_eval(
|
||||
audio_filepaths,
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalBeamSearchNGramConfig,
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
lm_path: str = None,
|
||||
beam_alpha: float = 1.0,
|
||||
beam_beta: float = 0.0,
|
||||
beam_width: int = 128,
|
||||
punctuation_capitalization: PunctuationCapitalization = None,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(decoding_cfg=None, decoder_type="ctc")
|
||||
else:
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.beam_size = beam_width
|
||||
cfg.decoding.ngram_lm_alpha = beam_alpha
|
||||
cfg.decoding.beam_beta = beam_beta
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.ngram_lm_model = cfg.kenlm_model_file
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = cfg.decoding_strategy
|
||||
model.cfg.decoding.beam = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(model.cfg.decoding, decoder_type='ctc')
|
||||
else:
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
logging.setLevel(level)
|
||||
|
||||
all_hyps = model.transcribe(audio_filepaths, cfg.batch_size)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
wer_dist_best = cer_dist_best = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
if preds_output_file:
|
||||
out_file = open(preds_output_file, 'w', encoding='utf_8', newline='\n')
|
||||
|
||||
for batch_idx, nbest_hyp in enumerate(all_hyps):
|
||||
target = target_transcripts[batch_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 = float("inf")
|
||||
for candidate_idx, candidate in enumerate(nbest_hyp):
|
||||
pred_text = apply_text_processing(punctuation_capitalization, cfg, candidate.text)
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
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.score
|
||||
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
|
||||
|
||||
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(
|
||||
'Oracle WER/CER in candidates with perfect LM= {:.2%}/{:.2%}'.format(
|
||||
wer_dist_best / words_count, cer_dist_best / chars_count
|
||||
)
|
||||
)
|
||||
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalBeamSearchNGramConfig', schema=EvalBeamSearchNGramConfig)
|
||||
def main(cfg: EvalBeamSearchNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalBeamSearchNGramConfig
|
||||
|
||||
valid_decoding_modes = ["greedy", "beamsearch", "beamsearch_ngram"]
|
||||
if cfg.decoding_mode not in valid_decoding_modes:
|
||||
raise ValueError(
|
||||
f"Given decoding_mode={cfg.decoding_mode} is invalid. Available options are :\n" f"{valid_decoding_modes}"
|
||||
)
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
punctuation_capitalization = PunctuationCapitalization(cfg.text_processing.punctuation_marks)
|
||||
target_transcripts = apply_text_processing(punctuation_capitalization, cfg, target_transcripts)
|
||||
|
||||
if cfg.hyps_cache_file and os.path.exists(cfg.hyps_cache_file):
|
||||
logging.info(f"Found a cached file of hypotheses at '{cfg.hyps_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of hypotheses from '{cfg.hyps_cache_file}' ...")
|
||||
with open(cfg.hyps_cache_file, 'rb') as probs_file:
|
||||
all_hyps = msgpack.load(probs_file)
|
||||
|
||||
if len(all_hyps) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the hypotheses file '{cfg.hyps_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the hypotheses cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
asr_model.cur_decoder = 'ctc'
|
||||
all_hyps = asr_model.transcribe(audio_file_paths, batch_size=cfg.batch_size)
|
||||
|
||||
if cfg.hyps_cache_file:
|
||||
os.makedirs(os.path.split(cfg.hyps_cache_file)[0], exist_ok=True)
|
||||
logging.info(f"Writing cached files of hypotheses at '{cfg.hyps_cache_file}'...")
|
||||
with open(cfg.hyps_cache_file, 'wb') as f_dump:
|
||||
msgpack.dump(all_hyps, f_dump)
|
||||
|
||||
wer_dist_greedy = 0
|
||||
cer_dist_greedy = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
for batch_idx, hyp in enumerate(all_hyps):
|
||||
pred_text = apply_text_processing(punctuation_capitalization, cfg, hyp.text)
|
||||
|
||||
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 = edit_distance(target_split_w, pred_split_w)['total']
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
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))
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
if cfg.decoding_mode == "beamsearch_ngram":
|
||||
if not os.path.exists(cfg.kenlm_model_file):
|
||||
raise FileNotFoundError(f"Could not find the KenLM model file '{cfg.kenlm_model_file}'.")
|
||||
lm_path = cfg.kenlm_model_file
|
||||
else:
|
||||
lm_path = None
|
||||
|
||||
# 'greedy' decoding_mode would skip the beam search decoding
|
||||
if cfg.decoding_mode in ["beamsearch_ngram", "beamsearch"]:
|
||||
if cfg.beam_width is None or cfg.beam_alpha is None or cfg.beam_beta is None:
|
||||
raise ValueError("beam_width, beam_alpha and beam_beta are needed to perform beam search decoding.")
|
||||
params = {'beam_width': cfg.beam_width, 'beam_alpha': cfg.beam_alpha, 'beam_beta': cfg.beam_beta}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_size, best_cer_beam_size = None, None
|
||||
best_wer_alpha, best_cer_alpha = None, None
|
||||
best_wer_beta, best_cer_beta = None, None
|
||||
best_wer, best_cer = float("inf"), float("inf")
|
||||
|
||||
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 cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
preds_output_file = os.path.join(
|
||||
cfg.preds_output_folder,
|
||||
f"preds_out_width{hp['beam_width']}_alpha{hp['beam_alpha']}_beta{hp['beam_beta']}.tsv",
|
||||
)
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
candidate_wer, candidate_cer = beam_search_eval(
|
||||
audio_file_paths,
|
||||
asr_model,
|
||||
cfg,
|
||||
target_transcripts=target_transcripts,
|
||||
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"],
|
||||
punctuation_capitalization=punctuation_capitalization,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_size, best_cer_alpha, best_cer_beta, best_cer = (
|
||||
hp["beam_width"],
|
||||
hp["beam_alpha"],
|
||||
hp["beam_beta"],
|
||||
candidate_cer,
|
||||
)
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_size, best_wer_alpha, best_wer_beta, best_wer = (
|
||||
hp["beam_width"],
|
||||
hp["beam_alpha"],
|
||||
hp["beam_beta"],
|
||||
candidate_wer,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_size}, '
|
||||
f'Beam alpha = {best_wer_alpha}, Beam beta = {best_wer_beta}'
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_size}, '
|
||||
f'Beam alpha = {best_cer_alpha}, Beam beta = {best_cer_beta}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,444 @@
|
||||
# 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.
|
||||
#
|
||||
|
||||
"""
|
||||
# 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 Transducer 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'.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_beamsearch_ngram.py --help
|
||||
python eval_beamsearch_ngram.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_beamsearch_ngram_transducer.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, separated with commas>] \
|
||||
beam_alpha=[<list of the beam alphas, separated with commas>] \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null \
|
||||
decoding_strategy=<greedy_batch or maes decoding>
|
||||
maes_prefix_alpha=[<list of the maes prefix alphas, separated with commas>] \
|
||||
maes_expansion_gamma=[<list of the maes expansion gammas, separated with commas>] \
|
||||
hat_subtract_ilm=<in case of HAT model: subtract internal LM or not> \
|
||||
hat_ilm_weight=[<in case of HAT model: list of the HAT internal LM weights, separated with commas>] \
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[4,8,16,....] \
|
||||
beam_alpha=[-2.0,-1.0,...,1.0,2.0] \
|
||||
|
||||
# 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
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalBeamSearchNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with beam search decoding and n-gram KenLM language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
kenlm_model_file: Optional[str] = None # The path of the KenLM binary model file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
probs_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
acoustic_batch_size: int = 128 # The batch size to calculate log probabilities
|
||||
beam_batch_size: int = 128 # The batch size to be used for beam search decoding
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
num_workers: int = 1 # Number of workers for DataLoader
|
||||
|
||||
# The decoding scheme to be used for evaluation
|
||||
decoding_strategy: str = "greedy_batch" # ["greedy_batch", "beam", "tsd", "alsd", "maes"]
|
||||
|
||||
# Beam Search hyperparameters
|
||||
beam_width: List[int] = field(default_factory=lambda: [8]) # The width or list of the widths for the beam search decoding
|
||||
beam_alpha: List[float] = field(default_factory=lambda: [0.2]) # The alpha parameter or list of the alphas for the beam search decoding
|
||||
|
||||
maes_prefix_alpha: List[int] = field(default_factory=lambda: [2]) # The maes_prefix_alpha or list of the maes_prefix_alpha for the maes decoding
|
||||
maes_expansion_gamma: List[float] = field(default_factory=lambda: [2.3]) # The maes_expansion_gamma or list of the maes_expansion_gamma for the maes decoding
|
||||
|
||||
# HAT related parameters (only for internal lm subtraction)
|
||||
hat_subtract_ilm: bool = False
|
||||
hat_ilm_weight: List[float] = field(default_factory=lambda: [0.0])
|
||||
|
||||
decoding: rnnt_beam_decoding.BeamRNNTInferConfig = field(default_factory=lambda: rnnt_beam_decoding.BeamRNNTInferConfig(beam_size=128))
|
||||
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
def decoding_step(
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalBeamSearchNGramConfig,
|
||||
all_probs: List[torch.Tensor],
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
beam_batch_size: int = 128,
|
||||
progress_bar: bool = True,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
cfg.decoding.hat_ilm_weight = cfg.decoding.hat_ilm_weight * cfg.hat_subtract_ilm
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.ngram_lm_model = cfg.kenlm_model_file
|
||||
cfg.decoding.hat_subtract_ilm = cfg.hat_subtract_ilm
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = cfg.decoding_strategy
|
||||
model.cfg.decoding.beam = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
logging.setLevel(level)
|
||||
|
||||
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', encoding='utf_8', newline='\n')
|
||||
|
||||
if progress_bar:
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
description = "Greedy_batch decoding.."
|
||||
else:
|
||||
description = f"{cfg.decoding_strategy} decoding with bw={cfg.decoding.beam_size}, ba={cfg.decoding.ngram_lm_alpha}, ma={cfg.decoding.maes_prefix_alpha}, mg={cfg.decoding.maes_expansion_gamma}, hat_ilmw={cfg.decoding.hat_ilm_weight}"
|
||||
it = tqdm(range(int(np.ceil(len(all_probs) / beam_batch_size))), desc=description, ncols=120)
|
||||
else:
|
||||
it = range(int(np.ceil(len(all_probs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
# disabling type checking
|
||||
probs_batch = all_probs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
probs_lens = torch.tensor([prob.shape[-1] for prob in probs_batch])
|
||||
with torch.no_grad():
|
||||
packed_batch = torch.zeros(len(probs_batch), probs_batch[0].shape[0], max(probs_lens), device='cpu')
|
||||
|
||||
for prob_index in range(len(probs_batch)):
|
||||
packed_batch[prob_index, :, : probs_lens[prob_index]] = torch.tensor(
|
||||
probs_batch[prob_index].unsqueeze(0), device=packed_batch.device, dtype=packed_batch.dtype
|
||||
)
|
||||
beams_batch = model.decoding.rnnt_decoder_predictions_tensor(
|
||||
packed_batch,
|
||||
probs_lens,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
best_hyp_batch = [dec_hyp[0] for dec_hyp in beams_batch]
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
beams_batch = [[x] for x in best_hyp_batch]
|
||||
|
||||
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): # type: (int, rnnt_beam_decoding.rnnt_utils.Hypothesis)
|
||||
pred_text = candidate.text
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
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.score
|
||||
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 cfg.decoding_strategy == "greedy_batch":
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
if preds_output_file:
|
||||
out_file.close()
|
||||
logging.info(f"Stored the predictions of {cfg.decoding_strategy} decoding at '{preds_output_file}'.")
|
||||
|
||||
if cfg.decoding.ngram_lm_model:
|
||||
logging.info(
|
||||
f"WER/CER with {cfg.decoding_strategy} decoding and N-gram model = {wer_dist_first / words_count:.2%}/{cer_dist_first / chars_count:.2%}"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"WER/CER with {cfg.decoding_strategy} decoding = {wer_dist_first / words_count:.2%}/{cer_dist_first / chars_count:.2%}"
|
||||
)
|
||||
logging.info(
|
||||
f"Oracle WER/CER in candidates with perfect LM= {wer_dist_best / words_count:.2%}/{cer_dist_best / chars_count:.2%}"
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalBeamSearchNGramConfig', schema=EvalBeamSearchNGramConfig)
|
||||
def main(cfg: EvalBeamSearchNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalBeamSearchNGramConfig
|
||||
|
||||
valid_decoding_strategis = ["greedy_batch", "beam", "tsd", "alsd", "maes"]
|
||||
if cfg.decoding_strategy not in valid_decoding_strategis:
|
||||
raise ValueError(
|
||||
f"Given decoding_strategy={cfg.decoding_strategy} is invalid. Available options are :\n"
|
||||
f"{valid_decoding_strategis}"
|
||||
)
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
if cfg.kenlm_model_file:
|
||||
if not os.path.exists(cfg.kenlm_model_file):
|
||||
raise FileNotFoundError(f"Could not find the KenLM model file '{cfg.kenlm_model_file}'.")
|
||||
if cfg.decoding_strategy != "maes":
|
||||
raise ValueError(f"Decoding with kenlm model is supported only for maes decoding algorithm.")
|
||||
lm_path = cfg.kenlm_model_file
|
||||
else:
|
||||
lm_path = None
|
||||
cfg.beam_alpha = [0.0]
|
||||
if cfg.hat_subtract_ilm:
|
||||
assert lm_path, "kenlm must be set for hat internal lm subtraction"
|
||||
|
||||
if cfg.decoding_strategy != "maes":
|
||||
cfg.maes_prefix_alpha, cfg.maes_expansion_gamma, cfg.hat_ilm_weight = [0], [0], [0]
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
if cfg.probs_cache_file and os.path.exists(cfg.probs_cache_file):
|
||||
logging.info(f"Found a cached file of probabilities at '{cfg.probs_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of probabilities from '{cfg.probs_cache_file}' ...")
|
||||
with open(cfg.probs_cache_file, 'rb') as probs_file:
|
||||
all_probs = msgpack.load(probs_file)
|
||||
|
||||
if len(all_probs) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the probabilities file '{cfg.probs_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the probabilities cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
# manual calculation of encoder_embeddings
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
asr_model.eval()
|
||||
asr_model.encoder.freeze()
|
||||
device = next(asr_model.parameters()).device
|
||||
all_probs = []
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, 'manifest.json'), 'w', encoding='utf-8') as fp:
|
||||
for audio_file in audio_file_paths:
|
||||
entry = {'audio_filepath': audio_file, 'duration': 100000, 'text': ''}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
config = {
|
||||
'paths2audio_files': audio_file_paths,
|
||||
'batch_size': cfg.acoustic_batch_size,
|
||||
'temp_dir': tmpdir,
|
||||
'num_workers': cfg.num_workers,
|
||||
'channel_selector': None,
|
||||
'augmentor': None,
|
||||
}
|
||||
temporary_datalayer = asr_model._setup_transcribe_dataloader(config)
|
||||
for test_batch in tqdm(temporary_datalayer, desc="Transcribing", disable=True):
|
||||
encoded, encoded_len = asr_model.forward(
|
||||
input_signal=test_batch[0].to(device), input_signal_length=test_batch[1].to(device)
|
||||
)
|
||||
# dump encoder embeddings per file
|
||||
for idx in range(encoded.shape[0]):
|
||||
encoded_no_pad = encoded[idx, :, : encoded_len[idx]]
|
||||
all_probs.append(encoded_no_pad)
|
||||
|
||||
if cfg.probs_cache_file:
|
||||
logging.info(f"Writing cached files of probabilities at '{cfg.probs_cache_file}'...")
|
||||
with open(cfg.probs_cache_file, 'wb') as f_dump:
|
||||
msgpack.dump(all_probs, f_dump)
|
||||
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
asr_model = asr_model.to('cpu')
|
||||
candidate_wer, candidate_cer = decoding_step(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
)
|
||||
logging.info(f"Greedy batch WER/CER = {candidate_wer:.2%}/{candidate_cer:.2%}")
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
# 'greedy_batch' decoding_strategy would skip the beam search decoding
|
||||
if cfg.decoding_strategy in ["beam", "tsd", "alsd", "maes"]:
|
||||
if cfg.beam_width is None or cfg.beam_alpha is None:
|
||||
raise ValueError("beam_width and beam_alpha are needed to perform beam search decoding.")
|
||||
params = {
|
||||
'beam_width': cfg.beam_width,
|
||||
'beam_alpha': cfg.beam_alpha,
|
||||
'maes_prefix_alpha': cfg.maes_prefix_alpha,
|
||||
'maes_expansion_gamma': cfg.maes_expansion_gamma,
|
||||
'hat_ilm_weight': cfg.hat_ilm_weight,
|
||||
}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_size, best_cer_beam_size = None, None
|
||||
best_wer_alpha, best_cer_alpha = None, None
|
||||
best_wer, best_cer = 1e6, 1e6
|
||||
|
||||
logging.info(
|
||||
f"==============================Starting the {cfg.decoding_strategy} decoding==============================="
|
||||
)
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
if cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
results_file = f"preds_out_{cfg.decoding_strategy}_bw{hp['beam_width']}"
|
||||
if cfg.decoding_strategy == "maes":
|
||||
results_file = f"{results_file}_ma{hp['maes_prefix_alpha']}_mg{hp['maes_expansion_gamma']}"
|
||||
if cfg.kenlm_model_file:
|
||||
results_file = f"{results_file}_ba{hp['beam_alpha']}"
|
||||
if cfg.hat_subtract_ilm:
|
||||
results_file = f"{results_file}_hat_ilmw{hp['hat_ilm_weight']}"
|
||||
preds_output_file = os.path.join(cfg.preds_output_folder, f"{results_file}.tsv")
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
cfg.decoding.beam_size = hp["beam_width"]
|
||||
cfg.decoding.ngram_lm_alpha = hp["beam_alpha"]
|
||||
cfg.decoding.maes_prefix_alpha = hp["maes_prefix_alpha"]
|
||||
cfg.decoding.maes_expansion_gamma = hp["maes_expansion_gamma"]
|
||||
cfg.decoding.hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
|
||||
candidate_wer, candidate_cer = decoding_step(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
preds_output_file=preds_output_file,
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_size = hp["beam_width"]
|
||||
best_cer_alpha = hp["beam_alpha"]
|
||||
best_cer_ma = hp["maes_prefix_alpha"]
|
||||
best_cer_mg = hp["maes_expansion_gamma"]
|
||||
best_cer_hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
best_cer = candidate_cer
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_size = hp["beam_width"]
|
||||
best_wer_alpha = hp["beam_alpha"]
|
||||
best_wer_ma = hp["maes_prefix_alpha"]
|
||||
best_wer_ga = hp["maes_expansion_gamma"]
|
||||
best_wer_hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
best_wer = candidate_wer
|
||||
|
||||
wer_hat_parameter = ""
|
||||
if cfg.hat_subtract_ilm:
|
||||
wer_hat_parameter = f"HAT ilm weight = {best_wer_hat_ilm_weight}, "
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_size}, '
|
||||
f'Beam alpha = {best_wer_alpha}, {wer_hat_parameter}'
|
||||
f'maes_prefix_alpha = {best_wer_ma}, maes_expansion_gamma = {best_wer_ga} '
|
||||
)
|
||||
|
||||
cer_hat_parameter = ""
|
||||
if cfg.hat_subtract_ilm:
|
||||
cer_hat_parameter = f"HAT ilm weight = {best_cer_hat_ilm_weight}"
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_size}, '
|
||||
f'Beam alpha = {best_cer_alpha}, {cer_hat_parameter} '
|
||||
f'maes_prefix_alpha = {best_cer_ma}, maes_expansion_gamma = {best_cer_mg}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,424 @@
|
||||
# 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.
|
||||
#
|
||||
|
||||
"""
|
||||
# This script would evaluate an N-gram language model in ARPA format in
|
||||
# fusion with WFST decoders on top of a trained ASR model with CTC decoder.
|
||||
# NeMo's WFST decoders use WFST decoding graphs made from ARPA LMs
|
||||
# to find the best candidates. This script supports BPE level encodings only
|
||||
# and models which is detected automatically from the type of the model.
|
||||
# You may train the LM model with e.g. SRILM.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_wfst_decoding_ctc.py --help
|
||||
python eval_wfst_decoding_ctc.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_wfst_decoding_ctc.py nemo_model_file=<path to the .nemo file of the model> \
|
||||
input_manifest=<path to the evaluation JSON manifest file> \
|
||||
arpa_model_file=<path to the ARPA LM model> \
|
||||
decoding_wfst_file=<path to the decoding WFST file> \
|
||||
beam_width=[<list of the beam widths, separated with commas>] \
|
||||
lm_weight=[<list of the LM weight multipliers, separated with commas>] \
|
||||
decoding_mode=<decoding mode, affects output. Usually "nbest"> \
|
||||
decoding_search_type=<a.k.a. decoding backend. Usually "riva"> \
|
||||
open_vocabulary_decoding=<whether to use open vocabulary mode for WFST decoding> \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[5.0,10.0,15.0,20.0] \
|
||||
lm_weight=[0.1,0.5,0.6,0.7,0.8,0.9,1.0,1.1,1.2,1.3,1.4,1.5,2.0] \
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules import ctc_beam_decoding
|
||||
from nemo.collections.asr.parts.utils.transcribe_utils import PunctuationCapitalization, TextProcessingConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalWFSTNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with WFST decoding and n-gram ARPA language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
arpa_model_file: Optional[str] = None # The path of the ARPA model file
|
||||
decoding_wfst_file: Optional[str] = None # The path of the decoding WFST file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
probs_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
acoustic_batch_size: int = 16 # The batch size to calculate log probabilities
|
||||
beam_batch_size: int = 512 # The batch size to be used for beam search decoding
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities and run WFST decoding
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
|
||||
# WFST decoding hyperparameters
|
||||
|
||||
beam_width: List[float] = field(default_factory=lambda: [10]) # The width or list of the beam widths for the WFST decoding
|
||||
lm_weight: List[float] = field(default_factory=lambda: [1.0]) # The language model weight parameter or list of parameters for the WFST decoding
|
||||
|
||||
open_vocabulary_decoding: bool = False # Whether to use open vocabulary mode for WFST decoding
|
||||
decoding_mode: str = "nbest"
|
||||
decoding_search_type: str = "riva"
|
||||
decoding: ctc_beam_decoding.WfstCTCInferConfig = field(
|
||||
default_factory=lambda: ctc_beam_decoding.WfstCTCInferConfig(beam_size=1)
|
||||
)
|
||||
|
||||
text_processing: Optional[TextProcessingConfig] = field(default_factory=lambda: TextProcessingConfig(
|
||||
punctuation_marks = ".,?",
|
||||
separate_punctuation = False,
|
||||
do_lowercase = False,
|
||||
rm_punctuation = False,
|
||||
))
|
||||
# fmt: on
|
||||
|
||||
|
||||
def beam_search_eval(
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalWFSTNGramConfig,
|
||||
all_probs: List[torch.Tensor],
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
lm_weight: float = 1.0,
|
||||
beam_width: float = 10.0,
|
||||
beam_batch_size: int = 512,
|
||||
progress_bar: bool = True,
|
||||
punctuation_capitalization: PunctuationCapitalization = None,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(decoding_cfg=None, decoder_type="ctc")
|
||||
else:
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.beam_width = beam_width
|
||||
cfg.decoding.lm_weight = lm_weight
|
||||
cfg.decoding.open_vocabulary_decoding = cfg.open_vocabulary_decoding
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.arpa_lm_path = cfg.arpa_model_file
|
||||
cfg.decoding.wfst_lm_path = cfg.decoding_wfst_file
|
||||
cfg.decoding.device = cfg.device
|
||||
cfg.decoding.decoding_mode = cfg.decoding_mode
|
||||
cfg.decoding.search_type = cfg.decoding_search_type
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = "wfst"
|
||||
model.cfg.decoding.wfst = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(model.cfg.decoding, decoder_type='ctc')
|
||||
decoding = model.ctc_decoding
|
||||
else:
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
decoding = model.decoding
|
||||
logging.setLevel(level)
|
||||
|
||||
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', encoding='utf_8', newline='\n')
|
||||
|
||||
if progress_bar:
|
||||
it = tqdm(
|
||||
range(int(np.ceil(len(all_probs) / beam_batch_size))),
|
||||
desc=f"Beam search decoding with width={beam_width}, lm_weight={lm_weight}",
|
||||
ncols=120,
|
||||
)
|
||||
else:
|
||||
it = range(int(np.ceil(len(all_probs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
# disabling type checking
|
||||
probs_batch = all_probs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
probs_lens = torch.tensor([prob.shape[0] for prob in probs_batch])
|
||||
with torch.no_grad():
|
||||
packed_batch = torch.zeros(len(probs_batch), max(probs_lens), probs_batch[0].shape[-1], device='cpu')
|
||||
|
||||
for prob_index in range(len(probs_batch)):
|
||||
packed_batch[prob_index, : probs_lens[prob_index], :] = probs_batch[prob_index].to(
|
||||
device=packed_batch.device, dtype=packed_batch.dtype
|
||||
)
|
||||
|
||||
_, beams_batch = decoding.ctc_decoder_predictions_tensor(
|
||||
packed_batch,
|
||||
decoder_lengths=probs_lens,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
|
||||
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): # type: (int, ctc_beam_decoding.rnnt_utils.Hypothesis)
|
||||
pred_text = candidate.text
|
||||
if cfg.text_processing.do_lowercase:
|
||||
pred_text = punctuation_capitalization.do_lowercase([pred_text])[0]
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
pred_text = punctuation_capitalization.rm_punctuation([pred_text])[0]
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
pred_text = punctuation_capitalization.separate_punctuation([pred_text])[0]
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
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.score
|
||||
if preds_output_file:
|
||||
out_file.write(f'{pred_text}\t{score}\n')
|
||||
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}'.")
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
logging.info(
|
||||
'Oracle WER/CER in candidates with perfect LM= {:.2%}/{:.2%}'.format(
|
||||
wer_dist_best / words_count, cer_dist_best / chars_count
|
||||
)
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalWFSTNGramConfig', schema=EvalWFSTNGramConfig)
|
||||
def main(cfg: EvalWFSTNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalWFSTNGramConfig
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
punctuation_capitalization = PunctuationCapitalization(cfg.text_processing.punctuation_marks)
|
||||
if cfg.text_processing.do_lowercase:
|
||||
target_transcripts = punctuation_capitalization.do_lowercase(target_transcripts)
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
target_transcripts = punctuation_capitalization.rm_punctuation(target_transcripts)
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
target_transcripts = punctuation_capitalization.separate_punctuation(target_transcripts)
|
||||
|
||||
if cfg.probs_cache_file and os.path.exists(cfg.probs_cache_file):
|
||||
logging.info(f"Found a cached file of probabilities at '{cfg.probs_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of probabilities from '{cfg.probs_cache_file}' ...")
|
||||
with open(cfg.probs_cache_file, 'rb') as probs_file:
|
||||
all_probs = msgpack.load(probs_file)
|
||||
|
||||
if len(all_probs) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the probabilities file '{cfg.probs_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the probabilities cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
asr_model.cur_decoder = 'ctc'
|
||||
all_hyps = asr_model.transcribe(
|
||||
audio_file_paths, batch_size=cfg.acoustic_batch_size, return_hypotheses=True
|
||||
)
|
||||
all_logits = [h.y_sequence for h in all_hyps]
|
||||
|
||||
all_probs = all_logits
|
||||
if cfg.probs_cache_file:
|
||||
os.makedirs(os.path.split(cfg.probs_cache_file)[0], exist_ok=True)
|
||||
logging.info(f"Writing cached files of probabilities at '{cfg.probs_cache_file}'...")
|
||||
with open(cfg.probs_cache_file, 'wb') as f_dump:
|
||||
msgpack.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 = preds.to(device='cpu').unsqueeze(0)
|
||||
preds_lens = torch.tensor([preds_tensor.shape[1]], device='cpu')
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
pred_text = asr_model.ctc_decoding.ctc_decoder_predictions_tensor(preds_tensor, preds_lens)[0]
|
||||
else:
|
||||
pred_text = asr_model.decoding.ctc_decoder_predictions_tensor(preds_tensor, preds_lens)[0]
|
||||
|
||||
if cfg.text_processing.do_lowercase:
|
||||
pred_text = punctuation_capitalization.do_lowercase([pred_text])[0]
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
pred_text = punctuation_capitalization.rm_punctuation([pred_text])[0]
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
pred_text = punctuation_capitalization.separate_punctuation([pred_text])[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 = edit_distance(target_split_w, pred_split_w)['total']
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
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))
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
if (cfg.arpa_model_file is None or not os.path.exists(cfg.arpa_model_file)) and (
|
||||
cfg.decoding_wfst_file is None or not os.path.exists(cfg.decoding_wfst_file)
|
||||
):
|
||||
raise FileNotFoundError(
|
||||
f"Could not find both the ARPA model file `{cfg.arpa_model_file}` "
|
||||
f"and the decoding WFST file `{cfg.decoding_wfst_file}`."
|
||||
)
|
||||
|
||||
if cfg.beam_width is None or cfg.lm_weight is None:
|
||||
raise ValueError("beam_width and lm_weight are needed to perform WFST decoding.")
|
||||
params = {'beam_width': cfg.beam_width, 'lm_weight': cfg.lm_weight}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_width, best_cer_beam_width = None, None
|
||||
best_wer_lm_weight, best_cer_lm_weight = None, None
|
||||
best_wer, best_cer = 1e6, 1e6
|
||||
|
||||
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 cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
preds_output_file = os.path.join(
|
||||
cfg.preds_output_folder,
|
||||
f"preds_out_beam_width{hp['beam_width']}_lm_weight{hp['lm_weight']}.tsv",
|
||||
)
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
candidate_wer, candidate_cer = beam_search_eval(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
preds_output_file=preds_output_file,
|
||||
beam_width=hp["beam_width"],
|
||||
lm_weight=hp["lm_weight"],
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
punctuation_capitalization=punctuation_capitalization,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_width = hp["beam_width"]
|
||||
best_cer_lm_weight = hp["lm_weight"]
|
||||
best_cer = candidate_cer
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_width = hp["beam_width"]
|
||||
best_wer_lm_weight = hp["lm_weight"]
|
||||
best_wer = candidate_wer
|
||||
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_width}, LM weight = {best_wer_lm_weight}'
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_width}, LM weight = {best_cer_lm_weight}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
# Use this script to install KenLM, OpenSeq2Seq decoder, Flashlight decoder
|
||||
shopt -s expand_aliases
|
||||
|
||||
NEMO_PATH=/workspace/nemo # Path to NeMo folder: /workspace/nemo if you use NeMo/Dockerfile
|
||||
if [ "$#" -eq 1 ]; then
|
||||
NEMO_PATH=$1
|
||||
fi
|
||||
KENLM_MAX_ORDER=10 # Maximum order of KenLM model, also specified in the setup_os2s_decoders.py
|
||||
|
||||
if [ -d "$NEMO_PATH" ]; then
|
||||
echo "The folder '$NEMO_PATH' exists."
|
||||
else
|
||||
echo "Error: The folder '$NEMO_PATH' does not exist. Specify it as a first command line positional argument!"
|
||||
exit 1
|
||||
fi
|
||||
cd $NEMO_PATH
|
||||
|
||||
if [ $(id -u) -eq 0 ]; then
|
||||
alias aptupdate='apt-get update'
|
||||
alias b2install='./b2'
|
||||
else
|
||||
alias aptupdate='sudo apt-get update'
|
||||
alias b2install='sudo ./b2'
|
||||
fi
|
||||
|
||||
aptupdate && apt-get upgrade -y && apt-get install -y swig liblzma-dev && rm -rf /var/lib/apt/lists/* # liblzma needed for flashlight decoder
|
||||
|
||||
# install Boost package for KenLM
|
||||
wget https://archives.boost.io/release/1.80.0/source/boost_1_80_0.tar.bz2 && tar --bzip2 -xf $NEMO_PATH/boost_1_80_0.tar.bz2 && cd boost_1_80_0 && ./bootstrap.sh && b2install --layout=tagged link=static,shared threading=multi,single install -j4 && cd .. || echo FAILURE
|
||||
export BOOST_ROOT=$NEMO_PATH/boost_1_80_0
|
||||
|
||||
git clone https://github.com/NVIDIA/OpenSeq2Seq
|
||||
cd OpenSeq2Seq
|
||||
git checkout ctc-decoders
|
||||
cd ..
|
||||
mv OpenSeq2Seq/decoders $NEMO_PATH/
|
||||
rm -rf OpenSeq2Seq
|
||||
cd $NEMO_PATH/decoders
|
||||
cp $NEMO_PATH/scripts/installers/setup_os2s_decoders.py ./setup.py
|
||||
./setup.sh
|
||||
|
||||
# install KenLM
|
||||
cd $NEMO_PATH/decoders/kenlm/build && cmake -DKENLM_MAX_ORDER=$KENLM_MAX_ORDER .. && make -j2
|
||||
cd $NEMO_PATH/decoders/kenlm
|
||||
python setup.py install --max_order=$KENLM_MAX_ORDER
|
||||
export KENLM_LIB=$NEMO_PATH/decoders/kenlm/build/bin
|
||||
export KENLM_ROOT=$NEMO_PATH/decoders/kenlm
|
||||
cd ..
|
||||
|
||||
# install Flashlight
|
||||
git clone https://github.com/flashlight/text && cd text
|
||||
python setup.py bdist_wheel
|
||||
pip install dist/*.whl
|
||||
cd ..
|
||||
+944
@@ -0,0 +1,944 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# 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.
|
||||
|
||||
# Copyright (c) 2016, Johns Hopkins University (Author: Daniel Povey).
|
||||
# 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 was copied from https://github.com/kaldi-asr/kaldi/blob/master/egs/wsj/s5/utils/lang/make_phone_lm.py
|
||||
# with minor python3 related changes.
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
# note, this was originally based
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
This script creates a language model that's intended to be used in modeling
|
||||
phone sequences (either of sentences or of dictionary entries), although of
|
||||
course it will work for any type of data. The easiest way
|
||||
to describe it is as a a Kneser-Ney language model (unmodified, with addition)
|
||||
with a fixed discounting constant equal to 1, except with no smoothing of the
|
||||
bigrams (and hence no unigram state). This is (a) because we want to keep the
|
||||
graph after context expansion small, (b) because languages tend to have
|
||||
constraints on which phones can follow each other, and (c) in order to get valid
|
||||
sequences of word-position-dependent phones so that lattice-align-words can
|
||||
work. It also includes have a special entropy-based pruning technique that
|
||||
backs off the statistics of pruned n-grams to lower-order states.
|
||||
This script reads lines from its standard input, each
|
||||
consisting of a sequence of integer symbol-ids (which should be > 0),
|
||||
representing the phone sequences of a sentence or dictionary entry.
|
||||
This script outputs a backoff language model in FST format""",
|
||||
epilog="See also utils/lang/make_phone_bigram_lang.sh",
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--phone-disambig-symbol",
|
||||
type=int,
|
||||
required=False,
|
||||
help="Integer corresponding to an otherwise-unused "
|
||||
"phone-level disambiguation symbol (e.g. #5). This is "
|
||||
"inserted at the beginning of the phone sequence and "
|
||||
"whenever we back off.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ngram-order",
|
||||
type=int,
|
||||
default=4,
|
||||
choices=[2, 3, 4, 5, 6, 7],
|
||||
help="Order of n-gram to use (but see also --num-extra-states;" "the effective order after pruning may be less.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-extra-ngrams",
|
||||
type=int,
|
||||
default=20000,
|
||||
help="Target number of n-grams in addition to the n-grams in "
|
||||
"the bigram LM states which can't be pruned away. n-grams "
|
||||
"will be pruned to reach this target.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backoff-ngram-order",
|
||||
type=int,
|
||||
default=2,
|
||||
choices=[1, 2, 3, 4, 5],
|
||||
help="This specifies the n-gram order at which (and below which) "
|
||||
"no backoff or pruning should be done. This is expected to normally "
|
||||
"be bigram, but for testing purposes you may want to set it to "
|
||||
"1.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-as-arpa",
|
||||
type=str,
|
||||
default="false",
|
||||
choices=["true", "false"],
|
||||
help="If true, print LM in ARPA format (default is to print "
|
||||
"as FST). You must also set --no-backoff-ngram-order=1 or "
|
||||
"this is not allowed.",
|
||||
)
|
||||
parser.add_argument("--verbose", type=int, default=0, choices=[0, 1, 2, 3, 4, 5], help="Verbose level")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose >= 1:
|
||||
print(" ".join(sys.argv), file=sys.stderr)
|
||||
|
||||
|
||||
class CountsForHistory(object):
|
||||
## This class (which is more like a struct) stores the counts seen in a
|
||||
## particular history-state. It is used inside class NgramCounts.
|
||||
## It really does the job of a dict from int to float, but it also
|
||||
## keeps track of the total count.
|
||||
def __init__(self):
|
||||
# The 'lambda: defaultdict(float)' is an anonymous function taking no
|
||||
# arguments that returns a new defaultdict(float).
|
||||
self.word_to_count = defaultdict(int)
|
||||
self.total_count = 0
|
||||
|
||||
def Words(self):
|
||||
return list(self.word_to_count.keys())
|
||||
|
||||
def __str__(self):
|
||||
# e.g. returns ' total=12 3->4 4->6 -1->2'
|
||||
return " total={0} {1}".format(
|
||||
str(self.total_count),
|
||||
" ".join(["{0} -> {1}".format(word, count) for word, count in self.word_to_count.items()]),
|
||||
)
|
||||
|
||||
## Adds a certain count (expected to be integer, but might be negative). If
|
||||
## the resulting count for this word is zero, removes the dict entry from
|
||||
## word_to_count.
|
||||
## [note, though, that in some circumstances we 'add back' zero counts
|
||||
## where the presence of n-grams would be structurally required by the arpa,
|
||||
## specifically if a higher-order history state has a nonzero count,
|
||||
## we need to structurally have the count there in the states it backs
|
||||
## off to.
|
||||
def AddCount(self, predicted_word, count):
|
||||
self.total_count += count
|
||||
assert self.total_count >= 0
|
||||
old_count = self.word_to_count[predicted_word]
|
||||
new_count = old_count + count
|
||||
if new_count < 0:
|
||||
print("predicted-word={0}, old-count={1}, count={2}".format(predicted_word, old_count, count))
|
||||
assert new_count >= 0
|
||||
if new_count == 0:
|
||||
del self.word_to_count[predicted_word]
|
||||
else:
|
||||
self.word_to_count[predicted_word] = new_count
|
||||
|
||||
|
||||
class NgramCounts(object):
|
||||
## A note on data-structure. Firstly, all words are represented as
|
||||
## integers. We store n-gram counts as an array, indexed by (history-length
|
||||
## == n-gram order minus one) (note: python calls arrays "lists") of dicts
|
||||
## from histories to counts, where histories are arrays of integers and
|
||||
## "counts" are dicts from integer to float. For instance, when
|
||||
## accumulating the 4-gram count for the '8' in the sequence '5 6 7 8', we'd
|
||||
## do as follows: self.counts[3][[5,6,7]][8] += 1.0 where the [3] indexes an
|
||||
## array, the [[5,6,7]] indexes a dict, and the [8] indexes a dict.
|
||||
def __init__(self, ngram_order):
|
||||
assert ngram_order >= 2
|
||||
# Integerized counts will never contain negative numbers, so
|
||||
# inside this program, we use -3 and -2 for the BOS and EOS symbols
|
||||
# respectively.
|
||||
# Note: it's actually important that the bos-symbol is the most negative;
|
||||
# it helps ensure that we print the state with left-context <s> first
|
||||
# when we print the FST, and this means that the start-state will have
|
||||
# the correct value.
|
||||
self.bos_symbol = -3
|
||||
self.eos_symbol = -2
|
||||
# backoff_symbol is kind of a pseudo-word, it's used in keeping track of
|
||||
# the backoff counts in each state.
|
||||
self.backoff_symbol = -1
|
||||
self.total_num_words = 0 # count includes EOS but not BOS.
|
||||
self.counts = []
|
||||
for n in range(ngram_order):
|
||||
self.counts.append(defaultdict(lambda: CountsForHistory()))
|
||||
|
||||
# adds a raw count (called while processing input data).
|
||||
# Suppose we see the sequence '6 7 8 9' and ngram_order=4, 'history'
|
||||
# would be (6,7,8) and 'predicted_word' would be 9; 'count' would be
|
||||
# 1.
|
||||
def AddCount(self, history, predicted_word, count):
|
||||
self.counts[len(history)][history].AddCount(predicted_word, count)
|
||||
|
||||
# 'line' is a string containing a sequence of integer word-ids.
|
||||
# This function adds the un-smoothed counts from this line of text.
|
||||
def AddRawCountsFromLine(self, line):
|
||||
try:
|
||||
words = [self.bos_symbol] + [int(x) for x in line.split()] + [self.eos_symbol]
|
||||
except Exception:
|
||||
sys.exit("make_phone_lm.py: bad input line {0} (expected a sequence " "of integers)".format(line))
|
||||
|
||||
for n in range(1, len(words)):
|
||||
predicted_word = words[n]
|
||||
history_start = max(0, n + 1 - args.ngram_order)
|
||||
history = tuple(words[history_start:n])
|
||||
self.AddCount(history, predicted_word, 1)
|
||||
self.total_num_words += 1
|
||||
|
||||
def AddRawCountsFromStandardInput(self):
|
||||
lines_processed = 0
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if line == "":
|
||||
break
|
||||
self.AddRawCountsFromLine(line)
|
||||
lines_processed += 1
|
||||
if lines_processed == 0 or args.verbose > 0:
|
||||
print(
|
||||
"make_phone_lm.py: processed {0} lines of input".format(lines_processed),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This backs off the counts by subtracting 1 and assigning the subtracted
|
||||
# count to the backoff state. It's like a special case of Kneser-Ney with D
|
||||
# = 1. The optimal D would likely be something like 0.9, but we plan to
|
||||
# later do entropy-pruning, and the remaining small counts of 0.1 would
|
||||
# essentially all get pruned away anyway, so we don't lose much by doing it
|
||||
# like this.
|
||||
def ApplyBackoff(self):
|
||||
# note: in the normal case where args.no_backoff_ngram_order == 2 we
|
||||
# don't do backoff for history-length = 1 (i.e. for bigrams)... this is
|
||||
# a kind of special LM where we're not going to back off to unigram,
|
||||
# there will be no unigram.
|
||||
if args.verbose >= 1:
|
||||
initial_num_ngrams = self.GetNumNgrams()
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
this_order_counts = self.counts[n]
|
||||
for hist, counts_for_hist in this_order_counts.items():
|
||||
backoff_hist = hist[1:]
|
||||
backoff_counts_for_hist = self.counts[n - 1][backoff_hist]
|
||||
this_discount_total = 0
|
||||
for word in counts_for_hist.Words():
|
||||
counts_for_hist.AddCount(word, -1)
|
||||
# You can interpret the following line as incrementing the
|
||||
# count-of-counts for the next-lower order. Note, however,
|
||||
# that later when we remove n-grams, we'll also add their
|
||||
# counts to the next-lower-order history state, so the
|
||||
# resulting counts won't strictly speaking be
|
||||
# counts-of-counts.
|
||||
backoff_counts_for_hist.AddCount(word, 1)
|
||||
this_discount_total += 1
|
||||
counts_for_hist.AddCount(self.backoff_symbol, this_discount_total)
|
||||
|
||||
if args.verbose >= 1:
|
||||
# Note: because D == 1, we completely back off singletons.
|
||||
print(
|
||||
"make_phone_lm.py: ApplyBackoff() reduced the num-ngrams from "
|
||||
"{0} to {1}".format(initial_num_ngrams, self.GetNumNgrams()),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This function prints out to stderr the n-gram counts stored in this
|
||||
# object; it's used for debugging.
|
||||
def Print(self, info_string):
|
||||
print(info_string, file=sys.stderr)
|
||||
# these are useful for debug.
|
||||
total = 0.0
|
||||
total_excluding_backoff = 0.0
|
||||
for this_order_counts in self.counts:
|
||||
for hist, counts_for_hist in this_order_counts.items():
|
||||
print(str(hist) + str(counts_for_hist), file=sys.stderr)
|
||||
total += counts_for_hist.total_count
|
||||
total_excluding_backoff += counts_for_hist.total_count
|
||||
if self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
total_excluding_backoff -= counts_for_hist.word_to_count[self.backoff_symbol]
|
||||
print(
|
||||
"total count = {0}, excluding backoff = {1}".format(total, total_excluding_backoff),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def GetHistToStateMap(self):
|
||||
# This function, called from PrintAsFst, returns a map from
|
||||
# history to integer FST-state.
|
||||
hist_to_state = dict()
|
||||
fst_state_counter = 0
|
||||
for n in range(0, args.ngram_order):
|
||||
for hist in self.counts[n].keys():
|
||||
hist_to_state[hist] = fst_state_counter
|
||||
fst_state_counter += 1
|
||||
return hist_to_state
|
||||
|
||||
# Returns the probability of word 'word' in history-state 'hist'.
|
||||
# If 'word' is self.backoff_symbol, returns the backoff prob
|
||||
# of this history-state.
|
||||
# Returns None if there is no such word in this history-state, or this
|
||||
# history-state does not exist.
|
||||
def GetProb(self, hist, word):
|
||||
if len(hist) >= args.ngram_order or not hist in self.counts[len(hist)]:
|
||||
return None
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
total_count = float(counts_for_hist.total_count)
|
||||
if not word in counts_for_hist.word_to_count:
|
||||
print(
|
||||
"make_phone_lm.py: no prob for {0} -> {1} " "[no such count]".format(hist, word),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
prob = float(counts_for_hist.word_to_count[word]) / total_count
|
||||
if len(hist) > 0 and word != self.backoff_symbol and self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
prob_in_backoff = self.GetProb(hist[1:], word)
|
||||
backoff_prob = float(counts_for_hist.word_to_count[self.backoff_symbol]) / total_count
|
||||
try:
|
||||
prob += backoff_prob * prob_in_backoff
|
||||
except Exception:
|
||||
sys.exit("problem, hist is {0}, word is {1}".format(hist, word))
|
||||
return prob
|
||||
|
||||
def PruneEmptyStates(self):
|
||||
# Removes history-states that have no counts.
|
||||
|
||||
# It's possible in principle for history-states to have no counts and
|
||||
# yet they cannot be pruned away because a higher-order version of the
|
||||
# state exists with nonzero counts, so we have to keep track of this.
|
||||
protected_histories = set()
|
||||
|
||||
states_removed_per_hist_len = [0] * args.ngram_order
|
||||
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
num_states_removed = 0
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
l = len(counts_for_hist.word_to_count)
|
||||
assert l > 0 and self.backoff_symbol in counts_for_hist.word_to_count
|
||||
if l == 1 and not hist in protected_histories: # only the backoff symbol has a count.
|
||||
del self.counts[n][hist]
|
||||
num_states_removed += 1
|
||||
else:
|
||||
# if this state was not pruned away, then the state that
|
||||
# it backs off to may not be pruned away either.
|
||||
backoff_hist = hist[1:]
|
||||
protected_histories.add(backoff_hist)
|
||||
states_removed_per_hist_len[n] = num_states_removed
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: in PruneEmptyStates(), num states removed for "
|
||||
"each history-length was: " + str(states_removed_per_hist_len),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def EnsureStructurallyNeededNgramsExist(self):
|
||||
# makes sure that if an n-gram like (6, 7, 8) -> 9 exists,
|
||||
# then counts exist for (7, 8) -> 9 and (8,) -> 9. It does so
|
||||
# by adding zero counts where such counts were absent.
|
||||
# [note: () -> 9 is guaranteed anyway by the backoff method, if
|
||||
# we have a unigram state].
|
||||
if args.verbose >= 1:
|
||||
num_ngrams_initial = self.GetNumNgrams()
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
# This loop ensures that if we have an n-gram like (6, 7, 8) -> 9,
|
||||
# then, say, (7, 8) -> 9 and (8) -> 9 exist.
|
||||
reduced_hist = hist
|
||||
for m in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
reduced_hist = reduced_hist[1:] # shift an element off
|
||||
# the history.
|
||||
counts_for_backoff_hist = self.counts[m][reduced_hist]
|
||||
for word in counts_for_hist.word_to_count.keys():
|
||||
counts_for_backoff_hist.word_to_count[word] += 0
|
||||
# This loop ensures that if we have an n-gram like (6, 7, 8) -> 9,
|
||||
# then, say, (6, 7) -> 8 and (6) -> 7 exist. This will be needed
|
||||
# for FST representations of the ARPA LM.
|
||||
reduced_hist = hist
|
||||
for m in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
this_word = reduced_hist[-1]
|
||||
reduced_hist = reduced_hist[:-1] # pop an element off the
|
||||
# history
|
||||
counts_for_backoff_hist = self.counts[m][reduced_hist]
|
||||
counts_for_backoff_hist.word_to_count[this_word] += 0
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: in EnsureStructurallyNeededNgramsExist(), "
|
||||
"added {0} n-grams".format(self.GetNumNgrams() - num_ngrams_initial),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This function prints the estimated language model as an FST.
|
||||
def PrintAsFst(self, word_disambig_symbol):
|
||||
# n is the history-length (== order + 1). We iterate over the
|
||||
# history-length in the order 1, 0, 2, 3, and then iterate over the
|
||||
# histories of each order in sorted order. Putting order 1 first
|
||||
# and sorting on the histories
|
||||
# ensures that the bigram state with <s> as the left context comes first.
|
||||
# (note: self.bos_symbol is the most negative symbol)
|
||||
|
||||
# History will map from history (as a tuple) to integer FST-state.
|
||||
hist_to_state = self.GetHistToStateMap()
|
||||
|
||||
for n in [1, 0] + list(range(2, args.ngram_order)):
|
||||
this_order_counts = self.counts[n]
|
||||
# For order 1, make sure the keys are sorted.
|
||||
keys = this_order_counts.keys() if n != 1 else sorted(this_order_counts.keys())
|
||||
for hist in keys:
|
||||
word_to_count = this_order_counts[hist].word_to_count
|
||||
this_fst_state = hist_to_state[hist]
|
||||
|
||||
for word in word_to_count.keys():
|
||||
# work out this_cost. Costs in OpenFst are negative logs.
|
||||
this_cost = -math.log(self.GetProb(hist, word))
|
||||
|
||||
if word > 0: # a real word.
|
||||
next_hist = hist + (word,) # appending tuples
|
||||
while not next_hist in hist_to_state:
|
||||
next_hist = next_hist[1:]
|
||||
next_fst_state = hist_to_state[next_hist]
|
||||
print(this_fst_state, next_fst_state, word, word, this_cost)
|
||||
elif word == self.eos_symbol:
|
||||
# print final-prob for this state.
|
||||
print(this_fst_state, this_cost)
|
||||
else:
|
||||
assert word == self.backoff_symbol
|
||||
backoff_fst_state = hist_to_state[hist[1 : len(hist)]]
|
||||
print(
|
||||
this_fst_state,
|
||||
backoff_fst_state,
|
||||
word_disambig_symbol,
|
||||
0,
|
||||
this_cost,
|
||||
)
|
||||
|
||||
# This function returns a set of n-grams that cannot currently be pruned
|
||||
# away, either because a higher-order form of the same n-gram already exists,
|
||||
# or because the n-gram leads to an n-gram state that exists.
|
||||
# [Note: as we prune, we remove any states that can be removed; see that
|
||||
# PruneToIntermediateTarget() calls PruneEmptyStates().
|
||||
|
||||
def GetProtectedNgrams(self):
|
||||
ans = set()
|
||||
for n in range(args.no_backoff_ngram_order + 1, args.ngram_order):
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
# If we have an n-gram (6, 7, 8) -> 9, the following loop will
|
||||
# add the backed-off n-grams (7, 8) -> 9 and (8) -> 9 to
|
||||
# 'protected-ngrams'.
|
||||
reduced_hist = hist
|
||||
for _ in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
reduced_hist = reduced_hist[1:] # shift an element off
|
||||
# the history.
|
||||
|
||||
for word in counts_for_hist.word_to_count.keys():
|
||||
if word != self.backoff_symbol:
|
||||
ans.add(reduced_hist + (word,))
|
||||
# The following statement ensures that if we are in a
|
||||
# history-state (6, 7, 8), then n-grams (6, 7, 8) and (6, 7) are
|
||||
# protected. This assures that the FST states are accessible.
|
||||
reduced_hist = hist
|
||||
for _ in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
ans.add(reduced_hist)
|
||||
reduced_hist = reduced_hist[:-1] # pop an element off the
|
||||
# history
|
||||
return ans
|
||||
|
||||
def PruneNgram(self, hist, word):
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
assert word != self.backoff_symbol and word in counts_for_hist.word_to_count
|
||||
count = counts_for_hist.word_to_count[word]
|
||||
del counts_for_hist.word_to_count[word]
|
||||
counts_for_hist.word_to_count[self.backoff_symbol] += count
|
||||
# the next call adds the count to the symbol 'word' in the backoff
|
||||
# history-state, and also updates its 'total_count'.
|
||||
self.counts[len(hist) - 1][hist[1:]].AddCount(word, count)
|
||||
|
||||
# The function PruningLogprobChange is the same as the same-named
|
||||
# function in float-counts-prune.cc in pocolm. Note, it doesn't access
|
||||
# any class members.
|
||||
|
||||
# This function computes the log-likelihood change (<= 0) from backing off
|
||||
# a particular symbol to the lower-order state.
|
||||
# The value it returns can be interpreted as a lower bound the actual log-likelihood
|
||||
# change. By "the actual log-likelihood change" we mean of data generated by
|
||||
# the model itself before making the change, then modeled with the changed model
|
||||
# [and comparing the log-like with the log-like before changing the model]. That is,
|
||||
# it's a K-L divergence, but with the caveat that we don't normalize by the
|
||||
# overall count of the data, so it's a K-L divergence multiplied by the training-data
|
||||
# count.
|
||||
|
||||
# 'count' is the count of the word (call it 'a') in this state. It's an integer.
|
||||
# 'discount' is the discount-count in this state (represented as the count
|
||||
# for the symbol self.backoff_symbol). It's an integer.
|
||||
# [note: we don't care about the total-count in this state, it cancels out.]
|
||||
# 'backoff_count' is the count of word 'a' in the lower-order state.
|
||||
# [actually it is the augmented count, treating any
|
||||
# extra probability from even-lower-order states as
|
||||
# if it were a count]. It's a float.
|
||||
# 'backoff_total' is the total count in the lower-order state. It's a float.
|
||||
def PruningLogprobChange(self, count, discount, backoff_count, backoff_total):
|
||||
if count == 0:
|
||||
return 0.0
|
||||
|
||||
assert discount > 0 and backoff_total >= backoff_count and backoff_total >= 0.99 * discount
|
||||
|
||||
# augmented_count is like 'count', but with the extra count for symbol
|
||||
# 'a' due to backoff included.
|
||||
augmented_count = count + discount * backoff_count / backoff_total
|
||||
|
||||
# We imagine a phantom symbol 'b' that represents all symbols other than
|
||||
# 'a' appearing in this history-state that are accessed via backoff. We
|
||||
# treat these as being distinct symbols from the same symbol if accessed
|
||||
# not-via-backoff. (Treating same symbols as distinct gives an upper bound
|
||||
# on the divergence). We also treat them as distinct from the same symbols
|
||||
# that are being accessed via backoff from other states. b_count is the
|
||||
# observed count of symbol 'b' in this state (the backed-off count is
|
||||
# zero). b_count is also the count of symbol 'b' in the backoff state.
|
||||
# Note: b_count will not be negative because backoff_total >= backoff_count.
|
||||
b_count = discount * ((backoff_total - backoff_count) / backoff_total)
|
||||
assert b_count >= -0.001 * backoff_total
|
||||
|
||||
# We imagine a phantom symbol 'c' that represents all symbols other than
|
||||
# 'a' and 'b' appearing in the backoff state, which got there from
|
||||
# backing off other states (other than 'this' state). Again, we imagine
|
||||
# the symbols are distinct even though they may not be (i.e. that c and
|
||||
# b represent disjoint sets of symbol, even though they might not really
|
||||
# be disjoint), and this gives us an upper bound on the divergence.
|
||||
c_count = backoff_total - backoff_count - b_count
|
||||
assert c_count >= -0.001 * backoff_total
|
||||
|
||||
# a_other is the count of 'a' in the backoff state that comes from
|
||||
# 'other sources', i.e. it was backed off from history-states other than
|
||||
# the current history state.
|
||||
a_other_count = backoff_count - discount * backoff_count / backoff_total
|
||||
assert a_other_count >= -0.001 * backoff_count
|
||||
|
||||
# the following sub-expressions are the 'new' versions of certain
|
||||
# quantities after we assign the total count 'count' to backoff. it
|
||||
# increases the backoff count in 'this' state, and also the total count
|
||||
# in the backoff state, and the count of symbol 'a' in the backoff
|
||||
# state.
|
||||
new_backoff_count = backoff_count + count # new count of symbol 'a' in
|
||||
# backoff state
|
||||
new_backoff_total = backoff_total + count # new total count in
|
||||
# backoff state.
|
||||
new_discount = discount + count # new discount-count in 'this' state.
|
||||
|
||||
# all the loglike changes below are of the form
|
||||
# count-of-symbol * log(new prob / old prob)
|
||||
# which can be more conveniently written (by canceling the denominators),
|
||||
# count-of-symbol * log(new count / old count).
|
||||
|
||||
# this_a_change is the log-like change of symbol 'a' coming from 'this'
|
||||
# state. bear in mind that
|
||||
# augmented_count = count + discount * backoff_count / backoff_total,
|
||||
# and the 'count' term is zero in the numerator part of the log expression,
|
||||
# because symbol 'a' is completely backed off in 'this' state.
|
||||
this_a_change = augmented_count * math.log(
|
||||
(new_discount * new_backoff_count / new_backoff_total) / augmented_count
|
||||
)
|
||||
|
||||
# other_a_change is the log-like change of symbol 'a' coming from all
|
||||
# other states than 'this'. For speed reasons we don't examine the
|
||||
# direct (non-backoff) counts of symbol 'a' in all other states than
|
||||
# 'this' that back off to the backoff state-- it would be slower.
|
||||
# Instead we just treat the direct part of the prob for symbol 'a' as a
|
||||
# distinct symbol when it comes from those other states... as usual,
|
||||
# doing so gives us an upper bound on the divergence.
|
||||
other_a_change = a_other_count * math.log(
|
||||
(new_backoff_count / new_backoff_total) / (backoff_count / backoff_total)
|
||||
)
|
||||
|
||||
# b_change is the log-like change of phantom symbol 'b' coming from
|
||||
# 'this' state (and note: it only comes from this state, that's how we
|
||||
# defined it).
|
||||
# note: the expression below could be more directly written as a
|
||||
# ratio of pseudo-counts as follows, by converting the backoff probabilities
|
||||
# into pseudo-counts in 'this' state:
|
||||
# b_count * logf((new_discount * b_count / new_backoff_total) /
|
||||
# (discount * b_count / backoff_total),
|
||||
# but we cancel b_count to give us the expression below.
|
||||
b_change = b_count * math.log((new_discount / new_backoff_total) / (discount / backoff_total))
|
||||
|
||||
# c_change is the log-like change of phantom symbol 'c' coming from
|
||||
# all other states that back off to the backoff sate (and all prob. mass of
|
||||
# 'c' comes from those other states). The expression below could be more
|
||||
# directly written as a ratio of counts, as c_count * logf((c_count /
|
||||
# new_backoff_total) / (c_count / backoff_total)), but we simplified it to
|
||||
# the expression below.
|
||||
c_change = c_count * math.log(backoff_total / new_backoff_total)
|
||||
|
||||
ans = this_a_change + other_a_change + b_change + c_change
|
||||
# the answer should not be positive.
|
||||
assert ans <= 0.0001 * (count + discount + backoff_count + backoff_total)
|
||||
if args.verbose >= 4:
|
||||
print(
|
||||
"pruning-logprob-change for {0},{1},{2},{3} is {4}".format(
|
||||
count, discount, backoff_count, backoff_total, ans
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return ans
|
||||
|
||||
def GetLikeChangeFromPruningNgram(self, hist, word):
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
counts_for_backoff_hist = self.counts[len(hist) - 1][hist[1:]]
|
||||
assert word != self.backoff_symbol and word in counts_for_hist.word_to_count
|
||||
count = counts_for_hist.word_to_count[word]
|
||||
discount = counts_for_hist.word_to_count[self.backoff_symbol]
|
||||
backoff_total = counts_for_backoff_hist.total_count
|
||||
# backoff_count is a pseudo-count: it's like the count of 'word' in the
|
||||
# backoff history-state, but adding something to account for further
|
||||
# levels of backoff.
|
||||
try:
|
||||
backoff_count = self.GetProb(hist[1:], word) * backoff_total
|
||||
except Exception:
|
||||
print(
|
||||
"problem getting backoff count: hist = {0}, word = {1}".format(hist, word),
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return self.PruningLogprobChange(float(count), float(discount), backoff_count, float(backoff_total))
|
||||
|
||||
# note: returns loglike change per word.
|
||||
def PruneToIntermediateTarget(self, num_extra_ngrams):
|
||||
protected_ngrams = self.GetProtectedNgrams()
|
||||
initial_num_extra_ngrams = self.GetNumExtraNgrams()
|
||||
num_ngrams_to_prune = initial_num_extra_ngrams - num_extra_ngrams
|
||||
assert num_ngrams_to_prune > 0
|
||||
|
||||
num_candidates_per_order = [0] * args.ngram_order
|
||||
num_pruned_per_order = [0] * args.ngram_order
|
||||
|
||||
# like_change_and_ngrams this will be a list of tuples consisting
|
||||
# of the likelihood change as a float and then the words of the n-gram
|
||||
# that we're considering pruning,
|
||||
# e.g. (-0.164, 7, 8, 9)
|
||||
# meaning that pruning the n-gram (7, 8) -> 9 leads to
|
||||
# a likelihood change of -0.164. We'll later sort this list
|
||||
# so we can prune the n-grams that made the least-negative
|
||||
# likelihood change.
|
||||
like_change_and_ngrams = []
|
||||
for n in range(args.no_backoff_ngram_order, args.ngram_order):
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
for word, count in counts_for_hist.word_to_count.items():
|
||||
if word != self.backoff_symbol:
|
||||
if not hist + (word,) in protected_ngrams:
|
||||
like_change = self.GetLikeChangeFromPruningNgram(hist, word)
|
||||
like_change_and_ngrams.append((like_change,) + hist + (word,))
|
||||
num_candidates_per_order[len(hist)] += 1
|
||||
|
||||
like_change_and_ngrams.sort(reverse=True)
|
||||
|
||||
if num_ngrams_to_prune > len(like_change_and_ngrams):
|
||||
print(
|
||||
"make_phone_lm.py: aimed to prune {0} n-grams but could only "
|
||||
"prune {1}".format(num_ngrams_to_prune, len(like_change_and_ngrams)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
num_ngrams_to_prune = len(like_change_and_ngrams)
|
||||
|
||||
total_loglike_change = 0.0
|
||||
|
||||
for i in range(num_ngrams_to_prune):
|
||||
total_loglike_change += like_change_and_ngrams[i][0]
|
||||
hist = like_change_and_ngrams[i][1:-1] # all but 1st and last elements
|
||||
word = like_change_and_ngrams[i][-1] # last element
|
||||
num_pruned_per_order[len(hist)] += 1
|
||||
self.PruneNgram(hist, word)
|
||||
|
||||
like_change_per_word = total_loglike_change / self.total_num_words
|
||||
|
||||
if args.verbose >= 1:
|
||||
effective_threshold = (
|
||||
like_change_and_ngrams[num_ngrams_to_prune - 1][0] if num_ngrams_to_prune >= 0 else 0.0
|
||||
)
|
||||
print(
|
||||
"Pruned from {0} ngrams to {1}, with threshold {2}. Candidates per order were {3}, "
|
||||
"num-ngrams pruned per order were {4}. Like-change per word was {5}".format(
|
||||
initial_num_extra_ngrams,
|
||||
initial_num_extra_ngrams - num_ngrams_to_prune,
|
||||
"%.4f" % effective_threshold,
|
||||
num_candidates_per_order,
|
||||
num_pruned_per_order,
|
||||
like_change_per_word,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.verbose >= 3:
|
||||
print(
|
||||
"Pruning: like_change_and_ngrams is:\n"
|
||||
+ "\n".join([str(x) for x in like_change_and_ngrams[:num_ngrams_to_prune]])
|
||||
+ "\n-------- stop pruning here: ----------\n"
|
||||
+ "\n".join([str(x) for x in like_change_and_ngrams[num_ngrams_to_prune:]]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.Print(
|
||||
"Counts after pruning to num-extra-ngrams={0}".format(initial_num_extra_ngrams - num_ngrams_to_prune)
|
||||
)
|
||||
|
||||
self.PruneEmptyStates()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after removing empty states [inside pruning algorithm]:")
|
||||
return like_change_per_word
|
||||
|
||||
def PruneToFinalTarget(self, num_extra_ngrams):
|
||||
# prunes to a specified num_extra_ngrams. The 'extra_ngrams' refers to
|
||||
# the count of n-grams of order higher than args.no_backoff_ngram_order.
|
||||
# We construct a sequence of targets that gradually approaches
|
||||
# this value. Doing it iteratively like this is a good way
|
||||
# to deal with the fact that sometimes we can't prune a certain
|
||||
# n-gram before certain other n-grams are pruned (because
|
||||
# they lead to a state that must be kept, or an n-gram exists
|
||||
# that backs off to this n-gram).
|
||||
|
||||
current_num_extra_ngrams = self.GetNumExtraNgrams()
|
||||
|
||||
if num_extra_ngrams >= current_num_extra_ngrams:
|
||||
print(
|
||||
"make_phone_lm.py: not pruning since target num-extra-ngrams={0} is >= "
|
||||
"current num-extra-ngrams={1}".format(num_extra_ngrams, current_num_extra_ngrams),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
target_sequence = [num_extra_ngrams]
|
||||
# two final iterations where the targets differ by factors of 1.1,
|
||||
# preceded by two iterations where the targets differ by factors of 1.2.
|
||||
for this_factor in [1.1, 1.2]:
|
||||
for n in range(0, 2):
|
||||
if int((target_sequence[-1] + 1) * this_factor) < current_num_extra_ngrams:
|
||||
target_sequence.append(int((target_sequence[-1] + 1) * this_factor))
|
||||
# then change in factors of 1.3
|
||||
while True:
|
||||
this_factor = 1.3
|
||||
if int((target_sequence[-1] + 1) * this_factor) < current_num_extra_ngrams:
|
||||
target_sequence.append(int((target_sequence[-1] + 1) * this_factor))
|
||||
else:
|
||||
break
|
||||
|
||||
target_sequence = list(set(target_sequence)) # only keep unique targets.
|
||||
target_sequence.sort(reverse=True)
|
||||
|
||||
print(
|
||||
"make_phone_lm.py: current num-extra-ngrams={0}, pruning with "
|
||||
"following sequence of targets: {1}".format(current_num_extra_ngrams, target_sequence),
|
||||
file=sys.stderr,
|
||||
)
|
||||
total_like_change_per_word = 0.0
|
||||
for target in target_sequence:
|
||||
total_like_change_per_word += self.PruneToIntermediateTarget(target)
|
||||
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: K-L divergence from pruning (upper bound) is " "%.4f" % total_like_change_per_word,
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# returns the number of n-grams on top of those that can't be pruned away
|
||||
# because their order is <= args.no_backoff_ngram_order.
|
||||
def GetNumExtraNgrams(self):
|
||||
ans = 0
|
||||
for hist_len in range(args.no_backoff_ngram_order, args.ngram_order):
|
||||
# note: hist_len + 1 is the actual order.
|
||||
ans += self.GetNumNgrams(hist_len)
|
||||
return ans
|
||||
|
||||
def GetNumNgrams(self, hist_len=None):
|
||||
ans = 0
|
||||
if hist_len is None:
|
||||
for hist_len in range(args.ngram_order):
|
||||
# note: hist_len + 1 is the actual order.
|
||||
ans += self.GetNumNgrams(hist_len)
|
||||
return ans
|
||||
else:
|
||||
for counts_for_hist in self.counts[hist_len].values():
|
||||
ans += len(counts_for_hist.word_to_count)
|
||||
if self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
ans -= 1 # don't count the backoff symbol, it doesn't produce
|
||||
# its own n-gram line.
|
||||
return ans
|
||||
|
||||
# this function, used in PrintAsArpa, converts an integer to
|
||||
# a string by either printing it as a string, or for self.bos_symbol
|
||||
# and self.eos_symbol, printing them as "<s>" and "</s>" respectively.
|
||||
def IntToString(self, i):
|
||||
if i == self.bos_symbol:
|
||||
return "<s>"
|
||||
elif i == self.eos_symbol:
|
||||
return "</s>"
|
||||
else:
|
||||
assert i != self.backoff_symbol
|
||||
return str(i)
|
||||
|
||||
def PrintAsArpa(self):
|
||||
# Prints out the FST in ARPA format.
|
||||
assert args.no_backoff_ngram_order == 1 # without unigrams we couldn't
|
||||
# print as ARPA format.
|
||||
|
||||
print("\\data\\")
|
||||
for hist_len in range(args.ngram_order):
|
||||
# print the number of n-grams. Add 1 for the 1-gram
|
||||
# section because of <s>, we print -99 as the prob so we
|
||||
# have a place to put the backoff prob.
|
||||
print(
|
||||
"ngram {0}={1}".format(
|
||||
hist_len + 1,
|
||||
self.GetNumNgrams(hist_len) + (1 if hist_len == 0 else 0),
|
||||
)
|
||||
)
|
||||
|
||||
print("")
|
||||
|
||||
for hist_len in range(args.ngram_order):
|
||||
print("\\{0}-grams:".format(hist_len + 1))
|
||||
|
||||
# print fake n-gram for <s>, for its backoff prob.
|
||||
if hist_len == 0:
|
||||
backoff_prob = self.GetProb((self.bos_symbol,), self.backoff_symbol)
|
||||
if backoff_prob != None:
|
||||
print("-99\t<s>\t{0}".format("%.5f" % math.log10(backoff_prob)))
|
||||
|
||||
for hist in self.counts[hist_len].keys():
|
||||
for word in self.counts[hist_len][hist].word_to_count.keys():
|
||||
if word != self.backoff_symbol:
|
||||
prob = self.GetProb(hist, word)
|
||||
assert prob != None and prob > 0
|
||||
backoff_prob = self.GetProb((hist) + (word,), self.backoff_symbol)
|
||||
line = "{0}\t{1}".format(
|
||||
"%.5f" % math.log10(prob),
|
||||
" ".join(self.IntToString(x) for x in hist + (word,)),
|
||||
)
|
||||
if backoff_prob != None:
|
||||
line += "\t{0}".format("%.5f" % math.log10(backoff_prob))
|
||||
print(line)
|
||||
print("")
|
||||
print("\\end\\")
|
||||
|
||||
|
||||
ngram_counts = NgramCounts(args.ngram_order)
|
||||
ngram_counts.AddRawCountsFromStandardInput()
|
||||
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Raw counts:")
|
||||
ngram_counts.ApplyBackoff()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after applying Kneser-Ney discounting:")
|
||||
ngram_counts.EnsureStructurallyNeededNgramsExist()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after adding structurally-needed n-grams (1st time):")
|
||||
ngram_counts.PruneEmptyStates()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after removing empty states:")
|
||||
ngram_counts.PruneToFinalTarget(args.num_extra_ngrams)
|
||||
|
||||
ngram_counts.EnsureStructurallyNeededNgramsExist()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after adding structurally-needed n-grams (2nd time):")
|
||||
|
||||
|
||||
if args.print_as_arpa == "true":
|
||||
ngram_counts.PrintAsArpa()
|
||||
else:
|
||||
if args.phone_disambig_symbol is None:
|
||||
sys.exit("make_phone_lm.py: --phone-disambig-symbol must be provided (unless " "you are writing as ARPA")
|
||||
ngram_counts.PrintAsFst(args.phone_disambig_symbol)
|
||||
|
||||
|
||||
## Below are some little test commands that can be used to look at the detailed stats
|
||||
## for a kind of sanity check.
|
||||
# test comand:
|
||||
# (echo 6 7 8 4; echo 7 8 9; echo 7 8; echo 7 4; echo 8 4 ) | utils/lang/make_phone_lm.py --phone-disambig-symbol=400 --verbose=3
|
||||
# (echo 6 7 8 4; echo 7 8 9; echo 7 8; echo 7 4; echo 8 4 ) | utils/lang/make_phone_lm.py --phone-disambig-symbol=400 --verbose=3 --num-extra-ngrams=0
|
||||
# (echo 6 7 8 4; echo 6 7 ) | utils/lang/make_phone_lm.py --print-as-arpa=true --no-backoff-ngram-order=1 --verbose=3
|
||||
|
||||
|
||||
## The following shows how we created some data suitable to do comparisons with
|
||||
## other language modeling toolkits. Note: we're running in a configuration
|
||||
## where --no-backoff-ngram-order=1 (i.e. we have a unigram LM state) because
|
||||
## it's the only way to get perplexity calculations and to write an ARPA file.
|
||||
##
|
||||
# cd egs/tedlium/s5_r2
|
||||
# . ./path.sh
|
||||
# mkdir -p lm_test
|
||||
# ali-to-phones exp/tri3/final.mdl "ark:gunzip -c exp/tri3/ali.*.gz|" ark,t:- | awk '{$1 = ""; print}' > lm_test/phone_seqs
|
||||
# wc lm_test/phone_seqs
|
||||
# 92464 8409563 27953288 lm_test/phone_seqs
|
||||
# head -n 20000 lm_test/phone_seqs > lm_test/train.txt
|
||||
# tail -n 1000 lm_test/phone_seqs > lm_test/test.txt
|
||||
|
||||
## This shows make_phone_lm.py with the default number of extra-lm-states (20k)
|
||||
## You have to have SRILM on your path to ger perplexities [note: it should be on the
|
||||
## path if you installed it and you sourced the tedlium s5b path.sh, as above.]
|
||||
# utils/lang/make_phone_lm.py --print-as-arpa=true --no-backoff-ngram-order=1 --verbose=1 < lm_test/train.txt > lm_test/arpa_pr20k
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_pr20k -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -80130.1 ppl=*8.23985* ppl1= 8.44325
|
||||
# on training data: 0 zeroprobs, logprob= -1.6264e+06 ppl= 7.46947 ppl1= 7.63431
|
||||
|
||||
## This shows make_phone_lm.py without any pruning (make --num-extra-ngrams very large).
|
||||
# utils/lang/make_phone_lm.py --print-as-arpa=true --num-extra-ngrams=1000000 --no-backoff-ngram-order=1 --verbose=1 < lm_test/train.txt > lm_test/arpa
|
||||
# ngram -order 4 -unk -lm lm_test/arpa -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -74976 ppl=*7.19459* ppl1= 7.36064
|
||||
# on training data: 0 zeroprobs, logprob= -1.44198e+06 ppl= 5.94659 ppl1= 6.06279
|
||||
|
||||
## This is SRILM without pruning (c.f. the 7.19 above, it's slightly better).
|
||||
# ngram-count -text lm_test/train.txt -order 4 -kndiscount2 -kndiscount3 -kndiscount4 -interpolate -lm lm_test/arpa_srilm
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_srilm -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -74742.2 ppl= *7.15044* ppl1= 7.31494
|
||||
|
||||
|
||||
## This is SRILM with a pruning beam tuned to get 20k n-grams above unigram
|
||||
## (c.f. the 8.23 above, it's a lot worse).
|
||||
# ngram-count -text lm_test/train.txt -order 4 -kndiscount2 -kndiscount3 -kndiscount4 -interpolate -prune 1.65e-05 -lm lm_test/arpa_srilm.pr1.65e-5
|
||||
# the model has 20249 n-grams above unigram [c.f. our 20k]
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_srilm.pr1.65e-5 -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -86803.7 ppl=*9.82202* ppl1= 10.0849
|
||||
|
||||
|
||||
## This is pocolm..
|
||||
## Note: we have to hold out some of the training data as dev to
|
||||
## estimate the hyperparameters, but we'll fold it back in before
|
||||
## making the final LM. [--fold-dev-into=train]
|
||||
# mkdir -p lm_test/data/text
|
||||
# head -n 1000 lm_test/train.txt > lm_test/data/text/dev.txt
|
||||
# tail -n +1001 lm_test/train.txt > lm_test/data/text/train.txt
|
||||
## give it a 'large' num-words so it picks them all.
|
||||
# export PATH=$PATH:../../../tools/pocolm/scripts
|
||||
# train_lm.py --num-word=100000 --fold-dev-into=train lm_test/data/text 4 lm_test/data/lm_unpruned
|
||||
# get_data_prob.py lm_test/test.txt lm_test/data/lm_unpruned/100000_4.pocolm
|
||||
## compute-probs: average log-prob per word was -1.95956 (perplexity = *7.0962*) over 87489 words.
|
||||
## Note: we can compare this perplexity with 7.15 with SRILM and 7.19 with make_phone_lm.py.
|
||||
|
||||
# pruned_lm_dir=${lm_dir}/${num_word}_${order}_prune${threshold}.pocolm
|
||||
# prune_lm_dir.py --target-num-ngrams=20100 lm_test/data/lm_unpruned/100000_4.pocolm lm_test/data/lm_unpruned/100000_4_pr20k.pocolm
|
||||
# get_data_prob.py lm_test/test.txt lm_test/data/lm_unpruned/100000_4_pr20k.pocolm
|
||||
## compute-probs: average log-prob per word was -2.0409 (perplexity = 7.69757) over 87489 words.
|
||||
## note: the 7.69 can be compared with 9.82 from SRILM and 8.23 from pocolm.
|
||||
## format_arpa_lm.py lm_test/data/lm_unpruned/100000_4_pr20k.pocolm | head
|
||||
## .. it has 20488 n-grams above unigram. More than 20k but not enough to explain the difference
|
||||
## .. in perplexity.
|
||||
|
||||
## OK... if I reran after modifying prune_lm_dir.py to comment out the line
|
||||
## 'steps += 'EM EM'.split()' which adds the two EM stages per step, and got the
|
||||
## perplexity again, I got the following:
|
||||
## compute-probs: average log-prob per word was -2.09722 (perplexity = 8.14353) over 87489 words.
|
||||
## .. so it turns out the E-M is actually important.
|
||||
@@ -0,0 +1,485 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. 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 interpolate two arpa N-gram language models (LMs),
|
||||
culculate perplexity of resulted LM, and make binary KenLM from it.
|
||||
|
||||
Minimun usage example to interpolate two N-gram language models with weights:
|
||||
alpha * ngram_a + beta * ngram_b = 2 * ngram_a + 1 * ngram_b
|
||||
|
||||
python3 ngram_merge.py --kenlm_bin_path /workspace/nemo/decoders/kenlm/build/bin \
|
||||
--arpa_a /path/ngram_a.kenlm.tmp.arpa \
|
||||
--alpha 2 \
|
||||
--arpa_b /path/ngram_b.kenlm.tmp.arpa \
|
||||
--beta 1 \
|
||||
--out_path /path/out
|
||||
|
||||
|
||||
Merge two N-gram language models and calculate its perplexity with test_file.
|
||||
python3 ngram_merge.py --kenlm_bin_path /workspace/nemo/decoders/kenlm/build/bin \
|
||||
--ngram_bin_path /workspace/nemo/decoders/ngram-1.3.14/src/bin \
|
||||
--arpa_a /path/ngram_a.kenlm.tmp.arpa \
|
||||
--alpha 0.5 \
|
||||
--arpa_b /path/ngram_b.kenlm.tmp.arpa \
|
||||
--beta 0.5 \
|
||||
--out_path /path/out \
|
||||
--nemo_model_file /path/to/model_tokenizer.nemo \
|
||||
--test_file /path/to/test_manifest.json \
|
||||
--force
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
import torch
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.modules.rnnt import RNNTDecoder
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import DEFAULT_TOKEN_OFFSET, kenlm_utils
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class NgramMerge:
|
||||
def __init__(self, ngram_bin_path):
|
||||
self.ngram_bin_path = ngram_bin_path
|
||||
|
||||
def ngrammerge(self, arpa_a: str, alpha: float, arpa_b: str, beta: float, arpa_c: str, force: bool) -> str:
|
||||
"""
|
||||
Merge two ARPA n-gram language models using the ngrammerge command-line tool and output the result in ARPA format.
|
||||
|
||||
Args:
|
||||
arpa_a (str): Path to the first input ARPA file.
|
||||
alpha (float): Interpolation weight for the first model.
|
||||
arpa_b (str): Path to the second input ARPA file.
|
||||
beta (float): Interpolation weight for the second model.
|
||||
arpa_c (str): Path to the output ARPA file.
|
||||
force (bool): Whether to overwrite existing output files.
|
||||
|
||||
Returns:
|
||||
str: Path to the output ARPA file in mod format.
|
||||
"""
|
||||
mod_a = arpa_a + ".mod"
|
||||
mod_b = arpa_b + ".mod"
|
||||
mod_c = arpa_c + ".mod"
|
||||
if os.path.isfile(mod_c) and not force:
|
||||
logging.info("File " + mod_c + " exists. Skipping.")
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngrammerge"),
|
||||
"--alpha=" + str(alpha),
|
||||
"--beta=" + str(beta),
|
||||
"--normalize",
|
||||
# "--use_smoothing",
|
||||
mod_a,
|
||||
mod_b,
|
||||
mod_c,
|
||||
]
|
||||
logging.info(
|
||||
"\n"
|
||||
+ str(
|
||||
subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
return mod_c
|
||||
|
||||
def arpa2mod(self, arpa_path: str, force: bool):
|
||||
"""
|
||||
This function reads an ARPA n-gram model and converts it to a binary format. The binary model is saved to the same directory as the ARPA model with a ".mod" extension. If the binary model file already exists and force argument is False, then the function skips conversion and returns a message. Otherwise, it executes the command to create a binary model using the subprocess.run method.
|
||||
|
||||
Parameters:
|
||||
arpa_path (string): The file path to the ARPA n-gram model.
|
||||
force (bool): If True, the function will convert the ARPA model to binary even if the binary file already exists. If False and the binary file exists, the function will skip the conversion.
|
||||
Returns:
|
||||
If the binary model file already exists and force argument is False, returns a message indicating that the file exists and the conversion is skipped.
|
||||
Otherwise, returns a subprocess.CompletedProcess object, which contains information about the executed command. The subprocess's output and error streams are redirected to stdout and stderr, respectively.
|
||||
"""
|
||||
mod_path = arpa_path + ".mod"
|
||||
if os.path.isfile(mod_path) and not force:
|
||||
return "File " + mod_path + " exists. Skipping."
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramread"),
|
||||
"--ARPA",
|
||||
arpa_path,
|
||||
mod_path,
|
||||
]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
def merge(
|
||||
self, arpa_a: str, alpha: float, arpa_b: str, beta: float, out_path: str, force: bool
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Merges two ARPA language models using the ngrammerge tool.
|
||||
|
||||
Args:
|
||||
arpa_a (str): Path to the first ARPA language model file.
|
||||
alpha (float): Interpolation weight for the first model.
|
||||
arpa_b (str): Path to the second ARPA language model file.
|
||||
beta (float): Interpolation weight for the second model.
|
||||
out_path (str): Path to the output directory for the merged ARPA model.
|
||||
force (bool): Whether to force overwrite of existing files.
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: A tuple containing the path to the merged binary language model file and the path to the
|
||||
merged ARPA language model file.
|
||||
"""
|
||||
logging.info("\n" + str(self.arpa2mod(arpa_a, force)) + "\n")
|
||||
|
||||
logging.info("\n" + str(self.arpa2mod(arpa_b, force)) + "\n")
|
||||
arpa_c = os.path.join(
|
||||
out_path,
|
||||
f"{os.path.split(arpa_a)[1]}-{alpha}-{os.path.split(arpa_b)[1]}-{beta}.arpa",
|
||||
)
|
||||
mod_c = self.ngrammerge(arpa_a, alpha, arpa_b, beta, arpa_c, force)
|
||||
return mod_c, arpa_c
|
||||
|
||||
def perplexity(self, ngram_mod: str, test_far: str) -> str:
|
||||
"""
|
||||
Calculates perplexity of a given ngram model on a test file.
|
||||
|
||||
Args:
|
||||
ngram_mod (str): The path to the ngram model file.
|
||||
test_far (str): The path to the test file.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the perplexity calculated.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the subprocess to calculate perplexity returns a non-zero exit code.
|
||||
|
||||
Example:
|
||||
>>> perplexity("/path/to/ngram_model", "/path/to/test_file")
|
||||
'Perplexity: 123.45'
|
||||
"""
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramperplexity"),
|
||||
"--v=1",
|
||||
ngram_mod,
|
||||
test_far,
|
||||
]
|
||||
ps = subprocess.Popen(sh_args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
stdout, stderr = ps.communicate()
|
||||
exit_code = ps.wait()
|
||||
command = " ".join(sh_args)
|
||||
assert (
|
||||
exit_code == 0
|
||||
), f"Exit_code must be 0.\n bash command: {command} \n stdout: {stdout} \n stderr: {stderr}"
|
||||
perplexity_out = "\n".join(stdout.split("\n")[-6:-1])
|
||||
return perplexity_out
|
||||
|
||||
def make_arpa(self, ngram_mod: str, ngram_arpa: str, force: bool):
|
||||
"""
|
||||
Converts an ngram model in binary format to ARPA format.
|
||||
|
||||
Args:
|
||||
- ngram_mod (str): The path to the ngram model in binary format.
|
||||
- ngram_arpa (str): The desired path for the ARPA format output file.
|
||||
- force (bool): If True, the ARPA format file will be generated even if it already exists.
|
||||
|
||||
Returns:
|
||||
- Tuple[bytes, bytes]
|
||||
|
||||
Raises:
|
||||
- AssertionError: If the shell command execution returns a non-zero exit code.
|
||||
- FileNotFoundError: If the binary ngram model file does not exist.
|
||||
"""
|
||||
if os.path.isfile(ngram_arpa) and not force:
|
||||
logging.info("File " + ngram_arpa + " exists. Skipping.")
|
||||
return None
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramprint"),
|
||||
"--ARPA",
|
||||
ngram_mod,
|
||||
ngram_arpa,
|
||||
]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
def test_perplexity(self, mod_c: str, symbols: str, test_txt: str, nemo_model_file: str, tmp_path: str) -> str:
|
||||
"""
|
||||
Tests the perplexity of a given ngram model on a test file.
|
||||
|
||||
Args:
|
||||
mod_c (str): The path to the ngram model file.
|
||||
symbols (str): The path to the symbol table file.
|
||||
test_txt (str): The path to the test text file.
|
||||
nemo_model_file (str): The path to the NeMo model file.
|
||||
tmp_path (str): The path to the temporary directory where the test far file will be created.
|
||||
force (bool): If True, overwrites any existing far file.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the perplexity calculated.
|
||||
|
||||
Example:
|
||||
>>> test_perplexity("/path/to/ngram_model", "/path/to/symbol_table", "/path/to/test_file", "/path/to/tokenizer_model", "/path/to/tmp_dir", True)
|
||||
'Perplexity: 123.45'
|
||||
"""
|
||||
|
||||
test_far = farcompile(symbols, test_txt, tmp_path, nemo_model_file)
|
||||
res_p = self.perplexity(mod_c, test_far)
|
||||
return res_p
|
||||
|
||||
|
||||
def farcompile(symbols: str, text_file: str, tmp_path: str, nemo_model_file: str) -> str:
|
||||
"""
|
||||
Compiles a text file into a FAR file using the given symbol table or tokenizer.
|
||||
|
||||
Args:
|
||||
symbols (str): The path to the symbol table file.
|
||||
text_file (str): The path to the text file to compile.
|
||||
tmp_path (str): The path to the temporary directory where the test far file will be created.
|
||||
nemo_model_file (str): The path to the NeMo model file (.nemo).
|
||||
force (bool): If True, overwrites any existing FAR file.
|
||||
|
||||
Returns:
|
||||
test_far (str): The path to the resulting FAR file.
|
||||
|
||||
Example:
|
||||
>>> farcompile("/path/to/symbol_table", "/path/to/text_file", "/path/to/far_file", "/path/to/tokenizer_model", "/path/to/nemo_model", True)
|
||||
"""
|
||||
test_far = os.path.join(tmp_path, os.path.split(text_file)[1] + ".far")
|
||||
|
||||
sh_args = [
|
||||
"farcompilestrings",
|
||||
"--generate_keys=10",
|
||||
"--fst_type=compact",
|
||||
"--symbols=" + symbols,
|
||||
"--keep_symbols",
|
||||
]
|
||||
|
||||
tokenizer, encoding_level, is_aggregate_tokenizer, _ = kenlm_utils.setup_tokenizer(nemo_model_file)
|
||||
|
||||
with open(test_far, "wb") as test_far_stdout:
|
||||
ps = subprocess.Popen(
|
||||
sh_args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=test_far_stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
kenlm_utils.iter_files(
|
||||
source_path=[text_file],
|
||||
dest_path=ps.stdin,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=1,
|
||||
)
|
||||
stdout, stderr = ps.communicate()
|
||||
|
||||
exit_code = ps.returncode
|
||||
|
||||
command = f"{shlex.join(sh_args)} > {shlex.quote(test_far)}"
|
||||
assert exit_code == 0, f"Exit_code must be 0.\n bash command: {command} \n stdout: {stdout} \n stderr: {stderr}"
|
||||
return test_far
|
||||
|
||||
|
||||
def make_kenlm(kenlm_bin_path: str, ngram_arpa: str, force: bool):
|
||||
"""
|
||||
Builds a language model from an ARPA format file using the KenLM toolkit.
|
||||
|
||||
Args:
|
||||
- kenlm_bin_path (str): The path to the KenLM toolkit binary.
|
||||
- ngram_arpa (str): The path to the ARPA format file.
|
||||
- force (bool): If True, the KenLM language model will be generated even if it already exists.
|
||||
|
||||
Raises:
|
||||
- AssertionError: If the shell command execution returns a non-zero exit code.
|
||||
- FileNotFoundError: If the KenLM binary or ARPA format file does not exist.
|
||||
"""
|
||||
ngram_kenlm = ngram_arpa + ".kenlm"
|
||||
if os.path.isfile(ngram_kenlm) and not force:
|
||||
logging.info("File " + ngram_kenlm + " exists. Skipping.")
|
||||
return None
|
||||
else:
|
||||
sh_args = [os.path.join(kenlm_bin_path, "build_binary"), "trie", "-i", ngram_arpa, ngram_kenlm]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def make_symbol_list(nemo_model_file, symbols, force):
|
||||
"""
|
||||
Function: make_symbol_list
|
||||
|
||||
Create a symbol table for the input tokenizer model file.
|
||||
|
||||
Args:
|
||||
nemo_model_file (str): Path to the NeMo model file.
|
||||
symbols (str): Path to the file where symbol list will be saved.
|
||||
force (bool): Flag to force creation of symbol list even if it already exists.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
None
|
||||
"""
|
||||
if os.path.isfile(symbols) and not force:
|
||||
logging.info("File " + symbols + " exists. Skipping.")
|
||||
else:
|
||||
if nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(nemo_model_file, map_location=torch.device('cpu'))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(nemo_model_file, map_location=torch.device('cpu'))
|
||||
|
||||
if isinstance(asr_model.decoder, RNNTDecoder):
|
||||
vocab_size = asr_model.decoder.blank_idx
|
||||
else:
|
||||
vocab_size = len(asr_model.decoder.vocabulary)
|
||||
|
||||
vocab = [chr(idx + DEFAULT_TOKEN_OFFSET) for idx in range(vocab_size)]
|
||||
with open(symbols, "w", encoding="utf-8") as f:
|
||||
for i, v in enumerate(vocab):
|
||||
f.write(v + " " + str(i) + "\n")
|
||||
|
||||
|
||||
def main(
|
||||
kenlm_bin_path: str,
|
||||
ngram_bin_path: str,
|
||||
arpa_a: str,
|
||||
alpha: float,
|
||||
arpa_b: str,
|
||||
beta: float,
|
||||
out_path: str,
|
||||
test_file: str,
|
||||
symbols: str,
|
||||
nemo_model_file: str,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Entry point function for merging ARPA format language models, testing perplexity, creating symbol list,
|
||||
and making ARPA and Kenlm models.
|
||||
|
||||
Args:
|
||||
- kenlm_bin_path (str): The path to the Kenlm binary.
|
||||
- arpa_a (str): The path to the first ARPA format language model.
|
||||
- alpha (float): The weight given to the first language model during merging.
|
||||
- arpa_b (str): The path to the second ARPA format language model.
|
||||
- beta (float): The weight given to the second language model during merging.
|
||||
- out_path (str): The path where the output files will be saved.
|
||||
- test_file (str): The path to the file on which perplexity needs to be calculated.
|
||||
- symbols (str): The path to the file where symbol list for the tokenizer model will be saved.
|
||||
- nemo_model_file (str): The path to the NeMo model file.
|
||||
- force (bool): If True, overwrite existing files, otherwise skip the operations.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
nm = NgramMerge(ngram_bin_path)
|
||||
mod_c, arpa_c = nm.merge(arpa_a, alpha, arpa_b, beta, out_path, force)
|
||||
|
||||
if test_file and nemo_model_file:
|
||||
if not symbols:
|
||||
symbols = os.path.join(out_path, os.path.split(nemo_model_file)[1] + ".syms")
|
||||
make_symbol_list(nemo_model_file, symbols, force)
|
||||
for test_f in test_file.split(","):
|
||||
test_p = nm.test_perplexity(mod_c, symbols, test_f, nemo_model_file, out_path)
|
||||
logging.info("Perplexity summary " + test_f + " : " + test_p)
|
||||
|
||||
logging.info("Making ARPA and Kenlm model " + arpa_c)
|
||||
out = nm.make_arpa(mod_c, arpa_c, force)
|
||||
if out:
|
||||
logging.info("\n" + str(out) + "\n")
|
||||
|
||||
out = make_kenlm(kenlm_bin_path, arpa_c, force)
|
||||
if out:
|
||||
logging.info("\n" + str(out) + "\n")
|
||||
|
||||
|
||||
def _parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Interpolate ARPA N-gram language models and make KenLM binary model to be used with beam search decoder of ASR models."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kenlm_bin_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="The path to the bin folder of KenLM library.",
|
||||
) # Use /workspace/nemo/decoders/kenlm/build/bin if installed it with scripts/asr_language_modeling/ngram_lm/install_beamsearch_decoders.sh
|
||||
parser.add_argument(
|
||||
"--ngram_bin_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="The path to the bin folder of OpenGrm Ngram library.",
|
||||
) # Use /workspace/nemo/decoders/ngram-1.3.14/src/bin if installed it with scripts/installers/install_opengrm.sh
|
||||
parser.add_argument("--arpa_a", required=True, type=str, help="Path to the arpa_a")
|
||||
parser.add_argument("--alpha", required=True, type=float, help="Weight of arpa_a")
|
||||
parser.add_argument("--arpa_b", required=True, type=str, help="Path to the arpa_b")
|
||||
parser.add_argument("--beta", required=True, type=float, help="Weight of arpa_b")
|
||||
parser.add_argument(
|
||||
"--out_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="Path to write tmp and resulted files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_file",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to test file to count perplexity if provided.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symbols",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to symbols (.syms) file . Could be calculated if it is not provided. Use as: --symbols /path/to/earnest.syms",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nemo_model_file",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="The path to '.nemo' file of the ASR model, or name of a pretrained NeMo model",
|
||||
)
|
||||
parser.add_argument("--force", "-f", action="store_true", help="Whether to recompile and rewrite all files")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(**vars(_parse_args()))
|
||||
@@ -0,0 +1,196 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. 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_paths=<list of paths 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 logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from glob import glob
|
||||
from typing import List
|
||||
|
||||
from omegaconf import MISSING
|
||||
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel, kenlm_utils
|
||||
from nemo.core.config import hydra_runner
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainKenlmConfig:
|
||||
"""
|
||||
Train an N-gram language model with KenLM to be used with beam search decoder of ASR models.
|
||||
"""
|
||||
|
||||
train_paths: List[str] = (
|
||||
MISSING # List of training files or folders. Files can be a plain text file or ".json" manifest or ".json.gz". Example: [/path/to/manifest/file,/path/to/folder]
|
||||
)
|
||||
|
||||
nemo_model_file: str = MISSING # The path to '.nemo' file of the ASR model, or name of a pretrained NeMo model
|
||||
kenlm_model_file: str = MISSING # The path to store the KenLM binary model file
|
||||
ngram_length: int = MISSING # The order of N-gram LM
|
||||
kenlm_bin_path: str = MISSING # The path to the bin folder of KenLM.
|
||||
|
||||
preserve_arpa: bool = False # Whether to preserve the intermediate ARPA file.
|
||||
ngram_prune: List[int] = field(
|
||||
default_factory=lambda: [0]
|
||||
) # List of digits to prune Ngram. Example: [0,0,1]. See Pruning section on the https://kheafield.com/code/kenlm/estimation
|
||||
cache_path: str = "" # Cache path to save tokenized files.
|
||||
verbose: int = 1 # Verbose level, default is 1.
|
||||
save_nemo: bool = False # Save .nemo checkpoint to use with NGramGPULanguageModel
|
||||
normalize_unk_nemo: bool = True # Normalize the UNK token in the NGramGPULanguageModel model
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='TrainKenlmConfig', schema=TrainKenlmConfig)
|
||||
def main(args: TrainKenlmConfig):
|
||||
train_paths = kenlm_utils.get_train_list(args.train_paths)
|
||||
|
||||
if isinstance(args.ngram_prune, str):
|
||||
args.ngram_prune = [args.ngram_prune]
|
||||
|
||||
tokenizer, encoding_level, is_aggregate_tokenizer, full_vocab_size = kenlm_utils.setup_tokenizer(
|
||||
args.nemo_model_file
|
||||
)
|
||||
|
||||
if encoding_level == "subword":
|
||||
discount_arg = "--discount_fallback" # --discount_fallback is needed for training KenLM for BPE-based models
|
||||
else:
|
||||
discount_arg = ""
|
||||
|
||||
arpa_file = f"{args.kenlm_model_file}.tmp.arpa"
|
||||
""" LMPLZ ARGUMENT SETUP """
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, 'lmplz'),
|
||||
"-o",
|
||||
str(args.ngram_length),
|
||||
"--arpa",
|
||||
arpa_file,
|
||||
discount_arg,
|
||||
"--prune",
|
||||
] + [str(n) for n in args.ngram_prune]
|
||||
|
||||
if args.cache_path:
|
||||
if not os.path.exists(args.cache_path):
|
||||
os.makedirs(args.cache_path, exist_ok=True)
|
||||
|
||||
""" DATASET SETUP """
|
||||
encoded_train_files = []
|
||||
for file_num, train_file in enumerate(train_paths):
|
||||
logging.info(f"Encoding the train file '{train_file}' number {file_num+1} out of {len(train_paths)} ...")
|
||||
|
||||
cached_files = glob(os.path.join(args.cache_path, os.path.split(train_file)[1]) + "*")
|
||||
encoded_train_file = os.path.join(args.cache_path, os.path.split(train_file)[1] + f"_{file_num}.tmp.txt")
|
||||
if (
|
||||
cached_files and cached_files[0] != encoded_train_file
|
||||
): # cached_files exists but has another file name: f"_{file_num}.tmp.txt"
|
||||
os.rename(cached_files[0], encoded_train_file)
|
||||
logging.info("Rename", cached_files[0], "to", encoded_train_file)
|
||||
|
||||
encoded_train_files.append(encoded_train_file)
|
||||
|
||||
kenlm_utils.iter_files(
|
||||
source_path=train_paths,
|
||||
dest_path=encoded_train_files,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
first_process_args = ["cat"] + encoded_train_files
|
||||
first_process = subprocess.Popen(first_process_args, stdout=subprocess.PIPE, stderr=sys.stderr)
|
||||
|
||||
logging.info(f"Running lmplz command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
kenlm_p = subprocess.run(
|
||||
kenlm_args,
|
||||
stdin=first_process.stdout,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
first_process.wait()
|
||||
|
||||
else:
|
||||
logging.info(f"Running lmplz command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
kenlm_p = subprocess.Popen(kenlm_args, stdout=sys.stdout, stdin=subprocess.PIPE, stderr=sys.stderr)
|
||||
kenlm_utils.iter_files(
|
||||
source_path=train_paths,
|
||||
dest_path=kenlm_p.stdin,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
kenlm_p.communicate()
|
||||
|
||||
if kenlm_p.returncode != 0:
|
||||
raise RuntimeError("Training KenLM was not successful!")
|
||||
|
||||
""" BINARY BUILD """
|
||||
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, "build_binary"),
|
||||
"trie",
|
||||
arpa_file,
|
||||
args.kenlm_model_file,
|
||||
]
|
||||
logging.info(f"Running binary_build command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
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!")
|
||||
|
||||
if args.save_nemo:
|
||||
if full_vocab_size is None:
|
||||
raise NotImplementedError("Unknown vocab size, cannot convert the model")
|
||||
nemo_model = NGramGPULanguageModel.from_arpa(
|
||||
lm_path=arpa_file, vocab_size=full_vocab_size, normalize_unk=args.normalize_unk_nemo
|
||||
)
|
||||
nemo_model.save_to(f"{args.kenlm_model_file}.nemo")
|
||||
|
||||
if not args.preserve_arpa:
|
||||
os.remove(arpa_file)
|
||||
logging.info(f"Deleted the arpa file '{arpa_file}'.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user