Introduce code from Rust monorepo. This is a mess

This commit is contained in:
Brandon Thomas
2022-02-28 05:43:54 -05:00
parent 921fb49f9a
commit ba326bfcfd
1058 changed files with 192192 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# 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.
from nemo.collections.nlp.data.data_utils import *
from nemo.collections.nlp.data.entity_linking.entity_linking_dataset import EntityLinkingDataset
from nemo.collections.nlp.data.information_retrieval.information_retrieval_dataset import (
BertInformationRetrievalDataset,
)
from nemo.collections.nlp.data.language_modeling.l2r_lm_dataset import (
L2RLanguageModelingDataset,
TarredL2RLanguageModelingDataset,
)
from nemo.collections.nlp.data.language_modeling.lm_bert_dataset import (
BertPretrainingDataset,
BertPretrainingPreprocessedDataloader,
)
from nemo.collections.nlp.data.language_modeling.sentence_dataset import SentenceDataset, TarredSentenceDataset
from nemo.collections.nlp.data.machine_translation.machine_translation_dataset import (
TarredTranslationDataset,
TranslationDataset,
)
from nemo.collections.nlp.data.neural_machine_translation.neural_machine_translation_dataset import (
NeuralMachineTranslationDataset,
)
from nemo.collections.nlp.data.question_answering_squad.qa_dataset import SquadDataset
from nemo.collections.nlp.data.token_classification.token_classification_dataset import (
BertTokenClassificationDataset,
BertTokenClassificationInferDataset,
)
@@ -0,0 +1,15 @@
# 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.
from nemo.collections.nlp.data.data_utils.data_preprocessing import *
@@ -0,0 +1,479 @@
# 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.
import csv
import json
import os
import pickle
import random
import re
import string
from collections import Counter
import numpy as np
import torch
from tqdm.auto import tqdm
from nemo.utils import logging
from nemo.utils.env_var_parsing import get_envint
__all__ = [
'DataProcessor',
'get_label_stats',
'partition_data',
'write_files',
'write_data',
'create_dataset',
'read_csv',
'get_dataset',
'partition',
'map_entities',
'get_entities',
'get_data',
'reverse_dict',
'get_intent_labels',
'get_stats',
'DATABASE_EXISTS_TMP',
'MODE_EXISTS_TMP',
'is_whitespace',
'write_vocab',
'if_exist',
'remove_punctuation_from_sentence',
'dataset_to_ids',
'get_freq_weights',
'fill_class_weights',
'normalize_answer',
'get_labels_to_labels_id_mapping',
'get_vocab',
'find_newlines',
'load_data_indices',
]
DATABASE_EXISTS_TMP = '{} dataset has already been processed and stored at {}'
MODE_EXISTS_TMP = '{} mode of {} dataset has already been processed and stored at {}'
class DataProcessor(object):
"""Base class for data converters for sequence classification data sets."""
def get_train_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the train set."""
raise NotImplementedError()
def get_dev_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the dev set."""
raise NotImplementedError()
def get_labels(self):
"""Gets the list of labels for this data set."""
raise NotImplementedError()
@classmethod
def _read_tsv(cls, input_file, quotechar=None):
"""Reads a tab separated value file."""
with open(input_file, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f, delimiter="\t", quotechar=quotechar)
lines = []
for line in reader:
# if sys.version_info[0] == 2:
# line = list(unicode(cell, 'utf-8') for cell in line)
lines.append(line)
return lines
def normalize_answer(s):
"""Lower text and remove punctuation, articles and extra whitespace."""
def remove_articles(text):
return re.sub(r'\b(a|an|the)\b', ' ', text)
def white_space_fix(text):
return ' '.join(text.split())
def remove_punc(text):
exclude = set(string.punctuation)
return ''.join(ch for ch in text if ch not in exclude)
def lower(text):
return text.lower()
return white_space_fix(remove_articles(remove_punc(lower(s))))
def get_label_stats(labels, outfile='stats.tsv', verbose=True):
'''
Args:
labels: list of all labels
outfile: path to the file where to save label stats
Returns:
total (int): total number of labels
label_frequencies (list of tuples): each tuple represent (label, label frequency)
max id of the labels
'''
labels = Counter(labels)
total = sum(labels.values())
out = open(outfile, 'w')
i = 0
freq_dict = {}
label_frequencies = labels.most_common()
for k, v in label_frequencies:
out.write(f'{k}\t\t{round(v/total,5)}\t\t{v}\n')
if verbose and i < 3:
logging.info(f'label: {k}, {v} out of {total} ({(v / total)*100.0:.2f}%).')
i += 1
freq_dict[k] = v
return total, freq_dict, max(labels.keys())
def partition_data(intent_queries, slot_tags, split=0.1):
n = len(intent_queries)
n_dev = int(n * split)
dev_idx = set(random.sample(range(n), n_dev))
dev_intents, dev_slots, train_intents, train_slots = [], [], [], []
dev_intents.append('sentence\tlabel\n')
train_intents.append('sentence\tlabel\n')
for i, item in enumerate(intent_queries):
if i in dev_idx:
dev_intents.append(item)
dev_slots.append(slot_tags[i])
else:
train_intents.append(item)
train_slots.append(slot_tags[i])
return train_intents, train_slots, dev_intents, dev_slots
def write_files(data, outfile):
with open(outfile, 'w') as f:
for item in data:
item = f'{item.strip()}\n'
f.write(item)
def write_data(data, slot_dict, intent_dict, outfold, mode, uncased):
intent_file = open(f'{outfold}/{mode}.tsv', 'w')
intent_file.write('sentence\tlabel\n')
slot_file = open(f'{outfold}/{mode}_slots.tsv', 'w')
for tokens, slots, intent in data:
text = ' '.join(tokens)
if uncased:
text = text.lower()
intent_file.write(f'{text}\t{intent_dict[intent]}\n')
slots = [str(slot_dict[slot]) for slot in slots]
slot_file.write(' '.join(slots) + '\n')
intent_file.close()
slot_file.close()
def create_dataset(train, dev, slots, intents, uncased, outfold):
os.makedirs(outfold, exist_ok=True)
if 'O' in slots:
slots.remove('O')
slots = sorted(list(slots)) + ['O']
intents = sorted(list(intents))
slots = write_vocab(slots, f'{outfold}/dict.slots.csv')
intents = write_vocab(intents, f'{outfold}/dict.intents.csv')
write_data(train, slots, intents, outfold, 'train', uncased)
write_data(dev, slots, intents, outfold, 'test', uncased)
def read_csv(file_path):
rows = []
with open(file_path, 'r') as csvfile:
read_csv = csv.reader(csvfile, delimiter=',')
for row in read_csv:
rows.append(row)
return rows
def get_dataset(files, dev_split=0.1):
# entity2value, value2entity = get_entities(files)
data, slots, intents = get_data(files)
if len(data) == 1:
train, dev = partition(data[0], split=dev_split)
else:
train, dev = data[0], data[1]
return train, dev, slots, intents
def partition(data, split=0.1):
n = len(data)
n_dev = int(n * split)
dev_idx = set(random.sample(range(n), n_dev))
dev, train = [], []
for i, item in enumerate(data):
if i in dev_idx:
dev.append(item)
else:
train.append(item)
return train, dev
def map_entities(entity2value, entities):
for key in entities:
if 'data' in entities[key]:
if key not in entity2value:
entity2value[key] = set([])
values = []
for value in entities[key]['data']:
values.append(value['value'])
values.extend(value['synonyms'])
entity2value[key] = entity2value[key] | set(values)
return entity2value
def get_entities(files):
entity2value = {}
for file in files:
with open(file, 'r') as json_file:
data = json.load(json_file)
entity2value = map_entities(entity2value, data['entities'])
value2entity = reverse_dict(entity2value)
return entity2value, value2entity
def get_data(files):
all_data, all_slots, all_intents = [], set(['O']), set()
for file in files:
file_data = []
with open(file, 'r') as json_file:
data = json.load(json_file)
for intent in data['intents']:
all_intents.add(intent)
utterances = data['intents'][intent]['utterances']
for utterance in utterances:
tokens, slots = [], []
for frag in utterance['data']:
frag_tokens = frag['text'].strip().split()
tokens.extend(frag_tokens)
if 'slot_name' not in frag:
slot = 'O'
else:
slot = frag['slot_name']
all_slots.add(slot)
slots.extend([slot] * len(frag_tokens))
file_data.append((tokens, slots, intent))
all_data.append(file_data)
return all_data, all_slots, all_intents
def reverse_dict(entity2value):
value2entity = {}
for entity in entity2value:
for value in entity2value[entity]:
value2entity[value] = entity
return value2entity
def get_intent_labels(intent_file):
labels = {}
label = 0
with open(intent_file, 'r') as f:
for line in f:
intent = line.strip()
labels[intent] = label
label += 1
return labels
def get_stats(lengths):
logging.info('Some stats of the lengths of the sequences:')
lengths = np.asarray(lengths)
logging.info(
f'Min: {np.min(lengths)} | \
Max: {np.max(lengths)} | \
Mean: {np.mean(lengths)} | \
Median: {np.median(lengths)}'
)
logging.info(f'75 percentile: {np.percentile(lengths, 75):.2f}')
logging.info(f'99 percentile: {np.percentile(lengths, 99):.2f}')
def is_whitespace(c):
if c == " " or c == "\t" or c == "\r" or c == "\n" or ord(c) == 0x202F:
return True
return False
def write_vocab(items, outfile):
vocab = {}
idx = 0
with open(outfile, 'w') as f:
for item in items:
f.write(item + '\n')
vocab[item] = idx
idx += 1
return vocab
def get_labels_to_labels_id_mapping(file):
'''
Reads labels from the file and returns labels to id mapping dictionary
Args:
file: path to file
Returns:
labels to id mapping dictionary
'''
lines = open(file, 'r').readlines()
lines = [line.strip() for line in lines if line.strip()]
label_ids = {lines[i]: i for i in range(len(lines))}
return label_ids
def if_exist(outfold, files):
if not os.path.exists(outfold):
return False
for file in files:
if not os.path.exists(f'{outfold}/{file}'):
return False
return True
def remove_punctuation_from_sentence(sentence):
sentence = re.sub('[' + string.punctuation + ']', '', sentence)
sentence = sentence.lower()
return sentence
def dataset_to_ids(dataset, tokenizer, cache_ids=False, add_bos_eos=True, cache_data_per_node=False, use_cache=False):
"""
Reads dataset from file line by line, tokenizes each line with tokenizer,
and returns list of lists which corresponds to ids of tokenized strings.
Args:
dataset (str): path to dataset
tokenizer: tokenizer to convert text into ids
cache_ids (bool): if True, ids are saved to disk as pickle file
with similar name (e.g., data.txt --> data.txt.pkl)
add_bos_eos (bool): whether to add <s> and </s> symbols (e.g., for NMT)
cache_data_per_node (bool): Cache data on local_rank 0. Use when there is not a shared-filesystem.
use_cache (bool): Use cached ids if they exist.
Returns:
ids: list of ids which correspond to tokenized strings of the dataset
"""
cached_ids_dataset = dataset + str(".pkl")
if use_cache and os.path.isfile(cached_ids_dataset):
logging.info("Loading cached tokenized dataset ...")
ids = pickle.load(open(cached_ids_dataset, "rb"))
else:
logging.info(f"Tokenizing dataset {dataset}...")
data = open(dataset, "rb").readlines()
ids = []
for sentence in tqdm(data, desc='Tokenizing sentence'):
sent_ids = tokenizer.text_to_ids(sentence.decode("utf-8"))
if add_bos_eos:
sent_ids = [tokenizer.bos_id] + sent_ids + [tokenizer.eos_id]
ids.append(sent_ids)
if cache_ids and (
not torch.distributed.is_initialized() or (cache_data_per_node and get_envint("LOCAL_RANK", 0) == 0)
):
logging.info("Caching tokenized dataset ...")
pickle.dump(ids, open(cached_ids_dataset, "wb"))
return ids
def get_freq_weights(label_freq):
"""
Goal is to give more weight to the classes with less samples
so as to match the ones with the higher frequencies. We achieve this by
dividing the total frequency by the freq of each label to calculate its weight.
"""
total_size = 0
for lf in label_freq.values():
total_size += lf
weighted_slots = {label: (total_size / (len(label_freq) * freq)) for label, freq in label_freq.items()}
return weighted_slots
def fill_class_weights(weights, max_id=-1):
"""
Gets a dictionary of labels with their weights and creates a list with size of the labels filled with those weights.
Missing labels in the dictionary would get value 1.
Args:
weights: dictionary of weights for labels, labels as keys and weights are their values
max_id: the largest label id in the dataset, default=-1 would consider the largest label in the weights dictionary as max_id
Returns:
weights_list: list of weights for labels
"""
if max_id < 0:
max_id = 0
for l in weights.keys():
max_id = max(max_id, l)
all_weights = [1.0] * (max_id + 1)
for i in range(len(all_weights)):
if i in weights:
all_weights[i] = weights[i]
return all_weights
def get_vocab(file):
lines = open(file, 'r').readlines()
lines = [line.strip() for line in lines if line.strip()]
labels = {i: lines[i] for i in range(len(lines))}
return labels
def find_newlines(contents):
"""
Finds all of the newline positions in a text file.
"""
start = 0
while True:
try:
# index and split are much faster than Python for loops
new_start = contents.index(b"\n", start)
line = (
contents[start:new_start]
.replace(b"\xc2\x99", b" ")
.replace(b"\xc2\xa0", b" ")
.decode("utf-8", errors="ignore")
)
if len(line.split()) > 0:
yield start
start = new_start + 1
except ValueError:
break
def load_data_indices(idx_file: str, data_file: str, savename: str):
"""
Loads dataset index file if it exsits
"""
data_dir = data_file[: data_file.rfind('/')]
mode = data_file[data_file.rfind('/') + 1 : data_file.rfind('.')]
idx_file = f"{data_dir}/{mode}_{savename}.pkl"
if os.path.isfile(idx_file):
# If the sentence indices file already exists, load from it
with open(idx_file, "rb") as f:
indices = pickle.load(f)
return indices, idx_file, data_dir
return None, idx_file, data_dir
@@ -0,0 +1,17 @@
# 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.
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.data_processor import SGDDataProcessor
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.dataset import SGDDataset
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.schema import Schema
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,676 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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 file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/baseline/data_utils.py
"""
import collections
import json
import os
import pickle
import re
from typing import Dict, List
import numpy as np
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.input_example import InputExample
from nemo.utils import logging
__all__ = ['SGDDataProcessor']
FILE_RANGES = {
"sgd_single_domain": {"train": range(1, 44), "dev": range(1, 8), "test": range(1, 12)},
"sgd_multi_domain": {"train": range(44, 128), "dev": range(8, 21), "test": range(12, 35)},
"sgd_all": {"train": range(1, 128), "dev": range(1, 21), "test": range(1, 35)},
"sgd_all_single": {"train": range(1, 128), "dev": range(1, 8), "test": range(1, 12)},
"multiwoz": {"train": range(1, 18), "dev": range(1, 3), "test": range(1, 3)},
"debug_sample": {"train": range(1, 2), "dev": range(1, 2), "test": range(1, 2)},
}
class SGDDataProcessor(object):
"""Data generator for SGD dialogues."""
def __init__(
self,
task_name: str,
data_dir: str,
dialogues_example_dir: str,
tokenizer: object,
schemas: object,
schema_config: Dict[str, int],
subsample: bool = False,
):
"""
Constructs SGDDataProcessor
Args:
task_name: task name, e.g. "sgd_single_domain"
data_dir: path to data directory
dialogues_example_dir: path to store processed dialogue examples
tokenizer: tokenizer object
schemas: schema object
schema_config: schema configuration
subsample: whether to balance positive and negative samples in dataset
"""
self.data_dir = data_dir
self._task_name = task_name
self.schemas = schemas
self.schema_config = schema_config
train_file_range = FILE_RANGES[task_name]["train"]
dev_file_range = FILE_RANGES[task_name]["dev"]
test_file_range = FILE_RANGES[task_name]["test"]
self._file_ranges = {
"train": train_file_range,
"dev": dev_file_range,
"test": test_file_range,
}
self._seen_services = {
"train": set(),
"dev": set(),
"test": set(),
}
self._tokenizer = tokenizer
self._subsample = subsample
self._dialogues_example_dir = dialogues_example_dir
self.dial_files = {}
# slots_relation_list.np would contain the candidate list of slots for each (service, slot) which would be
# looked into when a switch between two services happens in the dialogue and we can not find any value for a slot in the current user utterance.
# This file would get generated from the dialogues in the training set.
self.slots_relation_file = os.path.join(dialogues_example_dir, f"{task_name}_train_slots_relation_list.np")
for dataset in ["train", "dev", "test"]:
# Process dialogue files
dial_file = f"{task_name}_{dataset}_examples.processed"
dial_file = os.path.join(dialogues_example_dir, dial_file)
self.dial_files[(task_name, dataset)] = dial_file
dialog_paths = SGDDataProcessor.get_dialogue_files(data_dir, dataset, task_name)
dialogs = SGDDataProcessor.load_dialogues(dialog_paths)
for dialog in dialogs:
self._seen_services[dataset].update(set(dialog['services']))
def save_dialog_examples(self, overwrite_dial_files: bool):
"""
Preprocesses dialogues and saves to disk.
Args:
overwrite_dial_files: whether or not to overwrite saved file if already exists
"""
for dataset in ["train", "dev", "test"]:
dial_file = self.dial_files[(self._task_name, dataset)]
if not os.path.exists(dial_file) or overwrite_dial_files:
logging.info(f"Start generating the dialogue examples for {dataset} dataset.")
if not os.path.exists(self._dialogues_example_dir):
os.makedirs(self._dialogues_example_dir)
dial_examples, slots_relation_list = self._generate_dialog_examples(
dataset, self.schemas, self._subsample
)
for ex in dial_examples:
del ex.service_schema
del ex._tokenizer
del ex.schema_config
del ex.user_utterance
del ex.categorical_slot_id
del ex.system_utterance
del ex.noncategorical_slot_id
del ex.categorical_slot_value_id
del ex.requested_slot_id
del ex.intent_id
with open(dial_file, "wb") as f:
np.save(f, dial_examples)
if dataset == "train":
with open(self.slots_relation_file, "wb") as f:
pickle.dump(slots_relation_list, f)
logging.info(f"The slot carry-over list for train set is stored at {self.slots_relation_file}")
logging.info(f"The dialogue examples for {dataset} dataset saved at {dial_file}")
logging.info(f"Finish generating the dialogue examples for {dataset} dataset.")
def get_dialog_examples(self, dataset_split: str) -> List[object]:
"""
Loads preprocessed dialogue examples from disk.
Args:
dataset_split: dataset split
Returns:
dial_examples: list of InputExample's.
"""
if (self._task_name, dataset_split) not in self.dial_files or not os.path.exists(
self.dial_files[(self._task_name, dataset_split)]
):
raise ValueError(
f"{dataset_split} dialogue examples were not processed for {self._task_name} task. Re-initialize SGDDataProcessor and add {dataset_split} dataset split to datasets arg."
)
dial_file = self.dial_files[(self._task_name, dataset_split)]
logging.info(f"Loading dialogue examples from {dial_file}.")
with open(dial_file, "rb") as f:
dial_examples = np.load(f, allow_pickle=True)
f.close()
if not os.path.exists(self.slots_relation_file):
raise ValueError(
f"Slots relation file {self.slots_relation_file} does not exist. It is needed for the carry-over mechanism of state tracker for switches between services."
)
if os.path.getsize(self.slots_relation_file) > 0:
with open(self.slots_relation_file, "rb") as f:
self.schemas._slots_relation_list = pickle.load(f)
logging.info(
f"Loaded the slot relation list for value carry-over between services from {self.slots_relation_file}."
)
return dial_examples
def get_seen_services(self, dataset_split: str):
"""
Returns list of seen services, i.e. both in given and training split
Args:
dataset_split: data split
Returns:
seen_services: list of seen services
"""
seen_services = self._seen_services[dataset_split]
return seen_services
def _generate_dialog_examples(self, dataset_split: str, schemas: object, subsample: bool):
"""
Returns a list of `InputExample`s of the data splits' dialogues.
Args:
dataset_split: data split, can be "train", "dev", or "test".
schemas: schema for all services of all datasets
subsample: whether to balance postive and negative samples in the dataset
Returns:
examples: a list of `InputExample`s.
"""
logging.info(f'Creating examples and slot relation list from the dialogues started...')
dialog_paths = [
os.path.join(self.data_dir, dataset_split, "dialogues_{:03d}.json".format(i))
for i in self._file_ranges[dataset_split]
]
dialogs = SGDDataProcessor.load_dialogues(dialog_paths)
examples = []
slot_carryover_candlist = collections.defaultdict(int)
for dialog_idx, dialog in enumerate(dialogs):
if dialog_idx % 1000 == 0:
logging.info(f'Processed {dialog_idx} dialogues.')
examples.extend(
self._create_examples_from_dialog(dialog, schemas, dataset_split, slot_carryover_candlist, subsample)
)
slots_relation_list = collections.defaultdict(list)
for slots_relation, relation_size in slot_carryover_candlist.items():
if relation_size > 0:
slots_relation_list[(slots_relation[0], slots_relation[1])].append(
(slots_relation[2], slots_relation[3], relation_size)
)
slots_relation_list[(slots_relation[2], slots_relation[3])].append(
(slots_relation[0], slots_relation[1], relation_size)
)
return examples, slots_relation_list
def _create_examples_from_dialog(
self, dialog: dict, schemas: object, dataset_split: str, slot_carryover_candlist: dict, subsample: bool
):
"""
Create examples for every turn in the dialogue.
Args:
dialog: dialogue example
schemas: schema for all services of all datasets
dataset_split: data split
slot_carryover_candlist: a dictionary to keep and count the number of carry-over cases between two slots from two different services
subsample: whether to balance postive and negative samples in the dataset
Returns:
examples: a list of `InputExample`s.
"""
dialog_id = dialog["dialogue_id"]
prev_states = {}
examples = []
for turn_idx, turn in enumerate(dialog["turns"]):
# Generate an example for every frame in every user turn.
if turn["speaker"] == "USER":
user_utterance = turn["utterance"]
user_frames = {f["service"]: f for f in turn["frames"]}
if turn_idx > 0:
system_turn = dialog["turns"][turn_idx - 1]
system_utterance = system_turn["utterance"]
system_frames = {f["service"]: f for f in system_turn["frames"]}
else:
system_utterance = ""
system_frames = {}
turn_id = "{}-{}-{:02d}".format(dataset_split, dialog_id, turn_idx)
turn_examples, prev_states, slot_carryover_values = self._create_examples_from_turn(
turn_id,
system_utterance,
user_utterance,
system_frames,
user_frames,
prev_states,
schemas,
subsample,
)
examples.extend(turn_examples)
for value, slots_list in slot_carryover_values.items():
if value in ["True", "False"]:
continue
if len(slots_list) > 1:
for service1, slot1 in slots_list:
for service2, slot2 in slots_list:
if service1 == service2:
continue
if service1 > service2:
service1, service2 = service2, service1
slot1, slot2 = slot2, slot1
slot_carryover_candlist[(service1, slot1, service2, slot2)] += 1
return examples
def _get_state_update(self, current_state: dict, prev_state: dict) -> dict:
"""
Updates dialogue state
Args:
current_state: slot values pairs for the current dialogue turn
prev_state: slot values pairs for the previous dialogue turns
Returns:
state_update: slot values pairs that are added/updated during the current dialogue turn
"""
state_update = dict(current_state)
for slot, values in current_state.items():
if slot in prev_state and prev_state[slot][0] in values:
# Remove the slot from state if its value didn't change.
state_update.pop(slot)
return state_update
def _create_examples_from_turn(
self,
turn_id: int,
system_utterance: str,
user_utterance: str,
system_frames: dict,
user_frames: dict,
prev_states: dict,
schemas: object,
subsample: bool,
):
"""
Creates an example for each frame in the user turn.
Args:
turn_id: turn number
system_utterance: last system utterance
user_utterance: lst user utterance
system_frames: all system utterances and slot - slot value pairs
user_frames: all user utterances and slot - slot value pairs
prev_states: slot - slot value pairs from the previous turns
schemas: schema for all services of all datasets
subsample: whether to balance postive and negative samples in the dataset
Returns:
examples: a list of `InputExample`s.
prev_states: updated dialogue state e.g. {'Restaurants_1': {'city': ['San Jose'], 'cuisine': ['American']}}
"""
user_tokens, user_alignments, user_inv_alignments = self._tokenize(user_utterance)
system_tokens, system_alignments, system_inv_alignments = self._tokenize(system_utterance)
system_user_utterance = system_utterance + ' ' + user_utterance
system_user_tokens, system_user_alignments, system_user_inv_alignments = self._tokenize(system_user_utterance)
states = {}
examples = []
slot_carryover_values = collections.defaultdict(list)
for service, user_frame in user_frames.items():
base_example = InputExample(schema_config=self.schema_config, tokenizer=self._tokenizer,)
base_example.service_schema = schemas.get_service_schema(service)
base_example.service_id = schemas.get_service_schema(service).service_id
system_frame = system_frames.get(service, None)
state = user_frame["state"]["slot_values"]
state_update = self._get_state_update(state, prev_states.get(service, {}))
states[service] = state
dataset_split, dialog_id, turn_id_ = turn_id.split('-')
dialog_id_1, dialog_id_2 = dialog_id.split('_')
base_example.example_id = f"{turn_id}-{service}"
base_example.example_id_num = [
int(dialog_id_1),
int(dialog_id_2),
int(turn_id_),
schemas.get_service_id(service),
]
for model_task in range(self.schema_config["NUM_TASKS"]):
if model_task == 0:
for intent_id, intent in enumerate(schemas.get_service_schema(service).intents):
task_example = base_example.make_copy()
task_example.task_mask[model_task] = 1
task_example.intent_id = intent_id
task_example.example_id += f"-{model_task}-{intent_id}-0"
task_example.example_id_num.extend([model_task, intent_id, 0])
intent_description = (
intent + " " + schemas.get_service_schema(service).intent_descriptions[intent]
)
intent_tokens, intent_alignments, intent_inv_alignments = self._tokenize(intent_description)
task_example.add_utterance_features(
intent_tokens,
intent_inv_alignments,
system_user_tokens,
system_user_inv_alignments,
intent_description,
system_user_utterance,
)
task_example.add_intents(user_frame)
examples.append(task_example)
if model_task == 1:
for slot_id, slot in enumerate(schemas.get_service_schema(service).slots):
task_example = base_example.make_copy()
task_example.task_mask[model_task] = 1
task_example.requested_slot_id = slot_id
task_example.example_id += f"-{model_task}-{slot_id}-0"
task_example.example_id_num.extend([model_task, slot_id, 0])
slot_description = slot + " " + schemas.get_service_schema(service).slot_descriptions[slot]
slot_tokens, slot_alignments, slot_inv_alignments = self._tokenize(slot_description)
task_example.add_utterance_features(
slot_tokens,
slot_inv_alignments,
user_tokens,
user_inv_alignments,
slot_description,
user_utterance,
)
task_example.add_requested_slots(user_frame)
examples.append(task_example)
if model_task == 2:
off_slots = []
on_slots = []
for slot_id, slot in enumerate(schemas.get_service_schema(service).categorical_slots):
task_example = base_example.make_copy()
task_example.task_mask[model_task] = 1
# assert task_example.task_mask == [0, 0, 1, 0, 0, 0]
task_example.categorical_slot_id = slot_id
task_example.example_id += f"-{model_task}-{slot_id}-0"
task_example.example_id_num.extend([model_task, slot_id, 0])
slot_description = slot + " " + schemas.get_service_schema(service).slot_descriptions[slot]
slot_tokens, slot_alignments, slot_inv_alignments = self._tokenize(slot_description)
task_example.add_utterance_features(
slot_tokens,
slot_inv_alignments,
system_user_tokens,
system_user_inv_alignments,
slot_description,
system_user_utterance,
)
task_example.add_categorical_slots(state_update)
if task_example.categorical_slot_status == 0:
off_slots.append(task_example)
else:
on_slots.append(task_example)
examples.append(task_example)
old_example = task_example
for value_id, value in enumerate(
schemas.get_service_schema(service).get_categorical_slot_values(slot)
):
if dataset_split != 'train' or task_example.categorical_slot_status == 1:
task_example = old_example.make_copy_of_categorical_features()
task_example.task_mask[3] = 1
# assert task_example.task_mask == [0, 0, 0, 1, 0, 0]
task_example.categorical_slot_id = slot_id
task_example.categorical_slot_value_id = value_id
task_example.example_id = base_example.example_id + f"-3-{slot_id}-{value_id}"
task_example.example_id_num = base_example.example_id_num + [3, slot_id, value_id]
slot_description = slot + " " + value # add slot description
slot_tokens, slot_alignments, slot_inv_alignments = self._tokenize(slot_description)
task_example.add_utterance_features(
slot_tokens,
slot_inv_alignments,
system_user_tokens,
system_user_inv_alignments,
slot_description,
system_user_utterance,
)
task_example.add_categorical_slots(state_update)
assert task_example.categorical_slot_status == old_example.categorical_slot_status
examples.append(task_example)
if dataset_split == 'train' and subsample:
num_on_slots = len(on_slots)
examples.extend(
np.random.choice(off_slots, replace=False, size=min(max(num_on_slots, 1), len(off_slots)))
)
else:
examples.extend(off_slots)
if model_task == 4: # noncat slot status
off_slots = []
on_slots = []
for slot_id, slot in enumerate(schemas.get_service_schema(service).non_categorical_slots):
task_example = base_example.make_copy()
task_example.task_mask[model_task] = 1
# assert task_example.task_mask == [0, 0, 0, 0, 1, 0]
task_example.noncategorical_slot_id = slot_id
task_example.example_id += f"-{model_task}-{slot_id}-0"
task_example.example_id_num.extend([model_task, slot_id, 0])
slot_description = slot + " " + schemas.get_service_schema(service).slot_descriptions[slot]
slot_tokens, slot_alignments, slot_inv_alignments = self._tokenize(slot_description)
task_example.add_utterance_features(
slot_tokens,
slot_inv_alignments,
system_user_tokens,
system_user_inv_alignments,
slot_description,
system_user_utterance,
)
user_span_boundaries = self._find_subword_indices(
state_update,
user_utterance,
user_frame["slots"],
user_alignments,
user_tokens,
2 + len(slot_tokens) + len(system_tokens),
)
if system_frame is not None:
system_span_boundaries = self._find_subword_indices(
state_update,
system_utterance,
system_frame["slots"],
system_alignments,
system_tokens,
2 + len(slot_tokens),
)
else:
system_span_boundaries = {}
task_example.add_noncategorical_slots(
state_update, user_span_boundaries, system_span_boundaries
)
if task_example.noncategorical_slot_status == 0:
off_slots.append(task_example)
else:
on_slots.append(task_example)
examples.append(task_example)
if dataset_split != 'train' or task_example.noncategorical_slot_status == 1:
task_example = task_example.make_copy_of_non_categorical_features()
task_example.task_mask[5] = 1
# assert task_example.task_mask == [0, 0, 0, 0, 0, 1]
task_example.example_id = base_example.example_id + f"-5-{slot_id}-0"
task_example.example_id_num = base_example.example_id_num + [5, slot_id, 0]
examples.append(task_example)
if dataset_split == 'train' and subsample:
num_on_slots = len(on_slots)
examples.extend(
np.random.choice(off_slots, replace=False, size=min(max(num_on_slots, 1), len(off_slots)))
)
else:
examples.extend(off_slots)
if service not in prev_states and int(turn_id_) > 0:
for slot_name, values in state_update.items():
for value in values:
slot_carryover_values[value].append((service, slot_name))
for prev_service, prev_slot_value_list in prev_states.items():
if prev_service == service:
continue
if prev_service in state:
prev_slot_value_list = state[prev_service]
for prev_slot_name, prev_values in prev_slot_value_list.items():
for prev_value in prev_values:
slot_carryover_values[prev_value].append((prev_service, prev_slot_name))
return examples, states, slot_carryover_values
def _find_subword_indices(
self,
slot_values: dict,
utterance: str,
char_slot_spans: dict,
alignments: List[int],
subwords: List[str],
bias: int,
) -> dict:
"""
Find indices for subwords corresponding to slot values.
Args:
slot_values: slot - slot value pairs
utterance: utterance
char_slot_spans: char - slot spans
alignments: alignments
subwords: subtokens mapping
bias: offset
Returns:
span_boundaries: span boundaries
"""
span_boundaries = {}
for slot, values in slot_values.items():
# Get all values present in the utterance for the specified slot.
value_char_spans = {}
for slot_span in char_slot_spans:
if slot_span["slot"] == slot:
value = utterance[slot_span["start"] : slot_span["exclusive_end"]]
start_tok_idx = alignments[slot_span["start"]]
end_tok_idx = alignments[slot_span["exclusive_end"] - 1]
if 0 <= start_tok_idx < len(subwords):
end_tok_idx = min(end_tok_idx, len(subwords) - 1)
value_char_spans[value] = (start_tok_idx + bias, end_tok_idx + bias)
for v in values:
if v in value_char_spans:
span_boundaries[slot] = value_char_spans[v]
break
return span_boundaries
def _tokenize(self, utterance: str):
"""
Tokenize the utterance
Args:
utterance: A string containing the utterance to be tokenized.
Returns:
bert_tokens: A list of tokens obtained by word-piece tokenization of the
utterance.
alignments: A dict mapping indices of characters corresponding to start
and end positions of words (not subwords) to corresponding indices in
bert_tokens list.
inverse_alignments: A list of size equal to bert_tokens. Each element is a
tuple containing the index of the starting and inclusive ending
character of the word corresponding to the subword. This list is used
during inference to map word-piece indices to spans in the original
utterance.
"""
# utterance = tokenization.convert_to_unicode(utterance)
# After _naive_tokenize, spaces and punctuation marks are all retained, i.e.
# direct concatenation of all the tokens in the sequence will be the
# original string.
tokens = SGDDataProcessor._naive_tokenize(utterance)
# ['I', ' ', 'am', ' ', 'feeling', ' ', 'hungry', ' ', 'so', ' ', 'I', ' ', 'would', ' ', 'like', ' ', 'to', ' ', 'find', ' ', 'a', ' ', 'place', ' ', 'to', ' ', 'eat', '.']
# Filter out empty tokens and obtain aligned character index for each token.
alignments = {}
char_index = 0
bert_tokens = (
[]
) # ['I', 'am', 'feeling', 'hungry', 'so', 'I', 'would', 'like', 'to', 'find', 'a', 'place', 'to', 'eat', '.']
# These lists store inverse alignments to be used during inference.
bert_tokens_start_chars = []
bert_tokens_end_chars = []
for token in tokens:
if token.strip():
subwords = self._tokenizer.text_to_tokens(token)
# Store the alignment for the index of starting character and the
# inclusive ending character of the token.
alignments[char_index] = len(bert_tokens)
bert_tokens_start_chars.extend([char_index] * len(subwords))
bert_tokens.extend(subwords)
# The inclusive ending character index corresponding to the word.
inclusive_char_end = char_index + len(token) - 1
alignments[inclusive_char_end] = len(bert_tokens) - 1
bert_tokens_end_chars.extend([inclusive_char_end] * len(subwords))
char_index += len(token)
inverse_alignments = list(zip(bert_tokens_start_chars, bert_tokens_end_chars))
return bert_tokens, alignments, inverse_alignments
@classmethod
def _naive_tokenize(cls, s: str):
"""
Tokenizes a string, separating words, spaces and punctuations.
Args:
s: a string
Returns:
seq_tok: list of words, spaces and punctuations from the string
"""
# Spaces and punctuation marks are all retained, i.e. direct concatenation
# of all the tokens in the sequence will be the original string.
seq_tok = [tok for tok in re.split(r"([^a-zA-Z0-9])", s) if tok]
return seq_tok
@classmethod
def load_dialogues(cls, dialog_json_filepaths: List[str]) -> List[dict]:
"""
Obtain the list of all dialogues from specified json files.
Args:
dialog_json_filepaths: list of json files
Returns:
dialogs: the list of all dialogues
"""
dialogs = []
for dialog_json_filepath in sorted(dialog_json_filepaths):
with open(dialog_json_filepath, 'r') as f:
dialogs.extend(json.load(f))
f.close()
return dialogs
@classmethod
def get_dialogue_files(cls, data_dir: str, dataset_split: str, task_name: str):
"""
Obtain the list of all dialogue json files
Args:
data_dir: path to the data folder
dataset_split: data split
task_name: SGD task name, see keys of the FILE_RANGES
Returns:
dialog: the list of all dialogue json files paths
"""
return [
os.path.join(data_dir, dataset_split, 'dialogues_{:03d}.json'.format(fid))
for fid in FILE_RANGES[task_name][dataset_split]
]
@@ -0,0 +1,85 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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 file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst
"""
from typing import Dict, Optional
import numpy as np
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, LabelsType, NeuralType
__all__ = ['SGDDataset']
class SGDDataset(Dataset):
"""Processes SGD dataset"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports."""
return {
"example_id_num": NeuralType(('B', 'T'), ChannelType()),
"service_id": NeuralType(('B'), ChannelType()),
"utterance_ids": NeuralType(('B', 'T'), ChannelType()),
"token_type_ids": NeuralType(('B', 'T'), ChannelType()), # utterance segment
"attention_mask": NeuralType(('B', 'T'), ChannelType()), # utterance mask
"intent_status": NeuralType(('B'), LabelsType()),
"requested_slot_status": NeuralType(('B'), LabelsType()),
"categorical_slot_status": NeuralType(('B'), LabelsType()),
"categorical_slot_value_status": NeuralType(('B'), LabelsType()),
"noncategorical_slot_status": NeuralType(('B'), LabelsType()),
"noncategorical_slot_value_start": NeuralType(('B'), LabelsType()),
"noncategorical_slot_value_end": NeuralType(('B'), LabelsType()),
"start_char_idx": NeuralType(('B', 'T'), LabelsType()),
"end_char_idx": NeuralType(('B', 'T'), LabelsType()),
"task_mask": NeuralType(('B', 'T'), ChannelType()),
}
def __init__(self, dataset_split: str, dialogues_processor: object):
""" Constructor
Args:
dataset_split: dataset split
dialogues_processor: Data generator for SGD dialogues
"""
self.features = dialogues_processor.get_dialog_examples(dataset_split)
def __len__(self):
return len(self.features)
def __getitem__(self, idx: int):
ex = self.features[idx]
service_id = ex.service_id
return (
np.array(ex.example_id_num),
np.array(service_id),
np.array(ex.utterance_ids),
np.array(ex.utterance_segment),
np.array(ex.utterance_mask, dtype=np.long),
np.array(ex.intent_status, dtype=np.float32),
np.array(ex.requested_slot_status, dtype=np.float32),
np.array(ex.categorical_slot_status),
np.array(ex.categorical_slot_value_status, dtype=np.float32),
np.array(ex.noncategorical_slot_status),
np.array(ex.noncategorical_slot_value_start),
np.array(ex.noncategorical_slot_value_end),
np.array(ex.start_char_idx), # noncat_alignment_start
np.array(ex.end_char_idx), # noncat_alignment_end
np.array(ex.task_mask), # noncat_alignment_end
)
@@ -0,0 +1,294 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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.
"""
Evaluate predictions JSON file, w.r.t. ground truth file.
This file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/evaluate.py
"""
import collections
import glob
import json
import os
import numpy as np
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.metrics import (
ACTIVE_INTENT_ACCURACY,
JOINT_CAT_ACCURACY,
JOINT_GOAL_ACCURACY,
JOINT_NONCAT_ACCURACY,
NAN_VAL,
REQUESTED_SLOTS_F1,
REQUESTED_SLOTS_PRECISION,
REQUESTED_SLOTS_RECALL,
SLOT_TAGGING_F1,
SLOT_TAGGING_PRECISION,
SLOT_TAGGING_RECALL,
get_active_intent_accuracy,
get_average_and_joint_goal_accuracy,
get_requested_slots_f1,
get_slot_tagging_f1,
)
from nemo.utils import logging
__all__ = ['get_in_domain_services']
ALL_SERVICES = "#ALL_SERVICES"
SEEN_SERVICES = "#SEEN_SERVICES"
UNSEEN_SERVICES = "#UNSEEN_SERVICES"
# Name of the file containing all predictions and their corresponding frame metrics.
PER_FRAME_OUTPUT_FILENAME = "dialogues_and_metrics.json"
def get_service_set(schema_path: str) -> set:
"""
Get the set of all services present in a schema.
Args:
schema_path: schema file path
Returns:
service_set: set of services in file
"""
service_set = set()
with open(schema_path) as f:
schema = json.load(f)
for service in schema:
service_set.add(service["service_name"])
f.close()
return service_set
def get_in_domain_services(schema_path: str, service_set: set) -> set:
"""Get the set of common services between a schema and set of services.
Args:
schema_path: path to schema file
service_set: set of services
Returns:
joint_services: joint services between schema path file and service set
"""
joint_services = get_service_set(schema_path) & service_set
return joint_services
def get_dataset_as_dict(file_path_patterns) -> dict:
"""Read the DSTC8/SGD json dialogue data as dictionary with dialog ID as keys.
Args:
file_path_patterns: list or directory of files
Returns:
dataset_dict: dataset dictionary with dialog ID as keys
"""
dataset_dict = {}
if isinstance(file_path_patterns, list):
list_fp = file_path_patterns
else:
list_fp = sorted(glob.glob(file_path_patterns))
for fp in list_fp:
if PER_FRAME_OUTPUT_FILENAME in fp:
continue
logging.debug("Loading file: %s", fp)
with open(fp) as f:
data = json.load(f)
if isinstance(data, list):
for dial in data:
dataset_dict[dial["dialogue_id"]] = dial
elif isinstance(data, dict):
dataset_dict.update(data)
f.close()
return dataset_dict
def get_metrics(
dataset_ref: dict,
dataset_hyp: dict,
service_schemas: dict,
in_domain_services: set,
joint_acc_across_turn: bool,
use_fuzzy_match: bool,
):
"""Calculate the DSTC8/SGD metrics.
Args:
dataset_ref: The ground truth dataset represented as a dict mapping dialogue id to the corresponding dialogue.
dataset_hyp: The predictions in the same format as `dataset_ref`.
service_schemas: A dict mapping service name to the schema for the service.
in_domain_services: The set of services which are present in the training set.
joint_acc_across_turn: Whether to compute joint accuracy across turn instead of across service. Should be set to True when conducting multiwoz style evaluation.
use_fuzzy_match: Whether to use fuzzy string matching when comparing non-categorical slot values. Should be set to False when conducting multiwoz style evaluation.
Returns:
all_metric_aggregate: A dict mapping a metric collection name to a dict containing the values
for various metrics. Each metric collection aggregates the metrics across a specific set of frames in the dialogues.
per_frame_metric: metrics aggregated for each frame
"""
# Metrics can be aggregated in various ways, eg over all dialogues, only for
# dialogues containing unseen services or for dialogues corresponding to a
# single service. This aggregation is done through metric_collections, which
# is a dict mapping a collection name to a dict, which maps a metric to a list
# of values for that metric. Each value in this list is the value taken by
# the metric on a frame.
metric_collections = collections.defaultdict(lambda: collections.defaultdict(list))
# Ensure the dialogs in dataset_hyp also occur in dataset_ref.
assert set(dataset_hyp.keys()).issubset(set(dataset_ref.keys()))
logging.debug("len(dataset_hyp)=%d, len(dataset_ref)=%d", len(dataset_hyp), len(dataset_ref))
# Store metrics for every frame for debugging.
per_frame_metric = {}
for dial_id, dial_hyp in dataset_hyp.items():
dial_ref = dataset_ref[dial_id]
if set(dial_ref["services"]) != set(dial_hyp["services"]):
raise ValueError(
"Set of services present in ground truth and predictions don't match "
"for dialogue with id {}".format(dial_id)
)
joint_metrics = [JOINT_GOAL_ACCURACY, JOINT_CAT_ACCURACY, JOINT_NONCAT_ACCURACY]
for turn_id, (turn_ref, turn_hyp) in enumerate(zip(dial_ref["turns"], dial_hyp["turns"])):
metric_collections_per_turn = collections.defaultdict(lambda: collections.defaultdict(lambda: 1.0))
if turn_ref["speaker"] != turn_hyp["speaker"]:
raise ValueError("Speakers don't match in dialogue with id {}".format(dial_id))
# Skip system turns because metrics are only computed for user turns.
if turn_ref["speaker"] != "USER":
continue
if turn_ref["utterance"] != turn_hyp["utterance"]:
logging.error("Ref utt: %s", turn_ref["utterance"])
logging.error("Hyp utt: %s", turn_hyp["utterance"])
raise ValueError("Utterances don't match for dialogue with id {}".format(dial_id))
hyp_frames_by_service = {frame["service"]: frame for frame in turn_hyp["frames"]}
# Calculate metrics for each frame in each user turn.
for frame_ref in turn_ref["frames"]:
service_name = frame_ref["service"]
if service_name not in hyp_frames_by_service:
raise ValueError(
"Frame for service {} not found in dialogue with id {}".format(service_name, dial_id)
)
service = service_schemas[service_name]
frame_hyp = hyp_frames_by_service[service_name]
active_intent_acc = get_active_intent_accuracy(frame_ref, frame_hyp)
slot_tagging_f1_scores = get_slot_tagging_f1(frame_ref, frame_hyp, turn_ref["utterance"], service)
requested_slots_f1_scores = get_requested_slots_f1(frame_ref, frame_hyp)
goal_accuracy_dict = get_average_and_joint_goal_accuracy(
frame_ref, frame_hyp, service, use_fuzzy_match
)
frame_metric = {
ACTIVE_INTENT_ACCURACY: active_intent_acc,
REQUESTED_SLOTS_F1: requested_slots_f1_scores.f1,
REQUESTED_SLOTS_PRECISION: requested_slots_f1_scores.precision,
REQUESTED_SLOTS_RECALL: requested_slots_f1_scores.recall,
}
if slot_tagging_f1_scores is not None:
frame_metric[SLOT_TAGGING_F1] = slot_tagging_f1_scores.f1
frame_metric[SLOT_TAGGING_PRECISION] = slot_tagging_f1_scores.precision
frame_metric[SLOT_TAGGING_RECALL] = slot_tagging_f1_scores.recall
frame_metric.update(goal_accuracy_dict)
frame_id = "{:s}-{:03d}-{:s}".format(dial_id, turn_id, frame_hyp["service"])
per_frame_metric[frame_id] = frame_metric
# Add the frame-level metric result back to dialogues.
frame_hyp["metrics"] = frame_metric
# Get the domain name of the service.
domain_name = frame_hyp["service"].split("_")[0]
domain_keys = [ALL_SERVICES, frame_hyp["service"], domain_name]
if frame_hyp["service"] in in_domain_services:
domain_keys.append(SEEN_SERVICES)
else:
domain_keys.append(UNSEEN_SERVICES)
for domain_key in domain_keys:
for metric_key, metric_value in frame_metric.items():
if metric_value != NAN_VAL:
if joint_acc_across_turn and metric_key in joint_metrics:
metric_collections_per_turn[domain_key][metric_key] *= metric_value
else:
metric_collections[domain_key][metric_key].append(metric_value)
if joint_acc_across_turn:
# Conduct multiwoz style evaluation that computes joint goal accuracy
# across all the slot values of all the domains for each turn.
for domain_key in metric_collections_per_turn:
for metric_key, metric_value in metric_collections_per_turn[domain_key].items():
metric_collections[domain_key][metric_key].append(metric_value)
all_metric_aggregate = {}
for domain_key, domain_metric_vals in metric_collections.items():
domain_metric_aggregate = {}
for metric_key, value_list in domain_metric_vals.items():
if value_list:
# Metrics are macro-averaged across all frames.
domain_metric_aggregate[metric_key] = round(float(np.mean(value_list)) * 100.0, 2)
else:
domain_metric_aggregate[metric_key] = NAN_VAL
all_metric_aggregate[domain_key] = domain_metric_aggregate
return all_metric_aggregate, per_frame_metric
def evaluate(
prediction_dir: str,
data_dir: str,
eval_dataset: str,
in_domain_services: set,
joint_acc_across_turn: bool,
use_fuzzy_match: bool,
) -> dict:
"""Calculate the DSTC8/SGD metrics for given data.
Args:
prediction_dir: prediction location
data_dir: ground truth data location.
eval_dataset: evaluation data split
in_domain_services: The set of services which are present in the training set.
joint_acc_across_turn: Whether to compute joint goal accuracy across turn instead of across service. Should be set to True when conducting multiwoz style evaluation.
use_fuzzy_match: Whether to use fuzzy string matching when comparing non-categorical slot values. Should be set to False when conducting multiwoz style evaluation.
Returns:
A dict mapping a metric collection name to a dict containing the values
for various metrics for all dialogues and all services
"""
with open(os.path.join(data_dir, eval_dataset, "schema.json")) as f:
eval_services = {}
list_services = json.load(f)
for service in list_services:
eval_services[service["service_name"]] = service
f.close()
dataset_ref = get_dataset_as_dict(os.path.join(data_dir, eval_dataset, "dialogues_*.json"))
dataset_hyp = get_dataset_as_dict(os.path.join(prediction_dir, "*.json"))
# has ALLSERVICE, SEEN_SERVICES, UNSEEN_SERVICES, SERVICE, DOMAIN
all_metric_aggregate, _ = get_metrics(
dataset_ref, dataset_hyp, eval_services, in_domain_services, joint_acc_across_turn, use_fuzzy_match
)
if SEEN_SERVICES in all_metric_aggregate:
logging.info(f'Dialog metrics for {SEEN_SERVICES} : {sorted(all_metric_aggregate[SEEN_SERVICES].items())}')
if UNSEEN_SERVICES in all_metric_aggregate:
logging.info(f'Dialog metrics for {UNSEEN_SERVICES}: {sorted(all_metric_aggregate[UNSEEN_SERVICES].items())}')
if ALL_SERVICES in all_metric_aggregate:
logging.info(f'Dialog metrics for {ALL_SERVICES} : {sorted(all_metric_aggregate[ALL_SERVICES].items())}')
# Write the per-frame metrics values with the corrresponding dialogue frames.
with open(os.path.join(prediction_dir, PER_FRAME_OUTPUT_FILENAME), "w") as f:
json.dump(dataset_hyp, f, indent=2, separators=(",", ": "))
f.close()
return all_metric_aggregate[ALL_SERVICES]
@@ -0,0 +1,399 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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 file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/baseline/data_utils.py
"""
from typing import List
from nemo.utils import logging
__all__ = ['InputExample', 'STR_DONTCARE', 'STATUS_OFF', 'STATUS_ACTIVE', 'STATUS_DONTCARE']
STR_DONTCARE = "dontcare"
# These are used to represent the status of slots (off, active, dontcare) and
# intents (off, active) in dialogue state tracking.
STATUS_OFF = 0
STATUS_ACTIVE = 1
STATUS_DONTCARE = 2
class InputExample(object):
"""An example for training/inference."""
def __init__(
self,
schema_config: dict,
tokenizer: object,
service_schema: object = None,
example_id: str = "NONE",
example_id_num: List[int] = [],
):
"""
Constructs an InputExample.
Args:
schema_config: configuration
tokenizer: tokenizer object
service_schema: A ServiceSchema object wrapping the schema for the service
corresponding to this example.
example_id: Unique identifier for the example, like: 'train-1_00000-00-Restaurants_1'
example_id_num: dialogue_id and turn_id combined and service id combined into a list of ints,
like: [1, 0, 0, 18]
"""
self.schema_config = schema_config
self.service_schema = service_schema
self.service_id = None
if service_schema:
self.service_id = service_schema.service_id
self.example_id = example_id
self.example_id_num = example_id_num
self._max_seq_length = schema_config["MAX_SEQ_LENGTH"]
self._tokenizer = tokenizer
if self._tokenizer is None:
raise ValueError("Must specify tokenizer")
self.user_utterance = ''
self.system_utterance = ''
# The id of each subword in the vocabulary for BERT.
self.utterance_ids = [0] * self._max_seq_length
# Denotes the identity of the sequence. Takes values 0 (schema description) and 1 (system and user utterance).
self.utterance_segment = [0] * self._max_seq_length
# Mask which takes the value 0 for padded tokens and 1 otherwise.
self.utterance_mask = [0] * self._max_seq_length
# Start and inclusive end character indices in the original utterance
# corresponding to the tokens. This is used to obtain the character indices
# from the predicted subword indices during inference.
# NOTE: A positive value indicates the character indices in the schema description
# whereas a negative value indicates the character indices in the
# utterance. The indices are offset by 1 to prevent ambiguity in the
# 0 index, which could be in either the schema description or utterance by the
# above convention. Now the 0 index corresponds to padded tokens.
self.start_char_idx = [0] * self._max_seq_length
self.end_char_idx = [0] * self._max_seq_length
# Id of categorical slot present in the example or 0 if not present.
self.categorical_slot_id = 0
# Id of non categorical slot present in the example or 0 if not present.
self.noncategorical_slot_id = 0
# The status of categorical slot in the example.
self.categorical_slot_status = STATUS_OFF
# The status of non categorical slot in the example.
self.noncategorical_slot_status = STATUS_OFF
# Masks out tasks not represented by example
self.task_mask = [0] * schema_config["NUM_TASKS"]
# The index of the starting subword corresponding to the slot span
# for a non-categorical slot value.
self.noncategorical_slot_value_start = 0
# The index of the ending (inclusive) subword corresponding to the slot span
# for a non-categorical slot value.
self.noncategorical_slot_value_end = 0
# Id of categorical slot value present in the example or 0 if not present.
self.categorical_slot_value_id = 0
# The status of categorical slot value in the example.
self.categorical_slot_value_status = STATUS_OFF
# Id of requested slot present in the example or 0 if not present.
self.requested_slot_id = 0
# Takes value 1 if the corresponding slot is requested, 0 otherwise.
self.requested_slot_status = STATUS_OFF
# ID of intent present in the example.
self.intent_id = 0
# Takes value 1 if the intent is active, 0 otherwise.
self.intent_status = STATUS_OFF
@property
def readable_summary(self):
"""Get a readable dict that summarizes the attributes of an InputExample."""
seq_length = sum(self.utterance_mask)
utt_toks = self._tokenizer.ids_to_tokens(self.utterance_ids[:seq_length])
utt_tok_mask_pairs = list(zip(utt_toks, self.utterance_segment[:seq_length]))
active_intent = (
self.service_schema.get_intent_from_id(self.intent_id) if self.intent_status == STATUS_ACTIVE else ""
)
slot_values_in_state = {}
if self.categorical_slot_status == STATUS_ACTIVE:
slot_values_in_state[
self.service_schema.get_categorical_slot_from_id(self.categorical_slot_id)
] = self.service_schema.get_categorical_slot_value_from_id(
self.categorical_slot_id, self.categorical_slot_value_id
)
elif self.categorical_slot_status == STATUS_DONTCARE:
slot_values_in_state[
self.service_schema.get_categorical_slot_from_id(self.categorical_slot_id)
] = STR_DONTCARE
if self.noncategorical_slot_status == STATUS_ACTIVE:
slot = self.service_schema.get_non_categorical_slot_from_id(self.noncategorical_slot_id)
start_id = self.noncategorical_slot_value_start[idx]
end_id = self.noncategorical_slot_value_end[idx]
# Token list is consisted of the subwords that may start with "##". We
# remove "##" to reconstruct the original value. Note that it's not a
# strict restoration of the original string. It's primarily used for
# debugging.
# ex. ["san", "j", "##ose"] --> "san jose"
readable_value = " ".join(utt_toks[start_id : end_id + 1]).replace(" ##", "")
slot_values_in_state[slot] = readable_value
elif self.noncategorical_slot_status == STATUS_DONTCARE:
slot = self.service_schema.get_non_categorical_slot_from_id(self.noncategorical_slot_id)
slot_values_in_state[slot] = STR_DONTCARE
summary_dict = {
"utt_tok_mask_pairs": utt_tok_mask_pairs,
"utt_len": seq_length,
"categorical_slot_id": self.categorical_slot_id,
"noncategorical_slot_id": self.noncategorical_slot_id,
"intent_id": self.intent_id,
"service_name": self.service_schema.service_name,
"active_intent": active_intent,
"slot_values_in_state": slot_values_in_state,
}
return summary_dict
def add_utterance_features(
self, system_tokens, system_inv_alignments, user_tokens, user_inv_alignments, system_utterance, user_utterance
):
"""Add utterance related features input to InputExample.
Note: this method modifies the system tokens and user_tokens in place to
make their total length <= the maximum input length for BERT model.
Args:
system_tokens: a list of strings which represents schema description.
system_inv_alignments: a list of tuples which denotes the start and end
charater of the tpken that a bert token originates from in the original
schema description.
user_tokens: a list of strings which represents utterance.
user_inv_alignments: a list of tuples which denotes the start and end
charater of the token that a bert token originates from in the original
system and user utterance.
"""
# Input sequence length for utterance BERT encoder
max_utt_len = self._max_seq_length
# Modify lengths of schema description & utterance so that length of total utt
# (including cls_token, setp_token, sep_token) is no more than max_utt_len
is_too_long = truncate_seq_pair(system_tokens, user_tokens, max_utt_len - 3)
if is_too_long:
logging.debug(
f'Utterance sequence truncated in example id - {self.example_id} from {len(system_tokens) + len(user_tokens)}.'
)
# Construct the tokens, segment mask and valid token mask which will be
# input to BERT, using the tokens for schema description (sequence A) and
# system and user utterance (sequence B).
utt_subword = []
utt_seg = []
utt_mask = []
start_char_idx = []
end_char_idx = []
utt_subword.append(self._tokenizer.cls_token)
utt_seg.append(0)
utt_mask.append(1)
start_char_idx.append(0)
end_char_idx.append(0)
for subword_idx, subword in enumerate(system_tokens):
utt_subword.append(subword)
utt_seg.append(0)
utt_mask.append(1)
st, en = system_inv_alignments[subword_idx]
start_char_idx.append(-(st + 1))
end_char_idx.append(-(en + 1))
utt_subword.append(self._tokenizer.sep_token)
utt_seg.append(0)
utt_mask.append(1)
start_char_idx.append(0)
end_char_idx.append(0)
for subword_idx, subword in enumerate(user_tokens):
utt_subword.append(subword)
utt_seg.append(1)
utt_mask.append(1)
st, en = user_inv_alignments[subword_idx]
start_char_idx.append(st + 1)
end_char_idx.append(en + 1)
utt_subword.append(self._tokenizer.sep_token)
utt_seg.append(1)
utt_mask.append(1)
start_char_idx.append(0)
end_char_idx.append(0)
utterance_ids = self._tokenizer.tokens_to_ids(utt_subword)
# Zero-pad up to the BERT input sequence length.
while len(utterance_ids) < max_utt_len:
utterance_ids.append(0)
utt_seg.append(0)
utt_mask.append(0)
start_char_idx.append(0)
end_char_idx.append(0)
self.utterance_ids = utterance_ids
self.utterance_segment = utt_seg
self.utterance_mask = utt_mask
self.start_char_idx = start_char_idx
self.end_char_idx = end_char_idx
self.user_utterance = user_utterance
self.system_utterance = system_utterance
def make_copy(self):
"""Make a copy of the current example with utterance features."""
new_example = InputExample(
schema_config=self.schema_config,
service_schema=self.service_schema,
example_id=self.example_id,
example_id_num=self.example_id_num.copy(),
tokenizer=self._tokenizer,
)
return new_example
def make_copy_of_categorical_features(self):
"""Make a copy of the current example with utterance and categorical features."""
new_example = self.make_copy()
new_example.categorical_slot_status = self.categorical_slot_status
return new_example
def make_copy_of_non_categorical_features(self):
"""Make a copy of the current example with utterance features and non categorical features."""
new_example = self.make_copy()
new_example.noncategorical_slot_id = self.noncategorical_slot_id
new_example.noncategorical_slot_status = self.noncategorical_slot_status
new_example.utterance_ids = list(self.utterance_ids)
new_example.utterance_segment = list(self.utterance_segment)
new_example.utterance_mask = list(self.utterance_mask)
new_example.start_char_idx = list(self.start_char_idx)
new_example.end_char_idx = list(self.end_char_idx)
new_example.user_utterance = self.user_utterance
new_example.system_utterance = self.system_utterance
new_example.noncategorical_slot_status = self.noncategorical_slot_status
new_example.noncategorical_slot_value_start = self.noncategorical_slot_value_start
new_example.noncategorical_slot_value_end = self.noncategorical_slot_value_end
return new_example
def add_categorical_slots(self, state_update: dict):
"""Add features for categorical slots.
Args:
state_update: slot value pairs of the state update
"""
categorical_slots = self.service_schema.categorical_slots
if not categorical_slots:
return
slot = categorical_slots[self.categorical_slot_id]
values = state_update.get(slot, [])
if not values:
self.categorical_slot_status = STATUS_OFF
elif values[0] == STR_DONTCARE:
self.categorical_slot_status = STATUS_DONTCARE
else:
self.categorical_slot_status = STATUS_ACTIVE
self.categorical_slot_value_status = (
self.categorical_slot_value_id == self.service_schema.get_categorical_slot_value_id(slot, values[0])
)
def add_noncategorical_slots(self, state_update: dict, system_span_boundaries: dict, user_span_boundaries: dict):
"""Add features for non-categorical slots.
Args:
state_update: slot value pairs of state update
system_span_boundaries: span boundaries of schema description
user_span_boundaries: span boundaries of utterance
"""
noncategorical_slots = self.service_schema.non_categorical_slots
slot = noncategorical_slots[self.noncategorical_slot_id]
values = state_update.get(slot, [])
if not values:
self.noncategorical_slot_status = STATUS_OFF
elif values[0] == STR_DONTCARE:
self.noncategorical_slot_status = STATUS_DONTCARE
else:
self.noncategorical_slot_status = STATUS_ACTIVE
# Add indices of the start and end tokens for the first encountered
# value. Spans in user utterance are prioritized over the system
# utterance. If a span is not found, the slot value is ignored.
if slot in user_span_boundaries:
start, end = user_span_boundaries[slot]
elif slot in system_span_boundaries:
start, end = system_span_boundaries[slot]
else:
# A span may not be found because the value was cropped out or because
# the value was mentioned earlier in the dialogue. Since this model
# only makes use of the last two utterances to predict state updates,
# it will fail in such cases.
logging.debug(
f'"Slot values {str(values)} not found in user or system utterance in example with id - {self.example_id}.'
)
start = 0
end = 0
self.noncategorical_slot_value_start = start
self.noncategorical_slot_value_end = end
def add_requested_slots(self, frame: dict):
"""Add requested slots to InputExample
Args:
frame: frame object from which requested slots are extracted
"""
all_slots = self.service_schema.slots
slot = all_slots[self.requested_slot_id]
if slot in frame["state"]["requested_slots"]:
self.requested_slot_status = STATUS_ACTIVE
def add_intents(self, frame):
"""Add intents to InputExample
Args:
frame: frame object from which intents are extracted
"""
all_intents = self.service_schema.intents
intent = all_intents[self.intent_id]
if intent == frame["state"]["active_intent"]:
self.intent_status = STATUS_ACTIVE
# Modified from run_classifier._truncate_seq_pair in the public bert model repo.
# https://github.com/google-research/bert/blob/master/run_classifier.py.
def truncate_seq_pair(tokens_a: List[int], tokens_b: List[int], max_length: int) -> bool:
"""Truncate a seq pair in place so that their total length <= max_length.
Args:
tokens_a: first token sequence
tokens_b: second token sequence
max_length: truncated sequence length
Returns:
is_too_long: whether combined sequences exceed maximum sequence length
"""
is_too_long = False
# This is a simple heuristic which will always truncate the longer sequence
# one token at a time. This makes more sense than truncating an equal percent
# of tokens from each, since if one sequence is very short then each token
# that's truncated likely contains more information than a longer sequence.
while True:
total_length = len(tokens_a) + len(tokens_b)
if total_length <= max_length:
break
is_too_long = True
if len(tokens_a) > len(tokens_b):
tokens_a.pop()
else:
tokens_b.pop()
return is_too_long
@@ -0,0 +1,341 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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.
"""Evaluation metrics for Schema-guided dialogue.
This library provides functions for calculating the evaluation metrics for a
single dialogue. The following metrics are defined:
(1) Active intent accuracy: The fraction of user turns for which the active
intent has been correctly predicted.
(2) Slot tagging F1: The macro-averaged F1 score for tagging slot values for
non-categorical slots. This metric is optional to report in the final paper
if participants decide not to use slot tagging.
(3) Requested slots F1: The macro-averaged F1 score for requested slots over the
turns. For a turn, if there are no requested slots in both the ground truth
and the prediction, that turn is skipped. The reported number is the average
F1 score for all un-skipped user turns. This metric is optional to report in
the final paper.
(4) Average goal accuracy: For each turn, participants must predict a single
value for each slot present in the dialogue state. The slots which have a
non-empty assignment in the ground truth dialogue state are only considered.
This is the average accuracy of predicting the value of a slot correctly. A
fuzzy matching based score is used for non-categorical slots.
(5) Joint goal accuracy: This is the average accuracy of predicting all slot
assignments for a turn correctly. A fuzzy matching based score is used for
non-categorical slots. This is the primary evaluation metric used for ranking
submissions. More details to follow with the evaluation script.
This file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/metrics.py
"""
import collections
import numpy as np
from rapidfuzz import fuzz
F1Scores = collections.namedtuple("F1Scores", ["f1", "precision", "recall"])
# Evaluation and other relevant metrics for DSTC8/SGD Schema-guided DST.
# (1) Active intent accuracy.
ACTIVE_INTENT_ACCURACY = "active_intent_accuracy"
# (2) Slot tagging F1.
SLOT_TAGGING_F1 = "slot_tagging_f1"
SLOT_TAGGING_PRECISION = "slot_tagging_precision"
SLOT_TAGGING_RECALL = "slot_tagging_recall"
# (3) Requested slots F1.
REQUESTED_SLOTS_F1 = "requested_slots_f1"
REQUESTED_SLOTS_PRECISION = "requested_slots_precision"
REQUESTED_SLOTS_RECALL = "requested_slots_recall"
# (4) Average goal accuracy.
AVERAGE_GOAL_ACCURACY = "average_goal_accuracy"
AVERAGE_CAT_ACCURACY = "average_cat_accuracy"
AVERAGE_NONCAT_ACCURACY = "average_noncat_accuracy"
# (5) Joint goal accuracy.
JOINT_GOAL_ACCURACY = "joint_goal_accuracy"
JOINT_CAT_ACCURACY = "joint_cat_accuracy"
JOINT_NONCAT_ACCURACY = "joint_noncat_accuracy"
AVERAGE_CAT_STATUS_ACCURACY = "average_cat_status_accuracy"
AVERAGE_CAT_VALUE_ACCURACY = "average_cat_value_accuracy"
AVERAGE_NONCAT_STATUS_ACCURACY = "average_noncat_status_accuracy"
AVERAGE_NONCAT_VALUE_ACCURACY = "average_noncat_value_accuracy"
JOINT_CAT_STATUS_ACCURACY = "joint_cat_status_accuracy"
JOINT_CAT_VALUE_ACCURACY = "joint_cat_value_accuracy"
JOINT_NONCAT_STATUS_ACCURACY = "joint_noncat_status_accuracy"
JOINT_NONCAT_VALUE_ACCURACY = "joint_noncat_value_accuracy"
NAN_VAL = "NA"
def compute_f1(list_ref, list_hyp):
"""Compute F1 score from reference (grouth truth) list and hypothesis list.
Args:
list_ref: List of true elements.
list_hyp: List of postive (retrieved) elements.
Returns:
A F1Scores object containing F1, precision, and recall scores.
"""
ref = collections.Counter(list_ref)
hyp = collections.Counter(list_hyp)
true = sum(ref.values())
positive = sum(hyp.values())
true_positive = sum((ref & hyp).values())
precision = float(true_positive) / positive if positive else 1.0
recall = float(true_positive) / true if true else 1.0
if precision + recall > 0.0:
f1 = 2.0 * precision * recall / (precision + recall)
else: # The F1-score is defined to be 0 if both precision and recall are 0.
f1 = 0.0
return F1Scores(f1=f1, precision=precision, recall=recall)
def fuzzy_string_match(str_ref, str_hyp):
"""Returns fuzzy string similarity score in range [0.0, 1.0].
Args:
str_ref: reference string
str_hyp: hypothesis string
Returns:
fuzzy string similarity
"""
# The higher the score, the higher the similarity between the two strings.
return fuzz.token_sort_ratio(str_ref, str_hyp) / 100.0
def noncat_slot_value_match(str_ref_list, str_hyp, use_fuzzy_match):
"""Calculate non-categorical slots correctness.
Args:
str_ref_list: a list of reference strings.
str_hyp: the hypothesis string.
use_fuzzy_match: whether to use fuzzy string matching.
Returns:
score: The highest fuzzy string match score of the references and hypotheis.
"""
score = 0.0
for str_ref in str_ref_list:
if use_fuzzy_match:
match_score = fuzzy_string_match(str_ref, str_hyp)
else:
match_score = float(str_ref == str_hyp)
score = max(score, match_score)
return score
def compare_slot_values(slot_values_ref, slot_values_hyp, service, use_fuzzy_match):
"""Compare and get correctness of goal state's slot_values.
Args:
slot_values_ref: goal state slot_values from reference (ground truth).
slot_values_hyp: goal state slot_values from hypothesis (prediction).
service: a service data structure in the schema. We use it to obtain the
list of slots in the service and infer whether a slot is categorical.
use_fuzzy_match: whether to use fuzzy string matching for non-categorical
slot values
Returns:
list_cor: list of corectness scores, each corresponding to one slot in the
service. The score is a float either 0.0 or 1.0 for categorical slot,
and in range [0.0, 1.0] for non-categorical slot.
slot_active: list indicating whether the element in list_cor corresponds to
an active ground-truth slot.
slot_cat: list indicating whether the element in list_cor corresponds to a
categorical slot.
list_cor_status: list of correct slot statuses
list_cor_value: list of correctness score only for active slots. Monactive slots are assigned -1.
"""
list_cor = []
list_cor_status = []
list_cor_value = []
slot_active = []
slot_cat = []
for slot in service["slots"]:
slot_name = slot["name"]
slot_cat.append(slot["is_categorical"])
if slot_name in slot_values_ref: # REF=active
slot_active.append(True)
if slot_name in slot_values_hyp: # HYP=active, apply matching
value_ref_list = slot_values_ref[slot_name]
value_hyp = slot_values_hyp[slot_name][0]
if slot["is_categorical"]:
cor = float(value_ref_list[0] == value_hyp)
else:
cor = noncat_slot_value_match(value_ref_list, value_hyp, use_fuzzy_match)
list_cor.append(cor)
list_cor_status.append(1.0)
list_cor_value.append(cor)
else: # HYP=off
list_cor.append(0.0)
list_cor_status.append(0.0)
list_cor_value.append(-1.0)
else: # REF=off
slot_active.append(False)
if slot_name in slot_values_hyp: # HYP=active
list_cor.append(0.0)
list_cor_status.append(0.0)
else: # HYP=off
list_cor.append(1.0)
list_cor_status.append(1.0)
list_cor_value.append(-1.0)
assert len(list_cor) == len(service["slots"])
assert len(slot_active) == len(service["slots"])
assert len(slot_cat) == len(service["slots"])
return list_cor, slot_active, slot_cat, list_cor_status, list_cor_value
def get_active_intent_accuracy(frame_ref, frame_hyp):
"""Get active intent accuracy of a frame.
Args:
frame_ref: single semantic frame from reference (ground truth) file.
frame_hyp: single semantic frame from hypothesis (prediction) file.
Returns:
1.0 if the intent prediction is correct, otherwise 0.0.
"""
return float(frame_ref["state"]["active_intent"] == frame_hyp["state"]["active_intent"])
def get_slot_tagging_f1(frame_ref, frame_hyp, utt, service):
"""Get slot tagging (non-categorical slots only) F1 scores of a frame.
Args:
frame_ref: single semantic frame from reference (ground truth) file.
frame_hyp: single semantic frame from hypothesis (prediction) file.
utt: user utterance. Slot tagging annotations are the character positions in
the utterance.
service: a service data structure in the schema. We use it to infer whether
a slot is non-categorical.
Returns:
A F1Scores object containing F1, precision, and recall scores.
"""
list_noncat_slots = [s["name"] for s in service["slots"] if not s["is_categorical"]]
if "slots" not in frame_hyp:
return None
else:
list_ref = [
(s["slot"], utt[s["start"] : s["exclusive_end"]])
for s in frame_ref["slots"]
if s["slot"] in list_noncat_slots
]
list_hyp = [
(s["slot"], utt[s["start"] : s["exclusive_end"]])
for s in frame_hyp["slots"]
if s["slot"] in list_noncat_slots
]
return compute_f1(list_ref, list_hyp)
def get_requested_slots_f1(frame_ref, frame_hyp):
"""Get requested slots F1 scores of a frame.
Args:
frame_ref: single semantic frame from reference (ground truth) file.
frame_hyp: single semantic frame from hypothesis (prediction) file.
Returns:
A F1Scores object containing F1, precision, and recall scores.
"""
return compute_f1(frame_ref["state"]["requested_slots"], frame_hyp["state"]["requested_slots"])
def get_average_and_joint_goal_accuracy(frame_ref, frame_hyp, service, use_fuzzy_match):
"""Get average and joint goal accuracies of a frame.
Args:
frame_ref: single semantic frame from reference (ground truth) file.
frame_hyp: single semantic frame from hypothesis (prediction) file.
service: a service data structure in the schema. We use it to obtain the
list of slots in the service and infer whether a slot is categorical.
use_fuzzy_match: whether to use fuzzy string matching for comparing
non-categorical slot values.
Returns:
goal_acc: a dict whose values are average / joint
all-goal / categorical-goal / non-categorical-goal accuracies.
"""
goal_acc = {}
list_acc, slot_active, slot_cat, list_status_acc, list_value_acc = compare_slot_values(
frame_ref["state"]["slot_values"], frame_hyp["state"]["slot_values"], service, use_fuzzy_match
)
# (4) Average goal accuracy.
active_acc = [acc for acc, active in zip(list_acc, slot_active) if active]
goal_acc[AVERAGE_GOAL_ACCURACY] = np.mean(active_acc) if active_acc else NAN_VAL
# (4-a) categorical.
active_cat_acc = [acc for acc, active, cat in zip(list_acc, slot_active, slot_cat) if active and cat]
goal_acc[AVERAGE_CAT_ACCURACY] = np.mean(active_cat_acc) if active_cat_acc else NAN_VAL
# (4-b) non-categorical.
active_noncat_acc = [acc for acc, active, cat in zip(list_acc, slot_active, slot_cat) if active and not cat]
goal_acc[AVERAGE_NONCAT_ACCURACY] = np.mean(active_noncat_acc) if active_noncat_acc else NAN_VAL
# (5) Joint goal accuracy.
goal_acc[JOINT_GOAL_ACCURACY] = np.prod(list_acc) if list_acc else NAN_VAL
# (5-a) categorical.
cat_acc = [acc for acc, cat in zip(list_acc, slot_cat) if cat]
goal_acc[JOINT_CAT_ACCURACY] = np.prod(cat_acc) if cat_acc else NAN_VAL
# (5-b) non-categorical.
noncat_acc = [acc for acc, cat in zip(list_acc, slot_cat) if not cat]
goal_acc[JOINT_NONCAT_ACCURACY] = np.prod(noncat_acc) if noncat_acc else NAN_VAL
# !!!!!!!!!!DEBUG!!!!!!!!!!!!!
# cat status acc for both active and non active
active_cat_status_acc = [acc for acc, active, cat in zip(list_status_acc, slot_active, slot_cat) if cat and active]
goal_acc[AVERAGE_CAT_STATUS_ACCURACY] = np.mean(active_cat_status_acc) if active_cat_status_acc else NAN_VAL
# joint cat status acc for both active and non active
cat_status_acc = [acc for acc, cat in zip(list_status_acc, slot_cat) if cat]
goal_acc[JOINT_CAT_STATUS_ACCURACY] = np.prod(cat_status_acc) if cat_status_acc else NAN_VAL
# non cat status acc for both active and non active
active_noncat_status_acc = [
acc for acc, active, cat in zip(list_status_acc, slot_active, slot_cat) if not cat and active
]
goal_acc[AVERAGE_NONCAT_STATUS_ACCURACY] = (
np.mean(active_noncat_status_acc) if active_noncat_status_acc else NAN_VAL
)
# joint non cat status acc for both active and non active
noncat_status_acc = [acc for acc, cat in zip(list_status_acc, slot_cat) if not cat]
goal_acc[JOINT_NONCAT_STATUS_ACCURACY] = np.prod(noncat_status_acc) if noncat_status_acc else NAN_VAL
# cat value acc for both active and non active
active_cat_val_acc = [
acc for acc, active, cat in zip(list_value_acc, slot_active, slot_cat) if cat and acc > -0.5 and active
]
goal_acc[AVERAGE_CAT_VALUE_ACCURACY] = np.mean(active_cat_val_acc) if active_cat_val_acc else NAN_VAL
# joint cat value acc for both active and non active
cat_val_acc = [acc for acc, cat in zip(list_value_acc, slot_cat) if cat and acc > -0.5]
goal_acc[JOINT_CAT_VALUE_ACCURACY] = np.prod(cat_val_acc) if cat_val_acc else NAN_VAL
# cat non value acc for both active and non active
active_noncat_val_acc = [
acc for acc, active, cat in zip(list_value_acc, slot_active, slot_cat) if not cat and acc > -0.5 and active
]
goal_acc[AVERAGE_NONCAT_VALUE_ACCURACY] = np.mean(active_noncat_val_acc) if active_noncat_val_acc else NAN_VAL
# joint non cat value acc for both active and non active
noncat_val_acc = [acc for acc, cat in zip(list_value_acc, slot_cat) if not cat and acc > -0.5]
goal_acc[JOINT_NONCAT_VALUE_ACCURACY] = np.prod(noncat_val_acc) if noncat_val_acc else NAN_VAL
return goal_acc
@@ -0,0 +1,251 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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.
"""
Prediction and evaluation-related utility functions.
This file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/baseline/pred_utils.py
"""
import json
import os
from collections import OrderedDict, defaultdict
from typing import Dict, List, Optional
from nemo.collections.nlp.data.dialogue_state_tracking.sgd.input_example import (
STATUS_ACTIVE,
STATUS_DONTCARE,
STR_DONTCARE,
)
from nemo.utils import logging
REQ_SLOT_THRESHOLD = 0.5
__all__ = ['write_predictions_to_file']
def set_cat_slot(predictions_status: dict, predictions_value: dict, cat_slot_values: Dict[str, List[str]]) -> dict:
"""
Extract predicted categorical slot information
Args:
predictions_status: predicted statuses
predictions_value: predicted slot values
cat_slot_values: possible categorical slots and their potential values for this service
Returns:
out_dict: predicted slot value pairs
"""
out_dict = {}
for slot_idx, slot in enumerate(cat_slot_values):
slot_status = predictions_status[slot_idx][0]["cat_slot_status"]
if slot_status == STATUS_DONTCARE:
out_dict[slot] = STR_DONTCARE
elif slot_status == STATUS_ACTIVE:
tmp = predictions_value[slot_idx]
value_idx = max(tmp, key=lambda k: tmp[k]['cat_slot_value_status'][0].item())
out_dict[slot] = cat_slot_values[slot][value_idx]
return out_dict
def set_noncat_slot(
predictions_status: dict,
predictions_value: dict,
non_cat_slots: List[str],
user_utterance: str,
sys_slots_agg: Optional[dict] = None,
) -> dict:
"""
Extract predicted non categorical slot information
Args:
predictions_status: predicted statuses
predictions_value: predicted slot values
non_cat_slots: list of possible non categorical slots for this service
user_utterance: system and user utterance
sys_slots_agg: system retrieval lookup table. Contains for each slot the most recent value seen in the history
Returns:
out_dict: predicted slot value pairs
"""
out_dict = {}
for slot_idx, slot in enumerate(non_cat_slots):
slot_status = predictions_status[slot_idx][0]["noncat_slot_status"]
if slot_status == STATUS_DONTCARE:
out_dict[slot] = STR_DONTCARE
elif slot_status == STATUS_ACTIVE:
tok_start_idx = predictions_value[slot_idx][0]["noncat_slot_start"]
tok_end_idx = predictions_value[slot_idx][0]["noncat_slot_end"]
ch_start_idx = predictions_value[slot_idx][0]["noncat_alignment_start"][tok_start_idx]
ch_end_idx = predictions_value[slot_idx][0]["noncat_alignment_end"][tok_end_idx]
if ch_start_idx > 0 and ch_end_idx > 0:
# Add span from the utterance.
out_dict[slot] = user_utterance[ch_start_idx - 1 : ch_end_idx]
elif sys_slots_agg and slot in sys_slots_agg:
# system retrieval
out_dict[slot] = sys_slots_agg[slot]
return out_dict
def get_predicted_dialog(dialog: dict, all_predictions: dict, schemas: object, state_tracker: str) -> dict:
"""Overwrite the labels in the turn with the predictions from the model. For test set, these labels are missing from the data and hence they are added.
Args:
dialog: ground truth dialog
all_predictions: predictions
schemas: schema object of all services of all datasets
state_tracker: state tracker option, e.g. nemotracker
Returns:
dialog: dialog overwritten with prediction information
"""
dialog_id = dialog["dialogue_id"]
if state_tracker == "baseline":
sys_slots_agg = {}
else:
sys_slots_agg = defaultdict(OrderedDict)
all_slot_values = defaultdict(dict)
for turn_idx, turn in enumerate(dialog["turns"]):
if turn["speaker"] == "SYSTEM" and state_tracker == 'nemotracker':
for frame in turn["frames"]:
if frame["service"] not in sys_slots_agg:
sys_slots_agg[frame["service"]] = OrderedDict()
for action in frame["actions"]:
if action["slot"] and len(action["values"]) > 0:
sys_slots_agg[frame["service"]][action["slot"]] = action["values"][0]
if turn["speaker"] == "USER":
user_utterance = turn["utterance"]
system_utterance = dialog["turns"][turn_idx - 1]["utterance"] if turn_idx else ""
system_user_utterance = system_utterance + ' ' + user_utterance
turn_id = "{:02d}".format(turn_idx)
for frame in turn["frames"]:
predictions = all_predictions[(dialog_id, turn_id, frame["service"])]
slot_values = all_slot_values[frame["service"]]
service_schema = schemas.get_service_schema(frame["service"])
# Remove the slot spans and state if present.
frame.pop("slots", None)
frame.pop("state", None)
# The baseline model doesn't predict slot spans. Only state predictions
# are added.
state = {}
# Add prediction for active intent. No Offset is subtracted since schema has now NONE intent at index 0
state["active_intent"] = get_predicted_intent(
predictions=predictions[0], intents=service_schema.intents
)
# Add prediction for requested slots.
state["requested_slots"] = get_requested_slot(predictions=predictions[1], slots=service_schema.slots)
# Add prediction for user goal (slot values).
# Categorical slots.
cat_out_dict = set_cat_slot(
predictions_status=predictions[2],
predictions_value=predictions[3],
cat_slot_values=service_schema.categorical_slot_values,
)
for k, v in cat_out_dict.items():
slot_values[k] = v
# Non-categorical slots.
noncat_out_dict = set_noncat_slot(
predictions_status=predictions[4],
predictions_value=predictions[5],
non_cat_slots=service_schema.non_categorical_slots,
user_utterance=system_user_utterance,
sys_slots_agg=sys_slots_agg.get(frame["service"], None),
)
for k, v in noncat_out_dict.items():
slot_values[k] = v
# Create a new dict to avoid overwriting the state in previous turns
# because of use of same objects.
state["slot_values"] = {s: [v] for s, v in slot_values.items()}
frame["state"] = state
return dialog
def get_predicted_intent(predictions: dict, intents: List[str]) -> str:
"""
Returns intent name with maximum score
Args:
predictions: predictions
intents: list of possible intents for this service
Returns:
intent: predicted intent
"""
assert len(predictions) == len(intents)
active_intent_id = max(predictions, key=lambda k: predictions[k][0]['intent_status'])
intent = intents[active_intent_id]
return intent
def get_requested_slot(predictions: dict, slots: List[str]) -> List[str]:
"""
Returns list of slots which are predicted to be requested
Args:
predictions: predictions
slots: list of possible slots
Returns:
requested_slots: list of requested slots
"""
active_indices = [k for k in predictions if predictions[k][0]["req_slot_status"] > REQ_SLOT_THRESHOLD]
requested_slots = list(map(lambda k: slots[k], active_indices))
return requested_slots
def write_predictions_to_file(
predictions: List[dict],
input_json_files: List[str],
output_dir: str,
schemas: object,
state_tracker: str,
eval_debug: bool,
in_domain_services: set,
):
"""Save predicted dialogues as json files.
Args:
predictions: An iterator containing model predictions. This is the output of
the predict method in the estimator.
input_json_files: A list of json paths containing the dialogues to run
inference on.
output_dir: The directory where output json files will be created.
schemas: Schemas to all services in the dst dataset
state_tracker: state tracker option
eval_debug: output evaluation debugging information
in_domain_services: in domain services
"""
logging.info(f"Writing predictions to {output_dir} started.")
# Index all predictions.
all_predictions = defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))
for idx, prediction in enumerate(predictions):
eval_dataset, dialog_id, turn_id, service_name, model_task, slot_intent_id, value_id = prediction[
'example_id'
].split('-')
all_predictions[(dialog_id, turn_id, service_name)][int(model_task)][int(slot_intent_id)][
int(value_id)
] = prediction
logging.info(f'Predictions for {idx} examples in {eval_dataset} dataset are getting processed.')
# Read each input file and write its predictions.
for input_file_path in input_json_files:
with open(input_file_path) as f:
dialogs = json.load(f)
logging.debug(f'{input_file_path} file is loaded')
pred_dialogs = []
for d in dialogs:
pred_dialog = get_predicted_dialog(d, all_predictions, schemas, state_tracker)
pred_dialogs.append(pred_dialog)
input_file_name = os.path.basename(input_file_path)
output_file_path = os.path.join(output_dir, input_file_name)
with open(output_file_path, "w") as f:
json.dump(pred_dialogs, f, indent=2, separators=(",", ": "), sort_keys=True)
@@ -0,0 +1,222 @@
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
# Copyright 2019 The Google Research Authors.
#
# 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.
"""
Wrappers for schemas of different services.
This file contains code artifacts adapted from the original implementation:
https://github.com/google-research/google-research/blob/master/schema_guided_dst/schema.py
"""
import json
from typing import List, Optional, Union
from nemo.utils import logging
__all__ = ['Schema']
class ServiceSchema(object):
"""A wrapper for schema for a service."""
def __init__(self, schema_json: dict, service_id: Optional[int] = None):
"""
Constructor for ServiceSchema.
Args:
schema_json: schema json dict
service_id: service ID
"""
self._service_name = schema_json["service_name"]
self._description = schema_json["description"]
self._schema_json = schema_json
self._service_id = service_id
# Construct the vocabulary for intents, slots, categorical slots,
# non-categorical slots and categorical slot values.
self._intents = ["NONE"] + sorted(i["name"] for i in schema_json["intents"])
self._intent_descriptions = {i["name"]: i["description"] for i in schema_json["intents"]}
self._intent_descriptions["NONE"] = "none"
self._slots = sorted(s["name"] for s in schema_json["slots"])
self._slots_descriptions = {s["name"]: s["description"] for s in schema_json["slots"]}
self._categorical_slots = sorted(
s["name"] for s in schema_json["slots"] if s["is_categorical"] and s["name"] in self.state_slots
)
self._non_categorical_slots = sorted(
s["name"] for s in schema_json["slots"] if not s["is_categorical"] and s["name"] in self.state_slots
)
slot_schemas = {s["name"]: s for s in schema_json["slots"]}
categorical_slot_values = {}
categorical_slot_value_ids = {}
categorical_slot_ids = {}
non_categorical_slot_ids = {}
for slot_id, slot in enumerate(self._categorical_slots):
slot_schema = slot_schemas[slot]
values = sorted(slot_schema["possible_values"])
categorical_slot_values[slot] = values
value_ids = {value: idx for idx, value in enumerate(values)}
categorical_slot_value_ids[slot] = value_ids
categorical_slot_ids[slot] = slot_id
for slot_id, slot in enumerate(self._non_categorical_slots):
non_categorical_slot_ids[slot] = slot_id
self._categorical_slot_values = categorical_slot_values
self._categorical_slot_value_ids = categorical_slot_value_ids
self._categorical_slot_ids = categorical_slot_ids
self._non_categorical_slot_ids = non_categorical_slot_ids
@property
def schema_json(self) -> dict:
"""Returns schema json dictionary"""
return self._schema_json
@property
def state_slots(self) -> set:
"""Set of slots which are permitted to be in the dialogue state."""
state_slots = set()
for intent in self._schema_json["intents"]:
state_slots.update(intent["required_slots"])
state_slots.update(intent["optional_slots"])
return state_slots
@property
def service_name(self):
return self._service_name
@property
def service_id(self):
return self._service_id
@property
def description(self):
return self._description
@property
def slots(self):
return self._slots
@property
def intents(self):
return self._intents
@property
def intent_descriptions(self):
return self._intent_descriptions
@property
def slot_descriptions(self):
return self._slots_descriptions
@property
def categorical_slots(self):
return self._categorical_slots
@property
def non_categorical_slots(self):
return self._non_categorical_slots
@property
def categorical_slot_values(self):
return self._categorical_slot_values
def get_categorical_slot_values(self, slot):
return self._categorical_slot_values[slot]
def get_slot_from_id(self, slot_id):
return self._slots[slot_id]
def get_intent_from_id(self, intent_id):
return self._intents[intent_id]
def get_categorical_slot_from_id(self, slot_id):
return self._categorical_slots[slot_id]
def get_non_categorical_slot_from_id(self, slot_id):
return self._non_categorical_slots[slot_id]
def get_categorical_slot_value_from_id(self, slot_id, value_id):
slot = self._categorical_slots[slot_id]
return self._categorical_slot_values[slot][value_id]
def get_categorical_slot_value_id(self, slot, value):
return self._categorical_slot_value_ids[slot][value]
def get_categorical_slot_id(self, slot):
return self._categorical_slot_ids[slot]
def get_non_categorical_slot_id(self, slot):
return self._non_categorical_slot_ids[slot]
class Schema(object):
"""Wrapper for schemas for all services in a dataset."""
def __init__(self, schema_json_paths: Union[str, List[str]]):
"""
schema_json_paths: list of .json path to schema files of a single str with path to the json file.
"""
# Load the schema from the json file.
if isinstance(schema_json_paths, str):
with open(schema_json_paths, "r") as f:
all_schemas = json.load(f)
f.close()
else:
# load multiple schemas from the list of the json files
all_schemas = []
completed_services = []
for schema_json_path in schema_json_paths:
with open(schema_json_path, "r") as f:
schemas = json.load(f)
f.close()
logging.debug("Num of services in %s: %s", schema_json_path, len(schemas))
for service in schemas:
if service['service_name'] not in completed_services:
completed_services.append(service['service_name'])
all_schemas.append(service)
self._services = sorted(schema["service_name"] for schema in all_schemas)
self._services_vocab = {v: k for k, v in enumerate(self._services)}
self._services_id_to_vocab = {v: k for k, v in self._services_vocab.items()}
service_schemas = {}
for schema in all_schemas:
service = schema["service_name"]
service_schemas[service] = ServiceSchema(schema, service_id=self.get_service_id(service))
self._service_schemas = service_schemas
self._schemas = all_schemas
self._slots_relation_list = {}
def get_service_id(self, service: str):
return self._services_vocab[service]
def get_service_from_id(self, service_id: int):
return self._services[service_id]
def get_service_schema(self, service: str):
return self._service_schemas[service]
@property
def services(self):
return self._services
def save_to_file(self, file_path):
"""
Saves schema object to file
Args:
file_path: path to store schema object at
"""
with open(file_path, "w") as f:
json.dump(self._schemas, f, indent=2)
@@ -0,0 +1,15 @@
# 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.
from nemo.collections.nlp.data.entity_linking.entity_linking_dataset import EntityLinkingDataset
@@ -0,0 +1,135 @@
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import array
import pickle as pkl
from typing import Optional
import torch
from nemo.collections.nlp.data.data_utils.data_preprocessing import find_newlines, load_data_indices
from nemo.core.classes import Dataset
from nemo.utils import logging
__all__ = ['EntityLinkingDataset']
class EntityLinkingDataset(Dataset):
"""
Parent class for entity linking encoder training and index
datasets
Args:
tokenizer (obj): huggingface tokenizer,
data_file (str): path to tab separated column file where data
pairs apear in the format
concept_ID\tconcept_synonym1\tconcept_synonym2\n
newline_idx_file (str): path to pickle file containing location
of data_file newline characters
max_seq_length (int): maximum length of a concept in tokens
is_index_data (bool): Whether dataset will be used for building
a nearest neighbors index
"""
def __init__(
self,
tokenizer: object,
data_file: str,
newline_idx_file: Optional[str] = None,
max_seq_length: Optional[int] = 512,
is_index_data: bool = False,
):
self.tokenizer = tokenizer
# Try and load pair indices file if already exists
newline_indices, newline_idx_file, _ = load_data_indices(newline_idx_file, data_file, "newline_indices")
# If pair indices file doesn't exists, generate and store them
if newline_indices is None:
logging.info("Getting datafile newline indices")
with open(data_file, "rb") as f:
contents = f.read()
newline_indices = find_newlines(contents)
newline_indices = array.array("I", newline_indices)
# Store data file indicies to avoid generating them again
with open(newline_idx_file, "wb") as f:
pkl.dump(newline_indices, f)
self.newline_indices = newline_indices
self.data_file = data_file
self.num_lines = len(newline_indices)
self.max_seq_length = max_seq_length
self.is_index_data = is_index_data
logging.info(f"Loaded dataset with {self.num_lines} examples")
def __len__(self):
return self.num_lines
def __getitem__(self, idx):
concept_offset = self.newline_indices[idx]
with open(self.data_file, "r", encoding='utf-8-sig') as f:
# Find data pair within datafile using byte offset
f.seek(concept_offset)
concept = f.readline()[:-1]
concept = concept.strip().split("\t")
if self.is_index_data:
concept_id, concept = concept
return (int(concept_id), concept)
else:
concept_id, concept1, concept2 = concept
return (int(concept_id), concept1, concept2)
def _collate_fn(self, batch):
"""collate batch of input_ids, segment_ids, input_mask, and label
Args:
batch: A list of tuples of format (concept_ID, concept_synonym1, concept_synonym2).
"""
if self.is_index_data:
concept_ids, concepts = zip(*batch)
concept_ids = list(concept_ids)
concepts = list(concepts)
else:
concept_ids, concepts1, concepts2 = zip(*batch)
concept_ids = list(concept_ids)
concept_ids.extend(concept_ids) # Need to double label list to match each concept
concepts = list(concepts1)
concepts.extend(concepts2)
batch = self.tokenizer(
concepts,
add_special_tokens=True,
padding=True,
truncation=True,
max_length=self.max_seq_length,
return_token_type_ids=True,
return_attention_mask=True,
return_length=True,
)
return (
torch.LongTensor(batch["input_ids"]),
torch.LongTensor(batch["token_type_ids"]),
torch.LongTensor(batch["attention_mask"]),
torch.LongTensor(concept_ids),
)
@@ -0,0 +1,15 @@
# 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.
from nemo.collections.nlp.data.glue_benchmark.glue_benchmark_dataset import GLUEDataset
@@ -0,0 +1,323 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
import os
from nemo.collections.nlp.data.data_utils.data_preprocessing import DataProcessor
from nemo.utils import logging
__all__ = [
'ColaProcessor',
'MnliProcessor',
'MnliMismatchedProcessor',
'MrpcProcessor',
'Sst2Processor',
'StsbProcessor',
'QqpProcessor',
'QnliProcessor',
'RteProcessor',
'WnliProcessor',
]
class MrpcProcessor(DataProcessor):
"""Processor for the MRPC data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
logging.info(f'LOOKING AT {os.path.join(data_dir, "train.tsv")}')
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, i)
text_a = line[3]
text_b = line[4]
label = line[0]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class MnliProcessor(DataProcessor):
"""Processor for the MultiNLI data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev_matched.tsv")), "dev_matched")
def get_labels(self):
"""See base class."""
return ["contradiction", "entailment", "neutral"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
text_a = line[8]
text_b = line[9]
label = line[-1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class MnliMismatchedProcessor(MnliProcessor):
"""Processor for the MultiNLI Mismatched data set (GLUE version)."""
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev_mismatched.tsv")), "dev_matched")
class ColaProcessor(DataProcessor):
"""Processor for the CoLA data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
guid = "%s-%s" % (set_type, i)
text_a = line[3]
label = line[1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
return examples
class Sst2Processor(DataProcessor):
"""Processor for the SST-2 data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, i)
text_a = line[0]
label = line[1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
return examples
class StsbProcessor(DataProcessor):
"""Processor for the STS-B data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return [None]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
text_a = line[7]
text_b = line[8]
label = line[-1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class QqpProcessor(DataProcessor):
"""Processor for the QQP data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
try:
text_a = line[3]
text_b = line[4]
label = line[5]
except IndexError:
continue
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class QnliProcessor(DataProcessor):
"""Processor for the QNLI data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["entailment", "not_entailment"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
text_a = line[1]
text_b = line[2]
label = line[-1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class RteProcessor(DataProcessor):
"""Processor for the RTE data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["entailment", "not_entailment"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
text_a = line[1]
text_b = line[2]
label = line[-1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class WnliProcessor(DataProcessor):
"""Processor for the WNLI data set (GLUE version)."""
def get_train_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._create_examples(self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")
def get_labels(self):
"""See base class."""
return ["0", "1"]
def _create_examples(self, lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
if i == 0:
continue
guid = "%s-%s" % (set_type, line[0])
text_a = line[1]
text_b = line[2]
label = line[-1]
examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
return examples
class InputExample(object):
"""A single training/test example for simple sequence classification.
Args:
guid: Unique id for the example.
text_a: The untokenized text of the first sequence.
For single sequence tasks, only this sequence must be specified.
text_b: The untokenized text of the second
sequence. Only must be specified for sequence pair tasks.
label:The label of the example. This should be
specified for train and dev examples, but not for test examples.
"""
def __init__(self, guid: int, text_a: str, text_b: str = None, label: str = None):
"""Constructs a InputExample."""
self.guid = guid
self.text_a = text_a
self.text_b = text_b
self.label = label
@@ -0,0 +1,365 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
# Some code of this file was adapted from the HuggingFace library available at
# https://github.com/huggingface/transformers
import os
import pickle
from typing import Dict, List, Optional, Union
import numpy as np
import torch
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.glue_benchmark.data_processors import (
ColaProcessor,
MnliMismatchedProcessor,
MnliProcessor,
MrpcProcessor,
QnliProcessor,
QqpProcessor,
RteProcessor,
Sst2Processor,
StsbProcessor,
WnliProcessor,
)
from nemo.core.classes import Dataset
from nemo.core.neural_types import CategoricalValuesType, ChannelType, MaskType, NeuralType, RegressionValuesType
from nemo.utils import logging
__all__ = ['GLUEDataset']
processors = {
"cola": ColaProcessor,
"mnli": MnliProcessor,
"mnli-mm": MnliMismatchedProcessor,
"mrpc": MrpcProcessor,
"sst-2": Sst2Processor,
"sts-b": StsbProcessor,
"qqp": QqpProcessor,
"qnli": QnliProcessor,
"rte": RteProcessor,
"wnli": WnliProcessor,
}
output_modes = {
"cola": "classification",
"mnli": "classification",
"mnli-mm": "classification",
"mrpc": "classification",
"sst-2": "classification",
"sts-b": "regression",
"qqp": "classification",
"qnli": "classification",
"rte": "classification",
"wnli": "classification",
}
GLUE_TASKS_NUM_LABELS = {
"cola": 2,
"mnli": 3,
"mrpc": 2,
"sst-2": 2,
"sts-b": 1,
"qqp": 2,
"qnli": 2,
"rte": 2,
"wnli": 2,
}
class GLUEDataset(Dataset):
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
"labels": NeuralType(
tuple('B'), RegressionValuesType() if self.task_name == 'sts-b' else CategoricalValuesType()
),
}
def __init__(
self, file_name: str, task_name: str, tokenizer: TokenizerSpec, max_seq_length: str, use_cache: bool = True,
):
"""
Processes GLUE datasets
Args:
file_name: path to file
task_name: GLUE task name
tokenizer: such as AutoTokenizer
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
use_cache: whether to use data cache
"""
logging.info(f'Processing {file_name}')
data_dir, file_name = os.path.split(file_name)
file_name = file_name[:-4]
self.tokenizer = tokenizer
evaluate = False if 'train' in file_name else True
if task_name not in processors:
raise ValueError(f'{task_name} not supported. Choose from {processors.keys()}')
if task_name == 'mnli' and 'dev_mismatched' in file_name:
self.task_name = 'mnli-mm'
else:
self.task_name = task_name
processor = processors[self.task_name]()
output_mode = output_modes[self.task_name]
self.label_list = processor.get_labels()
self.examples = processor.get_dev_examples(data_dir) if evaluate else processor.get_train_examples(data_dir)
processor_name = type(processor).__name__
vocab_size = getattr(tokenizer, "vocab_size", 0)
cached_features_file = os.path.join(
data_dir,
"cached_{}_{}_{}_{}_{}".format(
processor_name, file_name, tokenizer.name, str(max_seq_length), str(vocab_size)
),
)
if use_cache and os.path.exists(cached_features_file):
logging.info(f"loading from {cached_features_file}")
with open(cached_features_file, "rb") as reader:
self.features = pickle.load(reader)
else:
token_params = {
'bos_token': None,
'eos_token': tokenizer.eos_token,
'pad_token': tokenizer.pad_token,
'cls_token': tokenizer.cls_token,
'sep_token_extra': tokenizer.eos_token if 'roberta' in tokenizer.name.lower() else None,
}
self.features = self.convert_examples_to_features(
self.examples, self.label_list, max_seq_length, tokenizer, output_mode, **token_params
)
master_device = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
if master_device:
logging.info(f'Saving train features into {cached_features_file}')
with open(cached_features_file, "wb") as writer:
pickle.dump(self.features, writer)
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
feature = self.features[idx]
return (
np.array(feature.input_ids),
np.array(feature.segment_ids),
np.array(feature.input_mask, dtype=np.long),
np.array(feature.label_id),
)
def convert_examples_to_features(
self,
examples: List[str],
label_list: List[int],
max_seq_length: int,
tokenizer: TokenizerSpec,
output_mode: str,
bos_token: str = None,
eos_token: str = '[SEP]',
pad_token: str = '[PAD]',
cls_token: str = '[CLS]',
sep_token_extra: str = None,
cls_token_at_end: bool = False,
cls_token_segment_id: int = 0,
pad_token_segment_id: int = 0,
pad_on_left: bool = False,
mask_padding_with_zero: bool = True,
sequence_a_segment_id: int = 0,
sequence_b_segment_id: int = 1,
):
"""
Loads a data file into a list of `InputBatch`s.
The `cls_token_at_end` defines the location of the CLS token:
* False (Default, BERT/XLM pattern): [CLS] + A + [SEP] + B + [SEP]
* True (XLNet/GPT pattern): A + [SEP] + B + [SEP] + [CLS]
The `cls_token_segment_id` defines the segment id associated to the CLS token (0 for BERT, 2 for XLNet)
The convention in BERT is:
a. For sequence pairs:
* tokens: [CLS] is this jack ##ville ? [SEP] no it is not . [SEP]
* type_ids: 0 0 0 0 0 0 0 1 1 1 1 1 1
b. For single sequences:
* tokens: [CLS] the dog is hairy . [SEP]
* type_ids: 0 0 0 0 0 0 0
Where "type_ids" are used to indicate whether this is the first
sequence or the second sequence. The embedding vectors for `type=0`
and `type=1` were learned during pre-training and are added to the
wordpiece embedding vector (and position vector). This is
not *strictly* necessarysince the [SEP] token unambiguously separates
the sequences, but it makes it easier for the model to learn
the concept of sequences.
For classification tasks, the first vector (corresponding to [CLS])
is used as as the "sentence vector". Note that this only makes sense
because the entire model is fine-tuned.
The convention for NMT is:
a. For sequence pairs:
* tokens:<BOS> is this jack ##ville ? <EOS> <BOS> no it is not . <EOS>
* type_ids:0 0 0 0 0 0 0 1 1 1 1 1 1 1
b. For single sequences:
* tokens: <BOS> the dog is hairy . <EOS>
* type_ids: 0 0 0 0 0 0 0
"""
label_map = {label: i for i, label in enumerate(label_list)}
features = []
for ex_index, example in enumerate(examples):
if ex_index % 10000 == 0:
logging.info("Writing example %d of %d" % (ex_index, len(examples)))
tokens_a = tokenizer.text_to_tokens(example.text_a)
tokens_b = None
if example.text_b:
tokens_b = tokenizer.text_to_tokens(example.text_b)
special_tokens_count = 2 if eos_token else 0
special_tokens_count += 1 if sep_token_extra else 0
special_tokens_count += 2 if bos_token else 0
special_tokens_count += 1 if cls_token else 0
self._truncate_seq_pair(tokens_a, tokens_b, max_seq_length - special_tokens_count)
else:
special_tokens_count = 1 if eos_token else 0
special_tokens_count += 1 if sep_token_extra else 0
special_tokens_count += 1 if bos_token else 0
if len(tokens_a) > max_seq_length - special_tokens_count:
tokens_a = tokens_a[: max_seq_length - special_tokens_count]
# Add special tokens to sequence_a
tokens = tokens_a
if bos_token:
tokens = [bos_token] + tokens
if eos_token:
tokens += [eos_token]
segment_ids = [sequence_a_segment_id] * len(tokens)
# Add sequence separator between sequences
if tokens_b and sep_token_extra:
tokens += [sep_token_extra]
segment_ids += [sequence_a_segment_id]
# Add special tokens to sequence_b
if tokens_b:
if bos_token:
tokens += [bos_token]
segment_ids += [sequence_b_segment_id]
tokens += tokens_b
segment_ids += [sequence_b_segment_id] * (len(tokens_b))
if eos_token:
tokens += [eos_token]
segment_ids += [sequence_b_segment_id]
# Add classification token - for BERT models
if cls_token:
if cls_token_at_end:
tokens += [cls_token]
segment_ids += [cls_token_segment_id]
else:
tokens = [cls_token] + tokens
segment_ids = [cls_token_segment_id] + segment_ids
input_ids = tokenizer.tokens_to_ids(tokens)
# The mask has 1 for real tokens and 0 for padding tokens. Only real
# tokens are attended to.
input_mask = [1 if mask_padding_with_zero else 0] * len(input_ids)
# Zero-pad up to the sequence length.
padding_length = max_seq_length - len(input_ids)
pad_token_id = tokenizer.tokens_to_ids([pad_token])[0]
if pad_on_left:
input_ids = ([pad_token_id] * padding_length) + input_ids
input_mask = ([0 if mask_padding_with_zero else 1] * padding_length) + input_mask
segment_ids = ([pad_token_segment_id] * padding_length) + segment_ids
else:
input_ids = input_ids + ([pad_token_id] * padding_length)
input_mask = input_mask + ([0 if mask_padding_with_zero else 1] * padding_length)
segment_ids = segment_ids + ([pad_token_segment_id] * padding_length)
if len(input_ids) != max_seq_length:
raise ValueError("input_ids must be of length max_seq_length")
if len(input_mask) != max_seq_length:
raise ValueError("input_mask must be of length max_seq_length")
if len(segment_ids) != max_seq_length:
raise ValueError("segment_ids must be of length max_seq_length")
if output_mode == "classification":
label_id = label_map[example.label]
elif output_mode == "regression":
label_id = np.float32(example.label)
else:
raise KeyError(output_mode)
if ex_index < 5:
logging.info("*** Example ***")
logging.info("guid: %s" % (example.guid))
logging.info("tokens: %s" % " ".join(list(map(str, tokens))))
logging.info("input_ids: %s" % " ".join(list(map(str, input_ids))))
logging.info("input_mask: %s" % " ".join(list(map(str, input_mask))))
logging.info("segment_ids: %s" % " ".join(list(map(str, segment_ids))))
logging.info("label: %s (id = %d)" % (example.label, label_id))
features.append(
InputFeatures(input_ids=input_ids, input_mask=input_mask, segment_ids=segment_ids, label_id=label_id)
)
return features
def _truncate_seq_pair(self, tokens_a: str, tokens_b: str, max_length: int):
"""Truncates a sequence pair in place to the maximum length.
This will always truncate the longer sequence one token at a time.
This makes more sense than truncating an equal percent
of tokens from each, since if one sequence is very short then each token
that's truncated likely contains more information than a longer sequence.
"""
while True:
total_length = len(tokens_a) + len(tokens_b)
if total_length <= max_length:
break
if len(tokens_a) > len(tokens_b):
tokens_a.pop()
else:
tokens_b.pop()
class InputFeatures(object):
"""A single set of features of data.
Args:
input_ids: input/token ids
input_mask: masks out subword tokens
segment_ids: distinguish one sentence from the other one (if present)
label_ids: label for the current example
"""
def __init__(
self, input_ids: List[int], input_mask: List[int], segment_ids: List[int], label_id: Union[float, int]
):
"""Initialized InputFeatures."""
self.input_ids = input_ids
self.input_mask = input_mask
self.segment_ids = segment_ids
self.label_id = label_id
@@ -0,0 +1,17 @@
# 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.
from nemo.collections.nlp.data.information_retrieval.information_retrieval_dataset import (
BertInformationRetrievalDataset,
)
@@ -0,0 +1,278 @@
# 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.
import multiprocessing as mp
import os
import pickle
import random
from typing import Optional
import numpy as np
from torch.utils.data import Dataset
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
__all__ = ["BertInformationRetrievalDataset"]
class BaseInformationRetrievalDataset(Dataset):
"""
Base information retrieval dataset on which other datasets are built.
Args:
tokenizer: tokenizer
max_query_length: maximum length of query in tokens
max_passage_length: maximum length of passage in tokens
"""
def __init__(
self, tokenizer: TokenizerSpec, max_query_length: Optional[int] = 31, max_passage_length: Optional[int] = 190,
):
self.tokenizer = tokenizer
self.max_query_length = max_query_length
self.max_passage_length = max_passage_length
def parse_npz(self, file, max_seq_length):
"""
Function which parses passages (documents) in npz format.
After pre-processing and tokenization, the dataset will be saved
as numpy matrix, i_th entry of which corresponds to i_th passage (document)
and has the following form:
[n, token_1, ..., token_n, 0, ..., 0]
where n is the passage length (in tokens) and 0s correspond to pad tokens.
Args:
file: str, path to file with passages (documents)
max_seq_length: maximum length of sequence in tokens
"""
cached_collection = file + ".npz"
if os.path.isfile(cached_collection):
dataset_npz = np.load(cached_collection)["data"]
else:
dataset_dict = self.tokenize_dataset(file, max_seq_length)
dataset_npz = np.zeros((len(dataset_dict), max_seq_length + 1))
for key in dataset_dict:
dataset_npz[key][0] = len(dataset_dict[key])
dataset_npz[key][1 : len(dataset_dict[key]) + 1] = dataset_dict[key]
np.savez(cached_collection, data=dataset_npz)
return dataset_npz
def parse_pkl(self, file, max_seq_length):
"""
Function which parses passages (documents, queries) in pkl format.
After pre-processing and tokenization, the dataset will be saved
as pkl dict, i_th entry of which corresponds to i_th passage (document, query)
and has the following form:
{passage_id: [token_1, ..., token_n]}
where n is the passage length (in tokens).
Args:
file: str, path to file with passages (documents)
max_seq_length: maximum length of sequence in tokens
"""
cached_collection = file + ".pkl"
if os.path.isfile(cached_collection):
dataset_dict = pickle.load(open(cached_collection, "rb"))
else:
dataset_dict = self.tokenize_dataset(file, max_seq_length)
pickle.dump(dataset_dict, open(cached_collection, "wb"))
return dataset_dict
def tokenize_dataset(self, file, max_seq_length):
"""
Function which pre-tokenizes the dataset.
"""
lines = open(file, "r").readlines()
with mp.Pool() as pool:
dataset_dict = pool.map(self.preprocess_line, lines)
dataset_dict = {id_: tokens[:max_seq_length] for (id_, tokens) in dataset_dict}
return dataset_dict
def preprocess_line(self, line):
"""
Parse a single entry (line) of tsv file.
"""
if "\t" not in line:
raise ValueError(f"Provided dataset does not have a form of tsv file")
id_, text = line.split("\t")
token_ids = self.tokenizer.text_to_ids(text.strip())
return int(id_), token_ids
def construct_input(self, token_ids1, max_seq_length, token_ids2=None):
"""
Function which constructs a valid input to BERT from tokens.
If only one list of tokens (token_ids1) is passed, the input will be
[CLS] token_ids1 [SEP]
if two lists of tokens are passed, the input will be
[CLS] token_ids1 [SEP] token_ids2 [SEP]
"""
input_ids = [self.tokenizer.pad_id] * max_seq_length
bert_input = [self.tokenizer.cls_id] + token_ids1 + [self.tokenizer.sep_id]
sentence1_length = len(bert_input)
if token_ids2 is not None:
bert_input = bert_input + token_ids2 + [self.tokenizer.sep_id]
bert_input = bert_input[:max_seq_length]
num_nonpad_tokens = len(bert_input)
input_ids[:num_nonpad_tokens] = bert_input
input_ids = np.array(input_ids, dtype=np.long)
input_mask = input_ids != self.tokenizer.pad_id
input_type_ids = np.ones_like(input_ids)
input_type_ids[:sentence1_length] = 0
return input_ids, input_mask, input_type_ids
def preprocess_bert(self, query_id, psg_ids):
"""
Transforms query id (Q) and a list of passages ids (P1, ..., Pk)
into a tensor of size [k, max_length] with the following rows:
[CLS] Q_text [SEP] Pi_text [SEP], i = 1, ..., k
"""
max_seq_length = self.max_query_length + self.max_passage_length + 3
input_ids, input_mask, input_type_ids = [], [], []
for psg_id in psg_ids:
inputs = self.construct_input(self.queries[query_id], max_seq_length, self._psgid2tokens(psg_id))
input_ids.append(inputs[0])
input_mask.append(inputs[1])
input_type_ids.append(inputs[2])
input_ids = np.stack(input_ids)
input_mask = np.stack(input_mask)
input_type_ids = np.stack(input_type_ids)
return input_ids, input_mask, input_type_ids
def preprocess_dpr(self, query_id, psg_ids):
"""
Transforms query id (Q) and a list of passages ids (P1, ..., Pk)
into two tensors of sizes [1, max_q_length] and [k, max_p_length]
with the following rows:
1) [CLS] Q_text [SEP]
2) [CLS] Pi_text [SEP], i = 1, ..., k
"""
q_input_ids, q_input_mask, q_type_ids = self.construct_input(self.queries[query_id], self.max_query_length + 2)
input_ids, input_mask, input_type_ids = [], [], []
for psg_id in psg_ids:
inputs = self.construct_input(self._psgid2tokens(psg_id), self.max_passage_length + 2)
input_ids.append(inputs[0])
input_mask.append(inputs[1])
input_type_ids.append(inputs[2])
input_ids = np.stack(input_ids)
input_mask = np.stack(input_mask)
input_type_ids = np.stack(input_type_ids)
return (
q_input_ids[None, ...],
q_input_mask[None, ...],
q_type_ids[None, ...],
input_ids,
input_mask,
input_type_ids,
)
def _psgid2tokens(self, psg_id):
"""
Internal function which maps passage id to its tokens.
"""
pass
def psgid2tokens_npz(self, psg_id):
"""
Mapping from passage id to its tokens in case of npz cache format.
"""
seq_len = self.passages[psg_id][0]
return self.passages[psg_id][1 : seq_len + 1].tolist()
def psgid2tokens_pkl(self, psg_id):
"""
Mapping from passage id to its tokens in case of pkl cache format.
"""
return self.passages[psg_id]
class BertInformationRetrievalDataset(BaseInformationRetrievalDataset):
def __init__(
self,
tokenizer: TokenizerSpec,
passages: str,
queries: str,
query_to_passages: str,
max_query_length: Optional[int] = 31,
max_passage_length: Optional[int] = 190,
num_negatives: Optional[int] = 10,
preprocess_fn: Optional[str] = "preprocess_bert",
psg_cache_format: Optional[str] = "npz",
):
"""
Dataset for training information retrieval models.
Args:
tokenizer: tokenizer
passages: path to tsv with [psg_id, psg_text] entries
queries: path to tsv with [query_id, query_text] entries
query_to_passages: path to tsv with
[query_id, pos_psg_id, neg_psg_id_1, ..., neg_psg_id_k] entries
max_query_length: maximum length of query in tokens
max_passage_length: maximum length of passage in tokens
num_negatives: number of negative passages per positive to use for training
preprocess_fn: either preprocess_bert or preprocess_dpr
preprocess_bert: joint input: [CLS] query [SEP] passage [SEP]
preprocess_dpr: separate inputs: [CLS] query [SEP], [CLS] passage [SEP]
psg_cache_format: either pkl or npz
"""
super().__init__(tokenizer, max_query_length, max_passage_length)
self.num_negatives = num_negatives
self.passages = getattr(self, f"parse_{psg_cache_format}")(passages, max_passage_length)
self._psgid2tokens = getattr(self, f"psgid2tokens_{psg_cache_format}")
self.queries = self.parse_pkl(queries, max_query_length)
self.idx2psgs = self.parse_query_to_passages(query_to_passages)
self._preprocess_fn = getattr(self, preprocess_fn)
def __getitem__(self, idx):
query_and_psgs = self.idx2psgs[idx]
query_id, psg_ids = query_and_psgs[0], query_and_psgs[1:]
inputs = self._preprocess_fn(query_id, psg_ids)
return [*inputs, query_id, np.array(psg_ids)]
def __len__(self):
return len(self.idx2psgs)
def parse_query_to_passages(self, file):
"""
Function which parses query to passages correspondence file.
"""
idx2psgs = {}
idx = 0
for line in open(file, "r").readlines():
if "\t" not in line:
raise ValueError(f"Provided dataset does not have a form of tsv file")
query_and_psgs = line.split("\t")
query_and_psgs_ids = [int(id_) for id_ in query_and_psgs]
query_and_rel_psg_ids, irrel_psgs_ids = query_and_psgs_ids[:2], query_and_psgs_ids[2:]
random.shuffle(irrel_psgs_ids)
num_samples = len(irrel_psgs_ids) // self.num_negatives
for j in range(num_samples):
left = self.num_negatives * j
right = self.num_negatives * (j + 1)
idx2psgs[idx] = query_and_rel_psg_ids + irrel_psgs_ids[left:right]
idx += 1
return idx2psgs
@@ -0,0 +1,22 @@
# 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.
from nemo.collections.nlp.data.intent_slot_classification.intent_slot_classification_dataset import (
IntentSlotClassificationDataset,
IntentSlotInferenceDataset,
)
from nemo.collections.nlp.data.intent_slot_classification.intent_slot_classification_descriptor import (
IntentSlotDataDesc,
)
@@ -0,0 +1,292 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
from typing import Any, Dict, Optional
import numpy as np
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.data_utils import get_stats
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, LabelsType, MaskType, NeuralType
from nemo.utils import logging
__all__ = ['IntentSlotClassificationDataset', 'IntentSlotInferenceDataset']
def get_features(
queries,
max_seq_length,
tokenizer,
pad_label=128,
raw_slots=None,
ignore_extra_tokens=False,
ignore_start_end=False,
):
all_subtokens = []
all_loss_mask = []
all_subtokens_mask = []
all_segment_ids = []
all_input_ids = []
all_input_mask = []
sent_lengths = []
all_slots = []
with_label = False
if raw_slots is not None:
with_label = True
for i, query in enumerate(queries):
words = query.strip().split()
subtokens = [tokenizer.cls_token]
loss_mask = [1 - ignore_start_end]
subtokens_mask = [0]
if with_label:
slots = [pad_label]
for j, word in enumerate(words):
word_tokens = tokenizer.text_to_tokens(word)
subtokens.extend(word_tokens)
loss_mask.append(1)
loss_mask.extend([int(not ignore_extra_tokens)] * (len(word_tokens) - 1))
subtokens_mask.append(1)
subtokens_mask.extend([0] * (len(word_tokens) - 1))
if with_label:
slots.extend([raw_slots[i][j]] * len(word_tokens))
subtokens.append(tokenizer.sep_token)
loss_mask.append(1 - ignore_start_end)
subtokens_mask.append(0)
sent_lengths.append(len(subtokens))
all_subtokens.append(subtokens)
all_loss_mask.append(loss_mask)
all_subtokens_mask.append(subtokens_mask)
all_input_mask.append([1] * len(subtokens))
if with_label:
slots.append(pad_label)
all_slots.append(slots)
max_seq_length_data = max(sent_lengths)
max_seq_length = min(max_seq_length, max_seq_length_data) if max_seq_length > 0 else max_seq_length_data
logging.info(f'Setting max length to: {max_seq_length}')
get_stats(sent_lengths)
too_long_count = 0
for i, subtokens in enumerate(all_subtokens):
if len(subtokens) > max_seq_length:
subtokens = [tokenizer.cls_token] + subtokens[-max_seq_length + 1 :]
all_input_mask[i] = [1] + all_input_mask[i][-max_seq_length + 1 :]
all_loss_mask[i] = [1 - ignore_start_end] + all_loss_mask[i][-max_seq_length + 1 :]
all_subtokens_mask[i] = [0] + all_subtokens_mask[i][-max_seq_length + 1 :]
if with_label:
all_slots[i] = [pad_label] + all_slots[i][-max_seq_length + 1 :]
too_long_count += 1
all_input_ids.append([tokenizer.tokens_to_ids(t) for t in subtokens])
if len(subtokens) < max_seq_length:
extra = max_seq_length - len(subtokens)
all_input_ids[i] = all_input_ids[i] + [0] * extra
all_loss_mask[i] = all_loss_mask[i] + [0] * extra
all_subtokens_mask[i] = all_subtokens_mask[i] + [0] * extra
all_input_mask[i] = all_input_mask[i] + [0] * extra
if with_label:
all_slots[i] = all_slots[i] + [pad_label] * extra
all_segment_ids.append([0] * max_seq_length)
logging.info(f'{too_long_count} are longer than {max_seq_length}')
# May be useful for debugging
logging.debug("*** Some Examples of Processed Data ***")
for i in range(min(len(all_input_ids), 5)):
logging.debug("i: %s" % (i))
logging.debug("subtokens: %s" % " ".join(list(map(str, all_subtokens[i]))))
logging.debug("loss_mask: %s" % " ".join(list(map(str, all_loss_mask[i]))))
logging.debug("input_mask: %s" % " ".join(list(map(str, all_input_mask[i]))))
logging.debug("subtokens_mask: %s" % " ".join(list(map(str, all_subtokens_mask[i]))))
if with_label:
logging.debug("slots_label: %s" % " ".join(list(map(str, all_slots[i]))))
return (all_input_ids, all_segment_ids, all_input_mask, all_loss_mask, all_subtokens_mask, all_slots)
class IntentSlotClassificationDataset(Dataset):
"""
Creates dataset to use for the task of joint intent
and slot classification with pretrained model.
Converts from raw data to an instance that can be used by
NMDataLayer.
For dataset to use during inference without labels, see
IntentSlotDataset.
Args:
input_file: file to sequence + label. the first line is header (sentence [tab] label)
each line should be [sentence][tab][label]
slot_file: file to slot labels, each line corresponding to slot labels for a sentence in input_file. No header.
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as NemoBertTokenizer
num_samples: number of samples you want to use for the dataset. If -1, use all dataset. Useful for testing.
pad_label: pad value use for slot labels. by default, it's the neutral label.
ignore_extra_tokens: whether to ignore extra tokens in the loss_mask.
ignore_start_end: whether to ignore bos and eos tokens in the loss_mask.
do_lower_case: convert query to lower case or not
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'loss_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
'intent_labels': NeuralType(('B'), LabelsType()),
'slot_labels': NeuralType(('B', 'T'), LabelsType()),
}
def __init__(
self,
input_file: str,
slot_file: str,
max_seq_length: int,
tokenizer: TokenizerSpec,
num_samples: int = -1,
pad_label: int = 128,
ignore_extra_tokens: bool = False,
ignore_start_end: bool = False,
do_lower_case: bool = False,
):
if num_samples == 0:
raise ValueError("num_samples has to be positive", num_samples)
with open(slot_file, 'r') as f:
slot_lines = f.readlines()
with open(input_file, 'r') as f:
input_lines = f.readlines()[1:]
assert len(slot_lines) == len(input_lines)
dataset = list(zip(slot_lines, input_lines))
if num_samples > 0:
dataset = dataset[:num_samples]
raw_slots, queries, raw_intents = [], [], []
for slot_line, input_line in dataset:
raw_slots.append([int(slot) for slot in slot_line.strip().split()])
parts = input_line.strip().split()
raw_intents.append(int(parts[-1]))
query = ' '.join(parts[:-1])
if do_lower_case:
query = query.lower()
queries.append(query)
features = get_features(
queries,
max_seq_length,
tokenizer,
pad_label=pad_label,
raw_slots=raw_slots,
ignore_extra_tokens=ignore_extra_tokens,
ignore_start_end=ignore_start_end,
)
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_loss_mask = features[3]
self.all_subtokens_mask = features[4]
self.all_slots = features[5]
self.all_intents = raw_intents
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.long),
np.array(self.all_loss_mask[idx]),
np.array(self.all_subtokens_mask[idx]),
self.all_intents[idx],
np.array(self.all_slots[idx]),
)
class IntentSlotInferenceDataset(Dataset):
"""
Creates dataset to use for the task of joint intent
and slot classification with pretrained model.
This is to be used during inference only.
It uses list of queries as the input.
Args:
queries (list): list of queries to run inference on
max_seq_length (int): max sequence length minus 2 for [CLS] and [SEP]
tokenizer (Tokenizer): such as NemoBertTokenizer
pad_label (int): pad value use for slot labels.
by default, it's the neutral label.
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""
Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'loss_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
}
def __init__(self, queries, max_seq_length, tokenizer, do_lower_case):
if do_lower_case:
for idx, query in enumerate(queries):
queries[idx] = queries[idx].lower()
features = get_features(queries, max_seq_length, tokenizer)
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_loss_mask = features[3]
self.all_subtokens_mask = features[4]
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.long),
np.array(self.all_loss_mask[idx]),
np.array(self.all_subtokens_mask[idx]),
)
@@ -0,0 +1,163 @@
# 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.
import itertools
from typing import List
from nemo.collections.nlp.data.data_utils.data_preprocessing import (
fill_class_weights,
get_freq_weights,
get_label_stats,
if_exist,
)
from nemo.utils import logging
class IntentSlotDataDesc:
""" Convert the raw data to the standard format supported by
IntentSlotDataDesc.
By default, the None label for slots is 'O'.
IntentSlotDataDesc requires two files:
input_file: file to sequence + label.
the first line is header (sentence [tab] label)
each line should be [sentence][tab][label]
slot_file: file to slot labels, each line corresponding to
slot labels for a sentence in input_file. No header.
To keep the mapping from label index to label consistent during
training and inferencing we require the following files:
dicts.intents.csv: each line is an intent. The first line
corresponding to the 0 intent label, the second line
corresponding to the 1 intent label, and so on.
dicts.slots.csv: each line is a slot. The first line
corresponding to the 0 slot label, the second line
corresponding to the 1 slot label, and so on.
Args:
data_dir: the directory of the dataset
modes: ['train', 'test', 'dev'],
none_slot_label: the label for slots that aren't identified defaulted to 'O'
pad_label: the int used for padding. If set to -1, it'll be set to the whatever the None label is.
"""
def __init__(
self,
data_dir: str,
modes: List[str] = ['train', 'test', 'dev'],
none_slot_label: str = 'O',
pad_label: int = -1,
):
if not if_exist(data_dir, ['dict.intents.csv', 'dict.slots.csv']):
raise FileNotFoundError(
"Make sure that your data follows the standard format "
"supported by JointIntentSlotDataset. Your data must "
"contain dict.intents.csv and dict.slots.csv."
)
self.data_dir = data_dir
self.intent_dict_file = self.data_dir + '/dict.intents.csv'
self.slot_dict_file = self.data_dir + '/dict.slots.csv'
self.intents_label_ids = IntentSlotDataDesc.label2idx(self.intent_dict_file)
self.num_intents = len(self.intents_label_ids)
self.slots_label_ids = IntentSlotDataDesc.label2idx(self.slot_dict_file)
self.num_slots = len(self.slots_label_ids)
infold = self.data_dir
for mode in modes:
if not if_exist(self.data_dir, [f'{mode}.tsv']):
logging.info(f' Stats calculation for {mode} mode' f' is skipped as {mode}.tsv was not found.')
continue
logging.info(f' Stats calculating for {mode} mode...')
slot_file = f'{self.data_dir}/{mode}_slots.tsv'
with open(slot_file, 'r') as f:
slot_lines = f.readlines()
input_file = f'{self.data_dir}/{mode}.tsv'
with open(input_file, 'r') as f:
input_lines = f.readlines()[1:] # Skipping headers at index 0
if len(slot_lines) != len(input_lines):
raise ValueError(
"Make sure that the number of slot lines match the "
"number of intent lines. There should be a 1-1 "
"correspondence between every slot and intent lines."
)
dataset = list(zip(slot_lines, input_lines))
raw_slots, raw_intents = [], []
for slot_line, input_line in dataset:
slot_list = [int(slot) for slot in slot_line.strip().split()]
raw_slots.append(slot_list)
parts = input_line.strip().split()
raw_intents.append(int(parts[-1]))
logging.info(f'Three most popular intents in {mode} mode:')
total_intents, intent_label_freq, max_id = get_label_stats(
raw_intents, infold + f'/{mode}_intent_stats.tsv'
)
merged_slots = itertools.chain.from_iterable(raw_slots)
logging.info(f'Three most popular slots in {mode} mode:')
slots_total, slots_label_freq, max_id = get_label_stats(merged_slots, infold + f'/{mode}_slot_stats.tsv')
logging.info(f'Total Number of Intents: {total_intents}')
logging.info(f'Intent Label Frequencies: {intent_label_freq}')
logging.info(f'Total Number of Slots: {slots_total}')
logging.info(f'Slots Label Frequencies: {slots_label_freq}')
if mode == 'train':
intent_weights_dict = get_freq_weights(intent_label_freq)
logging.info(f'Intent Weights: {intent_weights_dict}')
slot_weights_dict = get_freq_weights(slots_label_freq)
logging.info(f'Slot Weights: {slot_weights_dict}')
self.intent_weights = fill_class_weights(intent_weights_dict, self.num_intents - 1)
self.slot_weights = fill_class_weights(slot_weights_dict, self.num_slots - 1)
if pad_label != -1:
self.pad_label = pad_label
else:
if none_slot_label not in self.slots_label_ids:
raise ValueError(f'none_slot_label {none_slot_label} not ' f'found in {self.slot_dict_file}.')
self.pad_label = self.slots_label_ids[none_slot_label]
@staticmethod
def label2idx(file):
lines = open(file, 'r').readlines()
lines = [line.strip() for line in lines if line.strip()]
labels = {lines[i]: i for i in range(len(lines))}
return labels
@staticmethod
def intent_slot_dicts(data_dir):
'''
Return Intent and slot dictionaries
'''
intent_dict_file = data_dir + '/dict.intents.csv'
slot_dict_file = data_dir + '/dict.slots.csv'
intents_labels = open(intent_dict_file, 'r').readlines()
intents_labels = [line.strip() for line in intents_labels if line.strip()]
slots_labels = open(slot_dict_file, 'r').readlines()
slots_labels = [line.strip() for line in slots_labels if line.strip()]
return intents_labels, slots_labels
@@ -0,0 +1,20 @@
# 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.
from nemo.collections.nlp.data.language_modeling.l2r_lm_dataset import L2RLanguageModelingDataset
from nemo.collections.nlp.data.language_modeling.lm_bert_dataset import (
BertPretrainingDataset,
BertPretrainingPreprocessedDataloader,
)
from nemo.collections.nlp.data.language_modeling.sentence_dataset import SentenceDataset, TarredSentenceDataset
@@ -0,0 +1,240 @@
# 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.
import io
import json
from typing import Optional
import braceexpand
import numpy as np
import webdataset as wd
from torch.utils.data import Dataset, IterableDataset
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.data_utils import dataset_to_ids
from nemo.utils import logging
__all__ = ['L2RLanguageModelingDataset', 'TarredL2RLanguageModelingDataset']
class L2RLanguageModelingDataset(Dataset):
"""
Dataset for training and evaluating left-to-right language models.
Args:
tokenizer: tokenizer, such as WordTokenizer or CharTokenizer
dataset: path to data
max_seq_length: maximum sequence length (in tokens) of input tensors
batch_step: distance (in tokens) between two successive sequences of
the text. By default, it is equal to max_seq_length which corresponds
to splitting text into disjoint segments covering full dataset
use_cache: bool value, defaults to False. Determines whether the preprocessed,
tokenized dataset should be cached into a pickle file. If true, cache is saved
at the path provided in `dataset`.
"""
def __init__(
self,
tokenizer: TokenizerSpec,
dataset: str,
max_seq_length: Optional[int] = 512,
batch_step: Optional[int] = None,
use_cache: bool = False,
):
self.tokenizer = tokenizer
self.max_seq_length = max_seq_length
self.batch_step = batch_step or self.max_seq_length
ids = dataset_to_ids(dataset, tokenizer, cache_ids=use_cache, add_bos_eos=False)
self.ids = np.array([j for i in ids for j in i])
def __len__(self):
return (len(self.ids) - self.max_seq_length) // self.batch_step
def __getitem__(self, idx):
left = idx * self.batch_step
right = left + self.max_seq_length
src_ids = self.ids[left:right]
labels = self.ids[left + 1 : right + 1]
src_mask = (src_ids != self.tokenizer.pad_id).astype(np.float32)
return src_ids, src_mask, labels
class TarredL2RLanguageModelingDataset(IterableDataset):
"""
A similar Dataset to the L2RLanguageModelingDataset, but which loads tarred tokenized numpy files.
Accepts a single JSON metadata manifest file as well as the path(s) to the tarball(s) containing the wav files.
The manifest should contain information such as the number of shards, the number of tokens in the corpus,
and the number of tokens contained within each shard of the tarfile(s).
Valid formats for the text_tar_filepaths argument include:
(1) a single string that can be brace-expanded, e.g. 'path/to/text.tar' or 'path/to/text_{1..100}.tar.gz', or
(2) a list of file paths that will not be brace-expanded, e.g. ['text_1.tar', 'text_2.tar', ...].
Note: For brace expansion in (1), there may be cases where `{x..y}` syntax cannot be used due to shell interference.
This occurs most commonly inside SLURM scripts. Therefore we provide a few equivalent replacements.
Supported opening braces - { <=> (, [, < and the special tag _OP_.
Supported closing braces - } <=> ), ], > and the special tag _CL_.
For SLURM based tasks, we suggest the use of the special tags for ease of use.
See the WebDataset documentation for more information about accepted data and input formats.
If using multiple processes the number of shards should be divisible by the number of workers to ensure an
even split among workers. If it is not divisible, logging will give a warning but training will proceed.
Additionally, please note that the len() of this DataLayer is assumed to be the number of tokens
of the text data. An incorrect manifest length may lead to some DataLoader issues down the line.
Args:
text_tar_filepaths: Either a list of tokenized text tarball filepaths, or a
string (can be brace-expandable).
metadata_path (str): Path to the metadata manifest.
tokenizer: tokenizer, such as WordTokenizer or CharTokenizer
dataset: path to data
max_seq_length: maximum sequence length (in tokens) of input tensors
batch_step: distance (in tokens) between two successive sequences of
the text. By default, it is equal to max_seq_length which corresponds
to splitting text into disjoint segments covering full dataset
shuffle_n (int): How many samples to look ahead and load to be shuffled.
See WebDataset documentation for more details.
Defaults to 0.
shard_strategy (str): Tarred dataset shard distribution strategy chosen as a str value during ddp.
- `scatter`: The default shard strategy applied by WebDataset, where each node gets
a unique set of shards, which are permanently pre-allocated and never changed at runtime.
- `replicate`: Optional shard strategy, where each node gets all of the set of shards
available in the tarred dataset, which are permanently pre-allocated and never changed at runtime.
The benefit of replication is that it allows each node to sample data points from the entire
dataset independently of other nodes, and reduces dependence on value of `shuffle_n`.
Note: Replicated strategy allows every node to sample the entire set of available tarfiles,
and therefore more than one node may sample the same tarfile, and even sample the same
data points! As such, there is no assured guarantee that all samples in the dataset will be
sampled at least once during 1 epoch.
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
world_size (int): Total number of processes, used for partitioning shards. Defaults to 0.
"""
def __init__(
self,
text_tar_filepaths: str,
metadata_path: str,
tokenizer,
max_seq_length: int = 512,
batch_step: int = None,
shuffle_n: int = 1,
shard_strategy: str = "scatter",
global_rank: int = 0,
world_size: int = 0,
):
super(TarredL2RLanguageModelingDataset, self).__init__()
self.tokenizer = tokenizer
self.max_seq_length = max_seq_length
self.batch_step = batch_step or self.max_seq_length
valid_shard_strategies = ['scatter', 'replicate']
if shard_strategy not in valid_shard_strategies:
raise ValueError(f"`shard_strategy` must be one of {valid_shard_strategies}")
with open(metadata_path, 'r') as f:
metadata = json.load(f)
self.metadata = metadata
if isinstance(text_tar_filepaths, str):
# Replace '(', '[', '<' and '_OP_' with '{'
brace_keys_open = ['(', '[', '<', '_OP_']
for bkey in brace_keys_open:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "{")
# Replace ')', ']', '>' and '_CL_' with '}'
brace_keys_close = [')', ']', '>', '_CL_']
for bkey in brace_keys_close:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "}")
if isinstance(text_tar_filepaths, str):
# Brace expand
text_tar_filepaths = list(braceexpand.braceexpand(text_tar_filepaths))
if shard_strategy == 'scatter':
logging.info("All tarred dataset shards will be scattered evenly across all nodes.")
if len(text_tar_filepaths) % world_size != 0:
logging.warning(
f"Number of shards in tarred dataset ({len(text_tar_filepaths)}) is not divisible "
f"by number of distributed workers ({world_size})."
)
begin_idx = (len(text_tar_filepaths) // world_size) * global_rank
end_idx = begin_idx + (len(text_tar_filepaths) // world_size)
text_tar_filepaths = text_tar_filepaths[begin_idx:end_idx]
logging.info(
"Partitioning tarred dataset: process (%d) taking shards [%d, %d)", global_rank, begin_idx, end_idx
)
elif shard_strategy == 'replicate':
logging.info("All tarred dataset shards will be replicated across all nodes.")
else:
raise ValueError(f"Invalid shard strategy ! Allowed values are : {valid_shard_strategies}")
self.tarpath = text_tar_filepaths
# Put together WebDataset
self._dataset = wd.WebDataset(urls=text_tar_filepaths, nodesplitter=None)
if shuffle_n > 0:
self._dataset = self._dataset.shuffle(shuffle_n)
else:
logging.info("WebDataset will not shuffle files within the tar files.")
self._dataset = self._dataset.rename(npy='npy', key='__key__').to_tuple('npy', 'key').map(f=self._build_sample)
def _build_sample(self, tup):
# Load file
npy, filepath = tup
npy = io.BytesIO(npy)
data = np.load(npy) # loads np.int64 vector
npy.close()
# Select random contiguous subsegment
idx = np.random.randint(0, (len(data) - self.max_seq_length) // self.batch_step)
# Slice of data chunk
left = idx * self.batch_step
right = left + self.max_seq_length
data = data[left : right + 1]
# Create batch
src_ids = data[:-1]
labels = data[1:]
src_mask = (src_ids != self.tokenizer.pad_id).astype(np.float32)
return src_ids, src_mask, labels
def __iter__(self):
# We need to wrap an infinite generator since the actual files
# within the tar files contains large chunks of contiguous data.
# This prevents PTL from early exiting the train loop after exhausting
# all of the files in one iteration (though the actual dataset is many
# times larger due to each file containing a large chunk of data).
dl_iter = iter(self._dataset)
while True:
try:
batch = next(dl_iter)
yield batch
except StopIteration:
dl_iter = iter(self._dataset)
continue
def __len__(self):
return (self.metadata['num_text'] - self.max_seq_length) // self.batch_step
@@ -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.
import array
import os
import pickle
import random
from typing import Dict, List, Optional
import h5py
import numpy as np
from torch.utils.data import DataLoader, DistributedSampler
from tqdm import tqdm
from nemo.collections.nlp.data.data_utils.data_preprocessing import find_newlines, load_data_indices
from nemo.core.classes import Dataset
__all__ = ['BertPretrainingDataset', 'BertPretrainingPreprocessedDataloader']
def load_h5(input_file: str):
return h5py.File(input_file, "r")
class BertPretrainingDataset(Dataset):
"""
Dataset for bert pretraining when using data preprocessing including tokenization
"""
def __init__(
self,
tokenizer: object,
data_file: str,
max_seq_length: Optional[int] = 128,
mask_prob: Optional[float] = 0.15,
short_seq_prob: Optional[float] = 0.1,
seq_a_ratio: Optional[float] = 0.6,
sentence_idx_file: Optional[str] = None,
):
"""
Args:
tokenizer: tokenizer
data_file: path to data
max_seq_length: maximum sequence length of input tensors
mask_probability: proability to mask token
short_seq_prob: probability to create a sequence shorter than max_seq_length
seq_a_ratio: ratio between lengths of first and second sequence
sentence_idx_file: sentence indices file for caching
"""
self.tokenizer = tokenizer
# Loading enormous datasets into RAM isn't always feasible -- for
# example, the pubmed corpus is 200+ GB, which doesn't fit into RAM on
# most computers. To get around this, we store the indices of newlines
# in each file so we can seek to and retrieve sentences immediately
# from main memory when needed during training.
# Try and load sentence indices file if already exists
sentence_indices, sentence_idx_file, data_dir = load_data_indices(
sentence_idx_file, data_file, "sentence_indices"
)
# If sentence indices file doesn't exists, generate and store sentence indices
if sentence_indices is None:
sentence_indices = {}
filenames = [data_file]
for filename in tqdm(filenames):
with open(filename, "rb") as f:
contents = f.read()
newline_indices = find_newlines(contents)
if os.path.isdir(data_dir):
# Only keep the parts of the filepath that are invariant to
# the dataset's location on disk
filename = os.path.basename(filename)
# In python, arrays are much more space-efficient than lists
sentence_indices[filename] = array.array("I", newline_indices)
# Save sentence indices so we don't have to do this again
with open(sentence_idx_file, "wb") as f:
pickle.dump(sentence_indices, f)
corpus_size = 0
empty_files = []
# Find total number of newlines across entire corpus and remove files
# without any newlines
for filename in sentence_indices:
if len(sentence_indices[filename]) <= 1:
empty_files.append(filename)
else:
corpus_size += len(sentence_indices[filename])
for filename in empty_files:
del sentence_indices[filename]
self.corpus_size = corpus_size
self.dataset = data_dir
self.filenames = list(sentence_indices.keys())
self.mask_probability = mask_prob
self.max_seq_length = max_seq_length
self.sentence_indices = sentence_indices
self.vocab_size = self.tokenizer.vocab_size
self.short_seq_prob = short_seq_prob
self.seq_a_ratio = seq_a_ratio
def __len__(self):
return self.corpus_size
def __getitem__(self, idx: int, min_doc_length: Optional[int] = 16):
# Each sequence has three special tokens, as follows:
# tokenizer.cls_token <document a> tokenizer.sep_token <document b> tokenizer.eos_token
num_special_tokens = 3
max_num_tokens = self.max_seq_length - num_special_tokens
target_seq_length = max_num_tokens
if random.random() < self.short_seq_prob:
# TODO: maybe introduce an argument to control this.
target_seq_length = random.randint(2, max_num_tokens)
# prefer the seq_a to be slightly longer than seq_b, 0.6 by default
target_seq_length_a = int(round(target_seq_length * self.seq_a_ratio))
target_seq_length_b = target_seq_length - target_seq_length_a
def get_document(filepath, offset):
# Retrieve a specific line from a file and return as a document
if os.path.isdir(self.dataset):
filepath = os.path.join(self.dataset, filepath)
with open(filepath, "rb") as f:
f.seek(offset)
doc_text = f.readline()[:-1].decode("utf-8", errors="ignore")
document = self.tokenizer.text_to_ids(doc_text)
return document
def match_target_seq_length(
document: str, target_seq_length: int, filename: str, line_idx: int, sentence_indices: Dict[str, dict]
):
# If document is shorter than target sequence length,
# append the next line or take a random line as replacement.
num_lines = len(sentence_indices[filename])
while len(document) < target_seq_length:
if line_idx < (num_lines - 1):
# append the next line
line_idx += 1
else:
# current line is the last line, take a random one
line_idx = random.randrange(num_lines)
document = []
offset = sentence_indices[filename][line_idx]
document += get_document(filename, offset)
return document, line_idx
# Take sequence A from a random file and a random line
a_filename = random.choice(self.filenames)
a_line_idx = random.randrange(len(self.sentence_indices[a_filename]))
a_line_offset = self.sentence_indices[a_filename][a_line_idx]
a_document = get_document(a_filename, a_line_offset)
a_document, a_line_idx = match_target_seq_length(
a_document, target_seq_length_a, a_filename, a_line_idx, self.sentence_indices
)
is_last_line = a_line_idx >= (len(self.sentence_indices[a_filename]) - 1)
# About 50% of the time, B is a random sentence from the corpus
take_random_b = (random.random() < 0.5) or is_last_line
if take_random_b:
# This should rarely go for more than one iteration for large
# corpora. However, just to be careful, we try to make sure that
# the random document is not the same as the document
# we're processing.
for _ in range(10):
b_filename = random.choice(self.filenames)
b_line_idx = random.choice(range(len(self.sentence_indices[b_filename])))
if b_filename != a_filename:
break
else:
# Take another line from the same file
b_line_pos = self.sentence_indices[b_filename][b_line_idx]
a_line_pos = self.sentence_indices[a_filename][a_line_idx]
# TODO unclear about the following check
if abs(b_line_pos - a_line_pos) > max_num_tokens:
break
else:
pass
else:
b_filename = a_filename
b_line_idx = a_line_idx + 1
is_next = int(not take_random_b)
b_line_pos = self.sentence_indices[b_filename][b_line_idx]
b_document = get_document(b_filename, b_line_pos)
b_document, b_line_idx = match_target_seq_length(
b_document, target_seq_length_b, b_filename, b_line_idx, self.sentence_indices
)
def truncate_seq_pair(a, b, max_num_tokens):
# Truncates a pair of sequences to a maximum sequence length
while (len(a) + len(b)) > max_num_tokens:
# Truncate the longer sequence
if len(a) > len(b):
trunc_document = a
else:
trunc_document = b
if len(trunc_document) <= 1:
raise ValueError(
"Input text corpora probably too small. "
"Failed to truncate sequence pair to "
"maximum sequence legnth."
)
# Randomly truncate from the front or the back
if random.random() < 0.5:
del trunc_document[0]
else:
trunc_document.pop()
truncate_seq_pair(a_document, b_document, max_num_tokens)
output_ids = (
[self.tokenizer.cls_id] + a_document + [self.tokenizer.sep_id] + b_document + [self.tokenizer.eos_id]
)
input_ids, output_mask = self.mask_ids(output_ids)
input_mask = np.zeros(self.max_seq_length, dtype=np.long)
input_mask[: len(input_ids)] = 1
input_type_ids = np.zeros(self.max_seq_length, dtype=np.int)
input_type_ids[len(a_document) + 2 : len(output_ids) + 1] = 1
padding_length = max(0, self.max_seq_length - len(input_ids))
if padding_length > 0:
input_ids.extend([self.tokenizer.pad_id] * padding_length)
output_ids.extend([self.tokenizer.pad_id] * padding_length)
output_mask.extend([0] * padding_length)
# TODO: wrap the return value with () for consistent style.
return (
np.array(input_ids),
input_type_ids,
np.array(input_mask, dtype=np.long),
np.array(output_ids),
np.array(output_mask, dtype=np.float32),
is_next,
)
def mask_ids(self, ids: List[int]):
"""
Args:
ids: list of token ids representing a chunk of text
Returns:
masked_ids: list of input tokens with some of the entries masked
according to the following protocol from the original BERT paper:
each token is masked with a probability of 15% and is replaced with
1) the [MASK] token 80% of the time,
2) random token 10% of the time,
3) the same token 10% of the time.
output_mask: list of binary variables which indicate what tokens has
been masked (to calculate the loss function for these tokens only)
"""
# Whole-word masking by default, as it gives better performance.
cand_indexes = [[ids[0]]]
for tid in ids[1:]:
token = self.tokenizer.ids_to_tokens([tid])[0]
is_suffix = token.startswith('\u2581')
if is_suffix:
# group together with its previous token to form a whole-word
cand_indexes[-1].append(tid)
else:
cand_indexes.append([tid])
masked_ids, output_mask = [], []
mask_id = self.tokenizer.token_to_id("[MASK]")
for word_ids in cand_indexes:
is_special = (word_ids[0] == self.tokenizer.cls_id) or (word_ids[0] == self.tokenizer.sep_id)
if is_special or (random.random() > self.mask_probability):
output_mask.extend([0] * len(word_ids))
masked_ids.extend(word_ids)
else:
output_mask.extend([1] * len(word_ids))
p = random.random()
# for 80%, replace with mask
if p < 0.8:
masked_ids.extend([mask_id] * len(word_ids))
# for 10%, replace by a random token
elif p < 0.9:
for _ in word_ids:
# randomly select a valid word
random_word = random.randrange(self.vocab_size)
while random_word in (self.tokenizer.cls_id, self.tokenizer.sep_id):
random_word = random.randrange(self.vocab_size)
masked_ids.append(random_word)
# for 10%, use same token
else:
masked_ids.extend(word_ids)
return masked_ids, output_mask
class BertPretrainingPreprocessedDataset(Dataset):
"""
Dataset for already preprocessed data.
"""
def __init__(self, input_file: str, max_predictions_per_seq: int):
"""
Args:
input_file: data file in hdf5 format with preprocessed data in array format
max_predictions_per_seq: maximum number of masked tokens per sequence. Need to be consistent with data in input file.
"""
self.input_file = input_file
self.max_predictions_per_seq = max_predictions_per_seq
f = load_h5(input_file)
keys = [
'input_ids',
'input_mask',
'segment_ids',
'masked_lm_positions',
'masked_lm_ids',
'next_sentence_labels',
]
self.inputs = [np.asarray(f[key][:]) for key in keys]
f.close()
def __len__(self):
'Denotes the total number of samples'
return len(self.inputs[0])
def __getitem__(self, index: int):
[input_ids, input_mask, segment_ids, masked_lm_positions, masked_lm_ids, next_sentence_labels] = [
input[index].astype(np.int64) for input in self.inputs
]
output_mask = np.zeros_like(input_ids)
output_ids = input_ids.copy()
index = self.max_predictions_per_seq
padded_mask_indices = (masked_lm_positions == 0).nonzero()
if len(padded_mask_indices[0]) != 0:
index = padded_mask_indices[0][0]
output_mask[masked_lm_positions[:index]] = 1.0
output_ids[masked_lm_positions[:index]] = masked_lm_ids[:index]
# input_mask = np.asarray(input_mask, dtype=np.float32)
# output_mask = np.asarray(output_mask, dtype=np.float32)
return (input_ids, segment_ids, input_mask, output_ids, output_mask, next_sentence_labels)
class BertPretrainingPreprocessedDataloader(DataLoader):
"""
Dataloader for already preprocessed data in hdf5 files that is already in the format expected by BERT model.
"""
def __init__(self, data_files: List[str], max_predictions_per_seq: int, batch_size: int, seed: Optional[int] = 42):
"""
Args:
data_files: list of data files in hdf5 format with preprocessed data in array format
max_predictions_per_seq: maximum number of masked tokens per sequence. Need to be consistent with data in input file.
batch_size: batch size per gpu per forward pass
seed: seed to ensure each gpu process opens the same data file in each iteration
"""
super().__init__(None, batch_size=batch_size)
self.random = random.Random(seed)
self.data_files = data_files
self.max_predictions_per_seq = max_predictions_per_seq
# def __len__(self):
# return sum([len(load_h5(data_file)['input_ids']) for data_file in self.data_files])//(self.batch_size)
def __iter__(self):
self.random.shuffle(self.data_files)
for data_file in self.data_files:
train_data = BertPretrainingPreprocessedDataset(
input_file=data_file, max_predictions_per_seq=self.max_predictions_per_seq
)
train_sampler = DistributedSampler(train_data)
# print("---")
# print(os.getpid(), train_sampler.rank, train_sampler.num_replicas, train_sampler.num_samples)
# print("---")
train_dataloader = DataLoader(
dataset=train_data, sampler=train_sampler, batch_size=self.batch_size, shuffle=False,
)
for x in train_dataloader:
yield x
@@ -0,0 +1,273 @@
# 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.
"""Pytorch Dataset with sentences packed into batches by length."""
import io
import json
import logging
import pickle
from collections import OrderedDict
from typing import Any
import braceexpand
import numpy as np
import webdataset as wd
from torch.utils.data import IterableDataset
from nemo.collections.nlp.data.data_utils.data_preprocessing import dataset_to_ids
from nemo.core import Dataset
__all__ = ['SentenceDataset', 'TarredSentenceDataset']
class SentenceDataset(Dataset):
def __init__(
self,
tokenizer: Any,
dataset: Any,
tokens_in_batch: int = 1024,
clean: bool = False,
cache_ids: bool = False,
max_seq_length: int = 512,
min_seq_length: int = 1,
):
self.tokenizer = tokenizer
self.tokens_in_batch = tokens_in_batch
ids = dataset_to_ids(dataset, tokenizer, cache_ids=cache_ids)
if clean:
ids = self.clean(ids, max_tokens=max_seq_length, min_tokens=min_seq_length)
self.batch_sent_ids, self.batch_elem_lengths = self.pack_data_into_batches(ids)
self.batches = self.pad_batches(ids)
def __len__(self):
return len(self.batches)
def __getitem__(self, idx):
ids = self.batches[idx]
mask = (ids != self.tokenizer.pad_id).astype(np.int32)
return ids, mask
def pad_batches(self, ids):
"""
Augments source and target ids in the batches with padding symbol
to make the lengths of all sentences in the batches equal.
"""
batches = []
for batch_elem_len, batch_sent_ids in zip(self.batch_elem_lengths, self.batch_sent_ids):
batch = self.tokenizer.pad_id * np.ones((len(batch_sent_ids), batch_elem_len), dtype=np.int)
for i, sentence_idx in enumerate(batch_sent_ids):
batch[i][: len(ids[sentence_idx])] = ids[sentence_idx]
batches.append(batch)
return batches
def pack_data_into_batches(self, ids):
"""
Takes two lists of source and target sentences, sorts them, and packs
into batches to minimize the use of padding tokens. Returns a list of
batches where each batch contains indices of sentences included into it
"""
# create buckets sorted by the number of src tokens
# each bucket is also sorted by the number of tgt tokens
buckets = {}
for i, line_ids in enumerate(ids):
len_ = len(line_ids)
if len_ not in buckets:
buckets[len_] = [i]
else:
buckets[len_].append(i)
for b_idx in buckets:
buckets[b_idx] = sorted(buckets[b_idx])
buckets = OrderedDict(sorted(buckets.items()))
batches = []
batch_elem_lengths = []
curr_batch = []
len_of_longest_sent = 0
for sent_len, bucket in buckets.items():
for sent_i in bucket:
if sent_len * (len(curr_batch) + 1) > self.tokens_in_batch:
if not curr_batch:
raise ValueError(
f"The limitation on number of tokens in batch {self.tokens_in_batch} is too strong."
f"Several sentences contain {sent_len} tokens."
)
batches.append(curr_batch)
batch_elem_lengths.append(sent_len)
curr_batch = []
curr_batch.append(sent_i)
len_of_longest_sent = sent_len
if curr_batch:
batches.append(curr_batch)
batch_elem_lengths.append(len_of_longest_sent)
return batches, batch_elem_lengths
def clean(self, ids, max_tokens=None, min_tokens=None):
"""
Cleans source and target sentences to get rid of noisy data.
Specifically, a pair of sentences is removed if
-- either source or target is longer than *max_tokens*
-- either source or target is shorter than *min_tokens*
-- absolute difference between source and target is larger than
*max_tokens_diff*
-- one sentence is *max_tokens_ratio* times longer than the other
"""
ids_ = []
for i in range(len(ids)):
len_ = len(ids[i])
if (max_tokens is not None and len_ > max_tokens) or (min_tokens is not None and len_ < min_tokens):
continue
ids_.append(ids[i])
return ids_
class TarredSentenceDataset(IterableDataset):
"""
A similar Dataset to the SentenceDataset, but which loads tarred tokenized pickle files.
Accepts a single JSON metadata file containing the total number of batches
as well as the path(s) to the tarball(s) containing the wav files.
Valid formats for the text_tar_filepaths argument include:
(1) a single string that can be brace-expanded, e.g. 'path/to/text.tar' or 'path/to/text_{1..100}.tar.gz', or
(2) a list of file paths that will not be brace-expanded, e.g. ['text_1.tar', 'text_2.tar', ...].
Note: For brace expansion in (1), there may be cases where `{x..y}` syntax cannot be used due to shell interference.
This occurs most commonly inside SLURM scripts. Therefore we provide a few equivalent replacements.
Supported opening braces - { <=> (, [, < and the special tag _OP_.
Supported closing braces - } <=> ), ], > and the special tag _CL_.
For SLURM based tasks, we suggest the use of the special tags for ease of use.
See the WebDataset documentation for more information about accepted data and input formats.
If using multiple processes the number of shards should be divisible by the number of workers to ensure an
even split among workers. If it is not divisible, logging will give a warning but training will proceed.
Additionally, please note that the len() of this DataLayer is assumed to be the number of tokens
of the text data. An incorrect manifest length may lead to some DataLoader issues down the line.
Args:
text_tar_filepaths: Either a list of tokenized text tarball filepaths, or a
string (can be brace-expandable).
metadata_path (str): Path to the metadata manifest.
encoder_tokenizer: Autokenizer wrapped BPE tokenizer model, such as YTTM
decoder_tokenizer: Autokenizer wrapped BPE tokenizer model, such as YTTM
shuffle_n (int): How many samples to look ahead and load to be shuffled.
See WebDataset documentation for more details.
Defaults to 0.
shard_strategy (str): Tarred dataset shard distribution strategy chosen as a str value during ddp.
- `scatter`: The default shard strategy applied by WebDataset, where each node gets
a unique set of shards, which are permanently pre-allocated and never changed at runtime.
- `replicate`: Optional shard strategy, where each node gets all of the set of shards
available in the tarred dataset, which are permanently pre-allocated and never changed at runtime.
The benefit of replication is that it allows each node to sample data points from the entire
dataset independently of other nodes, and reduces dependence on value of `shuffle_n`.
Note: Replicated strategy allows every node to sample the entire set of available tarfiles,
and therefore more than one node may sample the same tarfile, and even sample the same
data points! As such, there is no assured guarantee that all samples in the dataset will be
sampled at least once during 1 epoch.
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
world_size (int): Total number of processes, used for partitioning shards. Defaults to 0.
reverse_lang_direction (bool): When True, swaps the source and target directions when returning minibatches.
"""
def __init__(
self,
text_tar_filepaths: str,
metadata_path: str,
tokenizer: str,
shuffle_n: int = 1,
shard_strategy: str = "scatter",
global_rank: int = 0,
world_size: int = 0,
):
super(TarredSentenceDataset, self).__init__()
self.tokenizer = tokenizer
self.pad_id = tokenizer.pad_id
valid_shard_strategies = ['scatter', 'replicate']
if shard_strategy not in valid_shard_strategies:
raise ValueError(f"`shard_strategy` must be one of {valid_shard_strategies}")
with open(metadata_path, 'r') as f:
metadata = json.load(f)
self.metadata = metadata
if isinstance(text_tar_filepaths, str):
# Replace '(', '[', '<' and '_OP_' with '{'
brace_keys_open = ['(', '[', '<', '_OP_']
for bkey in brace_keys_open:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "{")
# Replace ')', ']', '>' and '_CL_' with '}'
brace_keys_close = [')', ']', '>', '_CL_']
for bkey in brace_keys_close:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "}")
if isinstance(text_tar_filepaths, str):
# Brace expand
text_tar_filepaths = list(braceexpand.braceexpand(text_tar_filepaths))
if shard_strategy == 'scatter':
logging.info("All tarred dataset shards will be scattered evenly across all nodes.")
if len(text_tar_filepaths) % world_size != 0:
logging.warning(
f"Number of shards in tarred dataset ({len(text_tar_filepaths)}) is not divisible "
f"by number of distributed workers ({world_size})."
)
begin_idx = (len(text_tar_filepaths) // world_size) * global_rank
end_idx = begin_idx + (len(text_tar_filepaths) // world_size)
logging.info('Begin Index : %d' % (begin_idx))
logging.info('End Index : %d' % (end_idx))
text_tar_filepaths = text_tar_filepaths[begin_idx:end_idx]
logging.info(
"Partitioning tarred dataset: process (%d) taking shards [%d, %d)", global_rank, begin_idx, end_idx
)
elif shard_strategy == 'replicate':
logging.info("All tarred dataset shards will be replicated across all nodes.")
else:
raise ValueError(f"Invalid shard strategy ! Allowed values are : {valid_shard_strategies}")
self.tarpath = text_tar_filepaths
# Put together WebDataset
self._dataset = wd.WebDataset(urls=text_tar_filepaths, nodesplitter=None)
if shuffle_n > 0:
self._dataset = self._dataset.shuffle(shuffle_n)
else:
logging.info("WebDataset will not shuffle files within the tar files.")
self._dataset = self._dataset.rename(pkl='pkl', key='__key__').to_tuple('pkl', 'key').map(f=self._build_sample)
def _build_sample(self, fname):
# Load file
pkl_file, _ = fname
pkl_file = io.BytesIO(pkl_file)
data = pickle.load(pkl_file) # loads np.int64 vector
pkl_file.close()
ids = data["src"]
mask = (ids != self.pad_id).astype(np.int32)
return ids, mask
def __iter__(self):
return self._dataset.__iter__()
def __len__(self):
return self.metadata['num_batches']
@@ -0,0 +1,18 @@
# 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.
from nemo.collections.nlp.data.machine_translation.machine_translation_dataset import (
TarredTranslationDataset,
TranslationDataset,
)
@@ -0,0 +1,445 @@
# 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.
"""Pytorch Dataset for training Neural Machine Translation."""
import io
import json
import pickle
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, List, Optional
import braceexpand
import numpy as np
import webdataset as wd
from torch.utils.data import IterableDataset
from nemo.collections.nlp.data.data_utils.data_preprocessing import dataset_to_ids
from nemo.core import Dataset
from nemo.utils import logging
__all__ = ['TranslationDataset', 'TarredTranslationDataset']
@dataclass
class TranslationDataConfig:
src_file_name: Optional[Any] = None # Any = str or List[str]
tgt_file_name: Optional[Any] = None # Any = str or List[str]
use_tarred_dataset: bool = False
tar_files: Optional[Any] = None # Any = str or List[str]
metadata_file: Optional[Any] = None # Any = str or List[str]
lines_per_dataset_fragment: Optional[int] = 1000000
num_batches_per_tarfile: Optional[int] = 1000
shard_strategy: Optional[str] = 'scatter'
tokens_in_batch: int = 512
clean: bool = False
max_seq_length: int = 512
min_seq_length: int = 1
cache_ids: bool = False
cache_data_per_node: bool = False
use_cache: bool = False
shuffle: bool = False
num_samples: int = -1
drop_last: bool = False
pin_memory: bool = False
num_workers: int = 8
reverse_lang_direction: bool = False
load_from_tarred_dataset: bool = False
metadata_path: Optional[str] = None
tar_shuffle_n: int = 100
n_preproc_jobs: int = -2
tar_file_prefix: str = 'parallel'
concat_sampling_technique: Optional[str] = 'temperature'
concat_sampling_temperature: Optional[int] = 5
concat_sampling_probabilities: Optional[List[float]] = None
class TranslationDataset(Dataset):
def __init__(
self,
dataset_src: str,
dataset_tgt: str,
tokens_in_batch: int = 1024,
clean: bool = False,
max_seq_length: int = 512,
min_seq_length: int = 1,
max_seq_length_diff: int = 512,
max_seq_length_ratio: int = 512,
cache_ids: bool = False,
cache_data_per_node: bool = False,
use_cache: bool = False,
reverse_lang_direction: bool = False,
prepend_id: int = None,
):
self.dataset_src = dataset_src
self.dataset_tgt = dataset_tgt
self.tokens_in_batch = tokens_in_batch
self.cache_ids = cache_ids
self.use_cache = use_cache
self.clean = clean
self.cache_data_per_node = cache_data_per_node
self.max_seq_length = max_seq_length
self.min_seq_length = min_seq_length
self.max_seq_length_diff = max_seq_length_diff
self.max_seq_length_ratio = max_seq_length_ratio
self.reverse_lang_direction = reverse_lang_direction
self.prepend_id = prepend_id
# deprecation warnings for cache_ids, use_cache, and cache_data_per_node
if self.cache_ids is True or self.use_cache is True or self.cache_data_per_node is True:
logging.warning(
'Deprecation warning. self.cache_ids, self.use_cache, and self.cache_data_per_node will be removed. Data caching to be done with tarred datasets moving forward.'
)
def batchify(self, tokenizer_src, tokenizer_tgt):
src_ids = dataset_to_ids(
self.dataset_src,
tokenizer_src,
cache_ids=self.cache_ids,
cache_data_per_node=self.cache_data_per_node,
use_cache=self.use_cache,
)
tgt_ids = dataset_to_ids(
self.dataset_tgt,
tokenizer_tgt,
cache_ids=self.cache_ids,
cache_data_per_node=self.cache_data_per_node,
use_cache=self.use_cache,
)
if self.clean:
src_ids, tgt_ids = self.clean_src_and_target(
src_ids,
tgt_ids,
max_tokens=self.max_seq_length,
min_tokens=self.min_seq_length,
max_tokens_diff=self.max_seq_length_diff,
max_tokens_ratio=self.max_seq_length_ratio,
)
self.src_pad_id = tokenizer_src.pad_id
self.tgt_pad_id = tokenizer_tgt.pad_id
self.batch_indices = self.pack_data_into_batches(src_ids, tgt_ids)
self.batches = self.pad_batches(src_ids, tgt_ids, self.batch_indices)
def __len__(self):
return len(self.batches)
def __getitem__(self, idx):
src_ids = self.batches[idx]["src"]
tgt = self.batches[idx]["tgt"]
if self.reverse_lang_direction:
src_ids, tgt = tgt, src_ids
labels = tgt[:, 1:]
tgt_ids = tgt[:, :-1]
if self.prepend_id:
src_ids = np.insert(src_ids, 0, self.prepend_id, axis=-1)
src_mask = (src_ids != self.src_pad_id).astype(np.int32)
tgt_mask = (tgt_ids != self.tgt_pad_id).astype(np.int32)
return src_ids, src_mask, tgt_ids, tgt_mask, labels
def pad_batches(self, src_ids, tgt_ids, batch_indices):
"""
Augments source and target ids in the batches with padding symbol
to make the lengths of all sentences in the batches equal.
"""
batches = {}
for batch_idx, b in enumerate(batch_indices):
src_len = max([len(src_ids[i]) for i in b])
tgt_len = max([len(tgt_ids[i]) for i in b])
src_ids_ = self.src_pad_id * np.ones((len(b), src_len), dtype=np.int)
tgt_ids_ = self.tgt_pad_id * np.ones((len(b), tgt_len), dtype=np.int)
for i, sentence_idx in enumerate(b):
src_ids_[i][: len(src_ids[sentence_idx])] = src_ids[sentence_idx]
tgt_ids_[i][: len(tgt_ids[sentence_idx])] = tgt_ids[sentence_idx]
batches[batch_idx] = {"src": src_ids_, "tgt": tgt_ids_}
return batches
def pack_data_into_batches(self, src_ids, tgt_ids):
"""
Takes two lists of source and target sentences, sorts them, and packs
into batches to minimize the use of padding tokens. Returns a list of
batches where each batch contains indices of sentences included into it
"""
# create buckets sorted by the number of src tokens
# each bucket is also sorted by the number of tgt tokens
buckets = {}
for i, src_id in enumerate(src_ids):
src_len, tgt_len = len(src_id), len(tgt_ids[i])
if src_len not in buckets:
buckets[src_len] = [(tgt_len, i)]
else:
buckets[src_len].append((tgt_len, i))
for b_idx in buckets:
buckets[b_idx] = sorted(buckets[b_idx])
buckets = OrderedDict(sorted(buckets.items()))
indices = list(buckets.keys())
batches = [[]]
num_batches = 0
batch_size = 0
i = 0
src_len = 0
tgt_len = 0
while i < len(buckets):
while buckets[indices[i]]:
i_src = max(src_len, indices[i])
i_tgt = max(tgt_len, buckets[indices[i]][0][0])
try:
ip1_src = max(src_len, indices[i + 1])
ip1_tgt = max(tgt_len, buckets[indices[i + 1]][0][0])
except IndexError:
ip1_src = i_src + 1
ip1_tgt = i_tgt + 1
if i_src + i_tgt <= ip1_src + ip1_tgt:
src_len = i_src
tgt_len = i_tgt
_, idx = buckets[indices[i]].pop(0)
else:
src_len = ip1_src
tgt_len = ip1_tgt
_, idx = buckets[indices[i + 1]].pop(0)
batches[num_batches].append(idx)
batch_size += 1
if batch_size * (src_len + tgt_len) > self.tokens_in_batch:
num_examples_to_split = len(batches[num_batches])
batches_to_evict = 8 * ((num_examples_to_split - 1) // 8)
if batches_to_evict == 0:
batches_to_evict = num_examples_to_split
batches.append(batches[num_batches][batches_to_evict:])
batches[num_batches] = batches[num_batches][:batches_to_evict]
batch_size = num_examples_to_split - batches_to_evict
num_batches += 1
if batch_size > 0:
src_len = max([len(src_ids[j]) for j in batches[num_batches]])
tgt_len = max([len(tgt_ids[j]) for j in batches[num_batches]])
else:
src_len = 0
tgt_len = 0
break
if not buckets[indices[i]]:
i = i + 1
if not batches[-1]:
batches.pop(-1)
return batches
def clean_src_and_target(
self,
src_ids,
tgt_ids,
max_tokens=None,
min_tokens=None,
max_tokens_diff=None,
max_tokens_ratio=None,
filter_equal_src_and_dest=False,
):
"""
Cleans source and target sentences to get rid of noisy data.
Specifically, a pair of sentences is removed if
-- either source or target is longer than *max_tokens*
-- either source or target is shorter than *min_tokens*
-- absolute difference between source and target is larger than
*max_tokens_diff*
-- one sentence is *max_tokens_ratio* times longer than the other
"""
if len(src_ids) != len(tgt_ids):
raise ValueError("Source and target corpora have different lengths!")
src_ids_, tgt_ids_ = [], []
for i in range(len(src_ids)):
src_len, tgt_len = len(src_ids[i]), len(tgt_ids[i])
if (
(max_tokens is not None and (src_len > max_tokens or tgt_len > max_tokens))
or (min_tokens is not None and (src_len < min_tokens or tgt_len < min_tokens))
or (filter_equal_src_and_dest and src_ids[i] == tgt_ids[i])
or (max_tokens_diff is not None and np.abs(src_len - tgt_len) > max_tokens_diff)
):
continue
if max_tokens_ratio is not None:
ratio = max(src_len - 2, 1) / max(tgt_len - 2, 1)
if ratio > max_tokens_ratio or ratio < (1 / max_tokens_ratio):
continue
src_ids_.append(src_ids[i])
tgt_ids_.append(tgt_ids[i])
return src_ids_, tgt_ids_
class TarredTranslationDataset(IterableDataset):
"""
A similar Dataset to the TranslationDataset, but which loads tarred tokenized pickle files.
Accepts a single JSON metadata file containing the total number of batches
as well as the path(s) to the tarball(s) containing the pickled parallel dataset batch files.
Valid formats for the text_tar_filepaths argument include:
(1) a single string that can be brace-expanded, e.g. 'path/to/text.tar' or 'path/to/text_{1..100}.tar', or
(2) a list of file paths that will not be brace-expanded, e.g. ['text_1.tar', 'text_2.tar', ...].
Note: For brace expansion in (1), there may be cases where `{x..y}` syntax cannot be used due to shell interference.
This occurs most commonly inside SLURM scripts. Therefore we provide a few equivalent replacements.
Supported opening braces - { <=> (, [, < and the special tag _OP_.
Supported closing braces - } <=> ), ], > and the special tag _CL_.
For SLURM based tasks, we suggest the use of the special tags for ease of use.
See the WebDataset documentation for more information about accepted data and input formats.
If using multiple processes the number of shards should be divisible by the number of workers to ensure an
even split among workers. If it is not divisible, logging will give a warning but training will proceed.
Additionally, please note that the len() of this DataLayer is assumed to be the number of tokens
of the text data. An incorrect manifest length may lead to some DataLoader issues down the line.
Args:
text_tar_filepaths: Either a list of tokenized text tarball filepaths, or a
string (can be brace-expandable).
metadata_path (str): Path to the metadata manifest.
encoder_tokenizer: Autokenizer wrapped BPE tokenizer model, such as YTTM
decoder_tokenizer: Autokenizer wrapped BPE tokenizer model, such as YTTM
shuffle_n (int): How many samples to look ahead and load to be shuffled.
See WebDataset documentation for more details.
Defaults to 0.
shard_strategy (str): Tarred dataset shard distribution strategy chosen as a str value during ddp.
- `scatter`: The default shard strategy applied by WebDataset, where each node gets
a unique set of shards, which are permanently pre-allocated and never changed at runtime.
- `replicate`: Optional shard strategy, where each node gets all of the set of shards
available in the tarred dataset, which are permanently pre-allocated and never changed at runtime.
The benefit of replication is that it allows each node to sample data points from the entire
dataset independently of other nodes, and reduces dependence on value of `shuffle_n`.
Note: Replicated strategy allows every node to sample the entire set of available tarfiles,
and therefore more than one node may sample the same tarfile, and even sample the same
data points! As such, there is no assured guarantee that all samples in the dataset will be
sampled at least once during 1 epoch.
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
world_size (int): Total number of processes, used for partitioning shards. Defaults to 1.
reverse_lang_direction (bool): When True, swaps the source and target directions when returning minibatches.
prepend_id (int): Prepends the specificed token id to the start of every source sentence. Defaults to None.
"""
def __init__(
self,
text_tar_filepaths: str,
metadata_path: str,
encoder_tokenizer: str,
decoder_tokenizer: str,
shuffle_n: int = 1,
shard_strategy: str = "scatter",
global_rank: int = 0,
world_size: int = 1,
reverse_lang_direction: bool = False,
prepend_id: int = None,
):
super(TarredTranslationDataset, self).__init__()
self.encoder_tokenizer = encoder_tokenizer
self.decoder_tokenizer = decoder_tokenizer
self.reverse_lang_direction = reverse_lang_direction
self.src_pad_id = encoder_tokenizer.pad_id
self.tgt_pad_id = decoder_tokenizer.pad_id
self.prepend_id = prepend_id
valid_shard_strategies = ['scatter', 'replicate']
if shard_strategy not in valid_shard_strategies:
raise ValueError(f"`shard_strategy` must be one of {valid_shard_strategies}")
with open(metadata_path, 'r') as f:
metadata = json.load(f)
self.metadata = metadata
if isinstance(text_tar_filepaths, str):
# Replace '(', '[', '<' and '_OP_' with '{'
brace_keys_open = ['(', '[', '<', '_OP_']
for bkey in brace_keys_open:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "{")
# Replace ')', ']', '>' and '_CL_' with '}'
brace_keys_close = [')', ']', '>', '_CL_']
for bkey in brace_keys_close:
if bkey in text_tar_filepaths:
text_tar_filepaths = text_tar_filepaths.replace(bkey, "}")
if isinstance(text_tar_filepaths, str):
# Brace expand
text_tar_filepaths = list(braceexpand.braceexpand(text_tar_filepaths))
if shard_strategy == 'scatter':
logging.info("All tarred dataset shards will be scattered evenly across all nodes.")
if len(text_tar_filepaths) % world_size != 0:
logging.warning(
f"Number of shards in tarred dataset ({len(text_tar_filepaths)}) is not divisible "
f"by number of distributed workers ({world_size})."
)
begin_idx = (len(text_tar_filepaths) // world_size) * global_rank
end_idx = begin_idx + (len(text_tar_filepaths) // world_size)
logging.info('Begin Index : %d' % (begin_idx))
logging.info('End Index : %d' % (end_idx))
text_tar_filepaths = text_tar_filepaths[begin_idx:end_idx]
logging.info(
"Partitioning tarred dataset: process (%d) taking shards [%d, %d)", global_rank, begin_idx, end_idx
)
self.length = self.metadata['num_batches'] // world_size
elif shard_strategy == 'replicate':
logging.info("All tarred dataset shards will be replicated across all nodes.")
self.length = self.metadata['num_batches']
else:
raise ValueError(f"Invalid shard strategy ! Allowed values are : {valid_shard_strategies}")
self.tarpath = text_tar_filepaths
# Put together WebDataset
self._dataset = wd.WebDataset(urls=text_tar_filepaths, nodesplitter=None)
if shuffle_n > 0:
self._dataset = self._dataset.shuffle(shuffle_n)
else:
logging.info("WebDataset will not shuffle files within the tar files.")
self._dataset = self._dataset.rename(pkl='pkl', key='__key__').to_tuple('pkl', 'key').map(f=self._build_sample)
def _build_sample(self, fname):
# Load file
pkl_file, _ = fname
pkl_file = io.BytesIO(pkl_file)
data = pickle.load(pkl_file) # loads np.int64 vector
pkl_file.close()
src_ids = data["src"]
tgt = data["tgt"]
if self.reverse_lang_direction:
src_ids, tgt = tgt, src_ids
labels = tgt[:, 1:]
tgt_ids = tgt[:, :-1]
if self.prepend_id:
src_ids = np.insert(src_ids, 0, self.prepend_id, axis=-1)
src_mask = (src_ids != self.src_pad_id).astype(np.int32)
tgt_mask = (tgt_ids != self.tgt_pad_id).astype(np.int32)
return src_ids, src_mask, tgt_ids, tgt_mask, labels
def __iter__(self):
return self._dataset.__iter__()
def __len__(self):
return self.length
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
# 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.
from nemo.collections.nlp.data.neural_machine_translation.neural_machine_translation_dataset import (
NeuralMachineTranslationDataset,
)
@@ -0,0 +1,146 @@
# Copyright (c) 2020, MeetKai Inc. All rights reserved.
# 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.
import os
from typing import Dict, List, Optional, Tuple
import numpy as np
from nemo.collections.common.tokenizers import AutoTokenizer
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, MaskType, NeuralType
__all__ = ["NeuralMachineTranslationDataset"]
class NeuralMachineTranslationDataset(Dataset):
"""A dataset class that converts raw data to a dataset that can be used by NeuralMachineTranslationModel.
Args:
filepath: .tsv file to sequence + label.
the first line is header (sentence [tab] label)
each line should be [sentence][tab][label]
encoder_tokenizer: encoder tokenizer object such as AutoTokenizer
decoder_tokenizer: decoder tokenizer object. If using BART or end to end model, set this to encoder_tokenizer
max_seq_length: max sequence length including bos and eos tokens
num_samples: number of samples you want to use for the dataset. If -1, use all dataset. Useful for testing.
convert_labels: if true, converts labels for masked lm and updates pad_id to -100
for hf masked loss
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
"input_ids": NeuralType(("B", "T"), ChannelType()),
"attention_mask": NeuralType(("B", "T"), MaskType()),
"decoder_input_ids": NeuralType(("B", "T"), ChannelType()),
"lm_labels": NeuralType(("B", "T"), ChannelType()),
}
def __init__(
self,
filepath: str,
encoder_tokenizer: AutoTokenizer,
decoder_tokenizer: AutoTokenizer,
encoder_add_special_tokens: bool,
decoder_add_special_tokens: bool,
max_seq_length: int,
num_samples: int = -1,
convert_labels: bool = False,
):
self.filepath = filepath
self.encoder_tokenizer = encoder_tokenizer
self.decoder_tokenizer = decoder_tokenizer
self.encoder_add_special_tokens = encoder_add_special_tokens
self.decoder_add_special_tokens = decoder_add_special_tokens
self.max_seq_length = max_seq_length
self.num_samples = num_samples
self.convert_labels = convert_labels
if num_samples == 0:
raise ValueError("num_samples has to be positive.", num_samples)
if self.max_seq_length and self.max_seq_length <= 2:
self.max_seq_length = None
if not os.path.exists(filepath):
raise FileNotFoundError(
f"{filepath} not found. The filepath must be set in train_ds.filepath and validation_ds.filepath."
)
with open(filepath) as f:
lines = f.readlines()[1:]
if num_samples > 0:
lines = lines[:num_samples]
input_ids, input_masks, label_ids = [], [], []
for line in lines:
try:
sentence, label = line.split("\t")
except ValueError:
raise ValueError("Each line of input file should contain the format [sentence][tab][label].")
ids, mask = self.text_to_ids(
sentence, tokenizer=encoder_tokenizer, add_special_tokens=encoder_add_special_tokens
)
input_ids.append(ids)
input_masks.append(mask)
label_ids.append(
self.text_to_ids(label, tokenizer=decoder_tokenizer, add_special_tokens=decoder_add_special_tokens)[0]
)
self.input_ids = np.asarray(input_ids)
self.input_masks = np.asarray(input_masks)
self.label_ids = np.asarray(label_ids)
def text_to_ids(
self, text: str, tokenizer: AutoTokenizer, add_special_tokens=False
) -> Tuple[List[int], List[int]]:
"""Converts text to ids. Truncates and adds padding."""
text_tokens = tokenizer.text_to_ids(text.strip())
num_special_tokens = 2 if add_special_tokens else 0
if self.max_seq_length and self.max_seq_length > num_special_tokens:
text_tokens = text_tokens[: self.max_seq_length - num_special_tokens]
if add_special_tokens:
text_tokens = [tokenizer.bos_id] + text_tokens + [tokenizer.eos_id]
mask = [1] * len(text_tokens)
if self.max_seq_length and self.max_seq_length > num_special_tokens:
pad_length = self.max_seq_length - len(text_tokens)
text_tokens += [tokenizer.pad_id] * pad_length
mask += [0] * pad_length
return text_tokens, mask
def __len__(self):
return len(self.input_ids)
def convert_label_ids(self, label_ids: List[int]) -> Tuple[List[int], List[int]]:
decoder_input_ids = label_ids[:-1]
lm_labels = label_ids[1:].copy()
lm_labels[label_ids[1:] == self.decoder_tokenizer.pad_id] = -100 # for huggingface masked lm loss
return decoder_input_ids, lm_labels
def __getitem__(self, idx):
if self.convert_labels:
decoder_input_ids, lm_labels = self.convert_label_ids(self.label_ids[idx])
else:
decoder_input_ids = self.label_ids[idx]
lm_labels = self.label_ids[idx]
return self.input_ids[idx], self.input_masks[idx], decoder_input_ids, lm_labels
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,443 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
import collections
import json
import os
import pickle
from typing import Dict, List, Optional
import numpy as np
import torch
from nemo.collections.common.parts.utils import _compute_softmax
from nemo.collections.nlp.data.question_answering_squad.qa_squad_processing import (
EVALUATION_MODE,
INFERENCE_MODE,
TRAINING_MODE,
SquadProcessor,
apply_no_ans_threshold,
convert_examples_to_features,
exact_match_score,
f1_score,
find_all_best_thresh,
get_best_indexes,
get_final_text,
make_eval_dict,
merge_eval,
normalize_answer,
)
from nemo.core.classes import Dataset
from nemo.utils import logging
__all__ = ['SquadDataset']
class SquadDataset(Dataset):
"""
Creates SQuAD dataset for Question Answering.
Args:
data_file (str): train.*.json eval.*.json or test.*.json.
tokenizer (obj): Tokenizer object, e.g. AutoTokenizer.
version_2_with_negative (bool): True if training should allow
unanswerable questions.
doc_stride (int): When splitting up a long document into chunks,
how much stride to take between chunks.
max_query_length (iny): All training files which have a duration less
than min_duration are dropped. Can't be used if the `utt2dur` file
does not exist. Defaults to None.
max_seq_length (int): All training files which have a duration more
than max_duration are dropped. Can't be used if the `utt2dur` file
does not exist. Defaults to None.
num_samples: number of samples you want to use for the dataset.
If -1, use all dataset. Useful for testing.
mode (str): Use TRAINING_MODE/EVALUATION_MODE/INFERENCE_MODE to define between
training, evaluation and inference dataset.
use_cache (bool): Caches preprocessed data for future usage
"""
def __init__(
self,
data_file: str,
tokenizer: object,
doc_stride: int,
max_query_length: int,
max_seq_length: int,
version_2_with_negative: bool,
num_samples: int,
mode: str,
use_cache: bool,
):
self.tokenizer = tokenizer
self.version_2_with_negative = version_2_with_negative
self.processor = SquadProcessor(data_file=data_file, mode=mode)
self.mode = mode
if mode not in [TRAINING_MODE, EVALUATION_MODE, INFERENCE_MODE]:
raise ValueError(
f"mode should be either {TRAINING_MODE}, {EVALUATION_MODE}, {INFERENCE_MODE} but got {mode}"
)
self.examples = self.processor.get_examples()
vocab_size = getattr(tokenizer, "vocab_size", 0)
cached_features_file = (
data_file
+ '_cache'
+ '_{}_{}_{}_{}_{}_{}_{}'.format(
mode,
tokenizer.name,
str(vocab_size),
str(max_seq_length),
str(doc_stride),
str(max_query_length),
str(num_samples),
)
)
# check number of samples. Should be either -1 not to limit or positive number
if num_samples == 0:
raise ValueError(
f"num_samples has to be positive or -1 (to use the entire dataset), however got {num_samples}."
)
elif num_samples > 0:
self.examples = self.examples[:num_samples]
if use_cache and os.path.exists(cached_features_file):
logging.info(f"loading from {cached_features_file}")
with open(cached_features_file, "rb") as reader:
self.features = pickle.load(reader)
else:
logging.info(f"Preprocessing data.")
self.features = convert_examples_to_features(
examples=self.examples,
tokenizer=tokenizer,
max_seq_length=max_seq_length,
doc_stride=doc_stride,
max_query_length=max_query_length,
has_groundtruth=mode != INFERENCE_MODE,
)
if use_cache:
master_device = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
if master_device:
logging.info(" Saving train features into cached file %s", cached_features_file)
with open(cached_features_file, "wb") as writer:
pickle.dump(self.features, writer)
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
feature = self.features[idx]
if self.mode == INFERENCE_MODE:
return (
np.array(feature.input_ids),
np.array(feature.segment_ids),
np.array(feature.input_mask),
np.array(feature.unique_id),
)
else:
return (
np.array(feature.input_ids),
np.array(feature.segment_ids),
np.array(feature.input_mask),
np.array(feature.unique_id),
np.array(feature.start_position),
np.array(feature.end_position),
)
def get_predictions(
self,
unique_ids: List[int],
start_logits: List[List[float]],
end_logits: List[List[float]],
n_best_size: int,
max_answer_length: int,
do_lower_case: bool,
version_2_with_negative: bool,
null_score_diff_threshold: float,
):
example_index_to_features = collections.defaultdict(list)
unique_id_to_pos = {}
for index, unique_id in enumerate(unique_ids):
unique_id_to_pos[unique_id] = index
for feature in self.features:
example_index_to_features[feature.example_index].append(feature)
_PrelimPrediction = collections.namedtuple(
"PrelimPrediction", ["feature_index", "start_index", "end_index", "start_logit", "end_logit"]
)
all_predictions = collections.OrderedDict()
all_nbest_json = collections.OrderedDict()
scores_diff_json = collections.OrderedDict()
for (example_index, example) in enumerate(self.examples):
# finish this loop if we went through all batch examples
if example_index >= len(unique_ids):
break
features = example_index_to_features[example_index]
prelim_predictions = []
# keep track of the minimum score of null start+end of position 0
# large and positive
score_null = 1000000
# the paragraph slice with min null score
min_null_feature_index = 0
# start logit at the slice with min null score
null_start_logit = 0
# end logit at the slice with min null score
null_end_logit = 0
for (feature_index, feature) in enumerate(features):
pos = unique_id_to_pos[feature.unique_id]
start_indexes = get_best_indexes(start_logits[pos], n_best_size)
end_indexes = get_best_indexes(end_logits[pos], n_best_size)
# if we could have irrelevant answers,
# get the min score of irrelevant
if version_2_with_negative:
feature_null_score = start_logits[pos][0] + end_logits[pos][0]
if feature_null_score < score_null:
score_null = feature_null_score
min_null_feature_index = feature_index
null_start_logit = start_logits[pos][0]
null_end_logit = end_logits[pos][0]
for start_index in start_indexes:
for end_index in end_indexes:
# We could hypothetically create invalid predictions,
# e.g., predict that the start of the span is in the
# question. We throw out all invalid predictions.
if start_index >= len(feature.tokens):
continue
if end_index >= len(feature.tokens):
continue
if start_index not in feature.token_to_orig_map:
continue
if end_index not in feature.token_to_orig_map:
continue
if not feature.token_is_max_context.get(start_index, False):
continue
if end_index < start_index:
continue
length = end_index - start_index + 1
if length > max_answer_length:
continue
prelim_predictions.append(
_PrelimPrediction(
feature_index=feature_index,
start_index=start_index,
end_index=end_index,
start_logit=start_logits[pos][start_index],
end_logit=end_logits[pos][end_index],
)
)
if version_2_with_negative:
prelim_predictions.append(
_PrelimPrediction(
feature_index=min_null_feature_index,
start_index=0,
end_index=0,
start_logit=null_start_logit,
end_logit=null_end_logit,
)
)
prelim_predictions = sorted(prelim_predictions, key=lambda x: (x.start_logit + x.end_logit), reverse=True)
_NbestPrediction = collections.namedtuple("NbestPrediction", ["text", "start_logit", "end_logit"])
seen_predictions = {}
nbest = []
for pred in prelim_predictions:
if len(nbest) >= n_best_size:
break
feature = features[pred.feature_index]
if pred.start_index > 0: # this is a non-null prediction
tok_tokens = feature.tokens[pred.start_index : (pred.end_index + 1)]
orig_doc_start = feature.token_to_orig_map[pred.start_index]
orig_doc_end = feature.token_to_orig_map[pred.end_index]
orig_tokens = example.doc_tokens[orig_doc_start : (orig_doc_end + 1)]
tok_text = " ".join(tok_tokens)
# De-tokenize WordPieces that have been split off.
tok_text = tok_text.replace(" ##", "")
tok_text = tok_text.replace("##", "")
# Clean whitespace
tok_text = tok_text.strip()
tok_text = " ".join(tok_text.split())
orig_text = " ".join(orig_tokens)
final_text = get_final_text(tok_text, orig_text, do_lower_case)
if final_text in seen_predictions:
continue
seen_predictions[final_text] = True
else:
final_text = ""
seen_predictions[final_text] = True
nbest.append(_NbestPrediction(text=final_text, start_logit=pred.start_logit, end_logit=pred.end_logit))
# if we didn't include the empty option in the n-best, include it
if version_2_with_negative:
if "" not in seen_predictions:
nbest.append(_NbestPrediction(text="", start_logit=null_start_logit, end_logit=null_end_logit))
# In very rare edge cases we could only
# have single null pred. We just create a nonce prediction
# in this case to avoid failure.
if len(nbest) == 1:
nbest.insert(0, _NbestPrediction(text="empty", start_logit=0.0, end_logit=0.0))
# In very rare edge cases we could have no valid predictions. So we
# just create a nonce prediction in this case to avoid failure.
if not nbest:
nbest.append(_NbestPrediction(text="empty", start_logit=0.0, end_logit=0.0))
assert len(nbest) >= 1
total_scores = []
best_non_null_entry = None
for entry in nbest:
total_scores.append(entry.start_logit + entry.end_logit)
if not best_non_null_entry:
if entry.text:
best_non_null_entry = entry
probs = _compute_softmax(total_scores)
nbest_json = []
for (i, entry) in enumerate(nbest):
output = collections.OrderedDict()
output["question"] = example.question_text
output["text"] = entry.text
output["probability"] = probs[i]
output["start_logit"] = (
entry.start_logit if isinstance(entry.start_logit, float) else list(entry.start_logit)
)
output["end_logit"] = entry.end_logit if isinstance(entry.end_logit, float) else list(entry.end_logit)
nbest_json.append(output)
assert len(nbest_json) >= 1
if not version_2_with_negative:
all_predictions[example.qas_id] = nbest_json[0]["text"]
else:
# predict "" iff the null score -
# the score of best non-null > threshold
score_diff = score_null - best_non_null_entry.start_logit - (best_non_null_entry.end_logit)
scores_diff_json[example.qas_id] = score_diff
if score_diff > null_score_diff_threshold:
all_predictions[example.qas_id] = ""
else:
all_predictions[example.qas_id] = best_non_null_entry.text
all_nbest_json[example.qas_id] = nbest_json
return all_predictions, all_nbest_json, scores_diff_json
def evaluate_predictions(
self,
all_predictions: Dict[str, str],
no_answer_probs: Optional[float] = None,
no_answer_probability_threshold: float = 1.0,
):
qas_id_to_has_answer = {
example.qas_id: bool(example.answers) for example in self.examples[: len(all_predictions)]
}
has_answer_qids = [qas_id for qas_id, has_answer in qas_id_to_has_answer.items() if has_answer]
no_answer_qids = [qas_id for qas_id, has_answer in qas_id_to_has_answer.items() if not has_answer]
if no_answer_probs is None:
no_answer_probs = {k: 0.0 for k in all_predictions}
exact, f1 = self.get_raw_scores(all_predictions)
exact_threshold = apply_no_ans_threshold(
exact, no_answer_probs, qas_id_to_has_answer, no_answer_probability_threshold
)
f1_threshold = apply_no_ans_threshold(
f1, no_answer_probs, qas_id_to_has_answer, no_answer_probability_threshold
)
evaluation = make_eval_dict(exact_threshold, f1_threshold)
if has_answer_qids:
has_ans_eval = make_eval_dict(exact_threshold, f1_threshold, qid_list=has_answer_qids)
merge_eval(evaluation, has_ans_eval, "HasAns")
if no_answer_qids:
no_ans_eval = make_eval_dict(exact_threshold, f1_threshold, qid_list=no_answer_qids)
merge_eval(evaluation, no_ans_eval, "NoAns")
if no_answer_probs:
find_all_best_thresh(evaluation, all_predictions, exact, f1, no_answer_probs, qas_id_to_has_answer)
return evaluation["best_exact"], evaluation["best_f1"]
def get_raw_scores(self, preds: Dict[str, str]):
"""
Computes the exact and f1 scores from the examples
and the model predictions
"""
exact_scores = {}
f1_scores = {}
for example in self.examples:
qas_id = example.qas_id
gold_answers = [answer["text"] for answer in example.answers if normalize_answer(answer["text"])]
if not gold_answers:
# For unanswerable questions,
# only correct answer is empty string
gold_answers = [""]
if qas_id not in preds:
logging.warning("Missing prediction for %s" % qas_id)
continue
prediction = preds[qas_id]
exact_scores[qas_id] = max(exact_match_score(a, prediction) for a in gold_answers)
f1_scores[qas_id] = max(f1_score(a, prediction) for a in gold_answers)
return exact_scores, f1_scores
def evaluate(
self,
unique_ids: List[str],
start_logits: List[List[float]],
end_logits: List[List[float]],
n_best_size: int,
max_answer_length: int,
do_lower_case: bool,
version_2_with_negative: bool,
null_score_diff_threshold: float,
):
(all_predictions, all_nbest_json, scores_diff_json) = self.get_predictions(
unique_ids,
start_logits,
end_logits,
n_best_size,
max_answer_length,
do_lower_case,
version_2_with_negative,
null_score_diff_threshold,
)
exact_match, f1 = self.evaluate_predictions(all_predictions)
return exact_match, f1, all_predictions, all_nbest_json
@@ -0,0 +1,644 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
import collections
import json
from typing import Dict, List, Optional
from tqdm import tqdm
from transformers.models.bert.tokenization_bert import BasicTokenizer
from nemo.collections.nlp.data.data_utils import DataProcessor, is_whitespace, normalize_answer
from nemo.utils import logging
"""
Utility functions for Question Answering NLP tasks
Some parts of this code were adapted from the HuggingFace library at
https://github.com/huggingface/transformers
"""
TRAINING_MODE = "train"
EVALUATION_MODE = "eval"
INFERENCE_MODE = "infer"
def _get_tokens(s):
"""get normalized tokens"""
if not s:
return []
return normalize_answer(s).split()
def get_best_indexes(logits, n_best_size):
"""Get the n-best logits from a list."""
index_and_score = sorted(enumerate(logits), key=lambda x: x[1], reverse=True)
best_indexes = []
for i in range(len(index_and_score)):
if i >= n_best_size:
break
best_indexes.append(index_and_score[i][0])
return best_indexes
def get_final_text(pred_text: str, orig_text: str, do_lower_case: bool, verbose_logging: bool = False):
"""Project the tokenized prediction back to the original text.
When we created the data, we kept track of the alignment between original
(whitespace tokenized) tokens and our WordPiece tokenized tokens. So
now `orig_text` contains the span of our original text corresponding to
the span that we predicted.
However, `orig_text` may contain extra characters that we don't want in
our prediction.
For example, let's say:
pred_text = steve smith
orig_text = Steve Smith's
We don't want to return `orig_text` because it contains the extra "'s".
We don't want to return `pred_text` because it's already been normalized
(the SQuAD eval script also does punctuation stripping/lower casing but
our tokenizer does additional normalization like stripping accent
characters).
What we really want to return is "Steve Smith".
Therefore, we have to apply a semi-complicated alignment heuristic
between `pred_text` and `orig_text` to get a character-to-character
alignment. This can fail in certain cases in which case we just return
`orig_text`."""
def _strip_spaces(text):
ns_chars = []
ns_to_s_map = collections.OrderedDict()
for (i, c) in enumerate(text):
if c == " ":
continue
ns_to_s_map[len(ns_chars)] = i
ns_chars.append(c)
ns_text = "".join(ns_chars)
return ns_text, ns_to_s_map
# We first tokenize `orig_text`, strip whitespace from the result
# and `pred_text`, and check if they are the same length. If they are
# NOT the same length, the heuristic has failed. If they are the same
# length, we assume the characters are one-to-one aligned.
tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
tok_text = " ".join(tokenizer.tokenize(orig_text))
start_position = tok_text.find(pred_text)
if start_position == -1:
if verbose_logging:
logging.warning("Unable to find text: '%s' in '%s'" % (pred_text, orig_text))
return orig_text
end_position = start_position + len(pred_text) - 1
(orig_ns_text, orig_ns_to_s_map) = _strip_spaces(orig_text)
(tok_ns_text, tok_ns_to_s_map) = _strip_spaces(tok_text)
if len(orig_ns_text) != len(tok_ns_text):
if verbose_logging:
logging.warning(
"Length not equal after stripping spaces: '%s' vs '%s'", orig_ns_text, tok_ns_text,
)
return orig_text
# We then project the characters in `pred_text` back to `orig_text` using
# the character-to-character alignment.
tok_s_to_ns_map = {}
for (i, tok_index) in tok_ns_to_s_map.items():
tok_s_to_ns_map[tok_index] = i
orig_start_position = None
if start_position in tok_s_to_ns_map:
ns_start_position = tok_s_to_ns_map[start_position]
if ns_start_position in orig_ns_to_s_map:
orig_start_position = orig_ns_to_s_map[ns_start_position]
if orig_start_position is None:
if verbose_logging:
logging.warning("Couldn't map start position")
return orig_text
orig_end_position = None
if end_position in tok_s_to_ns_map:
ns_end_position = tok_s_to_ns_map[end_position]
if ns_end_position in orig_ns_to_s_map:
orig_end_position = orig_ns_to_s_map[ns_end_position]
if orig_end_position is None:
if verbose_logging:
logging.warning("Couldn't map end position")
return orig_text
output_text = orig_text[orig_start_position : (orig_end_position + 1)]
return output_text
def f1_score(prediction, ground_truth):
"""computes f1 score between prediction and ground truth"""
prediction_tokens = _get_tokens(prediction)
ground_truth_tokens = _get_tokens(ground_truth)
common = collections.Counter(prediction_tokens) & collections.Counter(ground_truth_tokens)
num_same = sum(common.values())
if len(ground_truth_tokens) == 0 or len(prediction_tokens) == 0:
# If either is no-answer, then F1 is 1 if they agree, 0 otherwise
return int(ground_truth_tokens == prediction_tokens)
if num_same == 0:
return 0
precision = 1.0 * num_same / len(prediction_tokens)
recall = 1.0 * num_same / len(ground_truth_tokens)
f1 = (2 * precision * recall) / (precision + recall)
return f1
def exact_match_score(prediction, ground_truth):
"""computes exact match between prediction and ground truth"""
return int(normalize_answer(prediction) == normalize_answer(ground_truth))
def apply_no_ans_threshold(scores, na_probs, qid_to_has_ans, na_prob_thresh):
"""Applies no answer threshhold"""
new_scores = {}
for qid, s in scores.items():
pred_na = na_probs[qid] > na_prob_thresh
if pred_na:
new_scores[qid] = float(not qid_to_has_ans[qid])
else:
new_scores[qid] = s
return new_scores
def make_eval_dict(exact_scores, f1_scores, qid_list=None):
"""returns dictionary with formatted evaluation scores"""
if not qid_list:
total = len(exact_scores)
return collections.OrderedDict(
[
("exact", 100.0 * sum(exact_scores.values()) / total),
("f1", 100.0 * sum(f1_scores.values()) / total),
("total", total),
]
)
else:
total = len(qid_list)
return collections.OrderedDict(
[
("exact", 100.0 * sum(exact_scores[k] for k in qid_list) / total),
("f1", 100.0 * sum(f1_scores[k] for k in qid_list) / total),
("total", total),
]
)
def merge_eval(main_eval, new_eval, prefix):
"""merges 2 evaluation dictionaries into the first one by adding prefix as key for name collision handling"""
for k in new_eval:
main_eval["%s_%s" % (prefix, k)] = new_eval[k]
def find_all_best_thresh(main_eval, preds, exact_raw, f1_raw, na_probs, qid_to_has_ans):
"""
Find best threshholds to maximize all evaluation metrics.
"""
best_exact, exact_thresh = _find_best_thresh(preds, exact_raw, na_probs, qid_to_has_ans)
best_f1, f1_thresh = _find_best_thresh(preds, f1_raw, na_probs, qid_to_has_ans)
main_eval["best_exact"] = best_exact
main_eval["best_exact_thresh"] = exact_thresh
main_eval["best_f1"] = best_f1
main_eval["best_f1_thresh"] = f1_thresh
def _find_best_thresh(preds, scores, na_probs, qid_to_has_ans):
"""
Find best threshhold to maximize evaluation metric
"""
num_no_ans = sum(1 for k in qid_to_has_ans if not qid_to_has_ans[k])
cur_score = num_no_ans
best_score = cur_score
best_thresh = 0.0
qid_list = sorted(na_probs, key=lambda k: na_probs[k])
for _, qid in enumerate(qid_list):
if qid not in scores:
continue
if qid_to_has_ans[qid]:
diff = scores[qid]
else:
if preds[qid]:
diff = -1
else:
diff = 0
cur_score += diff
if cur_score > best_score:
best_score = cur_score
best_thresh = na_probs[qid]
return 100.0 * best_score / len(scores), best_thresh
class SquadProcessor(DataProcessor):
"""
Processor for the SQuAD data set.
used by the version 1.1 and version 2.0 of SQuAD, respectively.
Args:
data_file: data file path
mode: TRAINING_MODE/EVALUATION_MODE/INFERENCE_MODE for creating training/evaluation/inference dataset
"""
def __init__(self, data_file: str, mode: str):
self.data_file = data_file
self.mode = mode
def get_examples(self):
if self.data_file is None:
raise ValueError(f"{self.mode} data file is None.")
with open(self.data_file, "r", encoding="utf-8") as reader:
input_data = json.load(reader)["data"]
return self._create_examples(input_data, set_type=self.mode)
def _create_examples(self, input_data, set_type):
examples = []
for entry in tqdm(input_data):
title = entry["title"]
for paragraph in entry["paragraphs"]:
context_text = paragraph["context"]
for qa in paragraph["qas"]:
qas_id = qa["id"]
question_text = qa["question"]
start_position_character = None
answer_text = None
answers = []
if "is_impossible" in qa:
is_impossible = qa["is_impossible"]
else:
is_impossible = False
if not is_impossible:
if set_type in [TRAINING_MODE, EVALUATION_MODE]:
answer = qa["answers"][0]
answer_text = answer["text"]
start_position_character = answer["answer_start"]
if set_type == EVALUATION_MODE:
answers = qa["answers"]
example = SquadExample(
qas_id=qas_id,
question_text=question_text,
context_text=context_text,
answer_text=answer_text,
start_position_character=start_position_character,
title=title,
is_impossible=is_impossible,
answers=answers,
)
examples.append(example)
return examples
class SquadExample(object):
"""
A single training/test example for the Squad dataset, as loaded from disk.
Args:
qas_id: The example's unique identifier
question_text: The question string
context_text: The context string
answer_text: The answer string
start_position_character: The character position of the start of
the answer, 0 indexed
title: The title of the example
answers: None by default, this is used during evaluation.
Holds answers as well as their start positions.
is_impossible: False by default, set to True if the example has
no possible answer.
"""
def __init__(
self,
qas_id: str,
question_text: str,
context_text: str,
answer_text: str,
start_position_character: int,
title: str,
answers: List[str] = [],
is_impossible: bool = False,
):
self.qas_id = qas_id
self.question_text = question_text
self.context_text = context_text
self.answer_text = answer_text
self.title = title
self.is_impossible = is_impossible
self.answers = answers
self.start_position, self.end_position = 0, 0
doc_tokens = []
char_to_word_offset = []
prev_is_whitespace = True
# Split on whitespace so that different tokens
# may be attributed to their original position.
# ex: context_text = ["hi yo"]
# char_to_word_offset = [0, 0, 0, 1, 1]
# doc_tokens = ["hi", "yo"]
for c in self.context_text:
if is_whitespace(c):
prev_is_whitespace = True
else:
if prev_is_whitespace:
doc_tokens.append(c)
else:
doc_tokens[-1] += c
prev_is_whitespace = False
char_to_word_offset.append(len(doc_tokens) - 1)
self.doc_tokens = doc_tokens
self.char_to_word_offset = char_to_word_offset
# Start end end positions only has a value during evaluation.
if start_position_character is not None and not is_impossible:
# start_position is index of word, end_position inclusive
self.start_position = char_to_word_offset[start_position_character]
self.end_position = char_to_word_offset[
min(start_position_character + len(answer_text) - 1, len(char_to_word_offset) - 1)
]
def convert_examples_to_features(
examples: List[object],
tokenizer: object,
max_seq_length: int,
doc_stride: int,
max_query_length: int,
has_groundtruth: bool,
):
"""Loads a data file into a list of `InputBatch`s."""
unique_id = 1000000000
features = []
for (example_index, example) in enumerate(examples):
query_tokens = tokenizer.text_to_tokens(example.question_text)
if len(query_tokens) > max_query_length:
query_tokens = query_tokens[0:max_query_length]
# context: index of token -> index of word
tok_to_orig_index = []
# context: index of word -> index of first token in token list
orig_to_tok_index = []
# context without white spaces after tokenization
all_doc_tokens = []
# doc tokens is word separated context
for (i, token) in enumerate(example.doc_tokens):
orig_to_tok_index.append(len(all_doc_tokens))
sub_tokens = tokenizer.text_to_tokens(token)
for sub_token in sub_tokens:
tok_to_orig_index.append(i)
all_doc_tokens.append(sub_token)
# idx of query token start and end in context
tok_start_position = None
tok_end_position = None
if has_groundtruth and example.is_impossible:
tok_start_position = -1
tok_end_position = -1
if has_groundtruth and not example.is_impossible:
tok_start_position = orig_to_tok_index[example.start_position]
if example.end_position < len(example.doc_tokens) - 1:
tok_end_position = orig_to_tok_index[example.end_position + 1] - 1
else:
tok_end_position = len(all_doc_tokens) - 1
(tok_start_position, tok_end_position) = _improve_answer_span(
all_doc_tokens, tok_start_position, tok_end_position, tokenizer, example.answer_text
)
# The -3 accounts for tokenizer.cls_token, tokenizer.sep_token and tokenizer.sep_token
# doc_spans contains all possible contexts options of given length
max_tokens_for_doc = max_seq_length - len(query_tokens) - 3
_DocSpan = collections.namedtuple("DocSpan", ["start", "length"])
doc_spans = []
start_offset = 0
while start_offset < len(all_doc_tokens):
length = len(all_doc_tokens) - start_offset
if length > max_tokens_for_doc:
length = max_tokens_for_doc
doc_spans.append(_DocSpan(start=start_offset, length=length))
if start_offset + length == len(all_doc_tokens):
break
start_offset += min(length, doc_stride)
for (doc_span_index, doc_span) in enumerate(doc_spans):
tokens = []
# maps context tokens idx in final input -> word idx in context
token_to_orig_map = {}
token_is_max_context = {}
segment_ids = []
tokens.append(tokenizer.cls_token)
segment_ids.append(0)
for token in query_tokens:
tokens.append(token)
segment_ids.append(0)
tokens.append(tokenizer.sep_token)
segment_ids.append(0)
for i in range(doc_span.length):
split_token_index = doc_span.start + i
token_to_orig_map[len(tokens)] = tok_to_orig_index[split_token_index]
is_max_context = _check_is_max_context(doc_spans, doc_span_index, split_token_index)
token_is_max_context[len(tokens)] = is_max_context
tokens.append(all_doc_tokens[split_token_index])
segment_ids.append(1)
tokens.append(tokenizer.sep_token)
segment_ids.append(1)
input_ids = tokenizer.tokens_to_ids(tokens)
# The mask has 1 for real tokens and 0 for padding tokens.
# Only real tokens are attended to.
input_mask = [1] * len(input_ids)
# Zero-pad up to the sequence length.
while len(input_ids) < max_seq_length:
input_ids.append(tokenizer.pad_id)
input_mask.append(0)
segment_ids.append(0)
assert len(input_ids) == max_seq_length
assert len(input_mask) == max_seq_length
assert len(segment_ids) == max_seq_length
# calculate start and end position in final array
# of tokens in answer if no answer,
# 0 for both pointing to tokenizer.cls_token
start_position = None
end_position = None
if has_groundtruth and not example.is_impossible:
doc_start = doc_span.start
doc_end = doc_span.start + doc_span.length - 1
out_of_span = False
if not (tok_start_position >= doc_start and tok_end_position <= doc_end):
out_of_span = True
if out_of_span:
start_position = 0
end_position = 0
else:
doc_offset = len(query_tokens) + 2
start_position = tok_start_position - doc_start + doc_offset
end_position = tok_end_position - doc_start + doc_offset
if has_groundtruth and example.is_impossible:
# if our document chunk does not contain
# an annotation we throw it out, since there is nothing
# to predict.
start_position = 0
end_position = 0
if example_index < 1:
logging.info("*** Example ***")
logging.info("unique_id: %s" % (unique_id))
logging.info("example_index: %s" % (example_index))
logging.info("doc_span_index: %s" % (doc_span_index))
logging.info("tokens: %s" % " ".join(tokens))
logging.info(
"token_to_orig_map: %s" % " ".join(["%d:%d" % (x, y) for (x, y) in token_to_orig_map.items()])
)
logging.info(
"token_is_max_context: %s"
% " ".join(["%d:%s" % (x, y) for (x, y) in token_is_max_context.items()])
)
logging.info("input_ids: %s" % " ".join([str(x) for x in input_ids]))
logging.info("input_mask: %s" % " ".join([str(x) for x in input_mask]))
logging.info("segment_ids: %s" % " ".join([str(x) for x in segment_ids]))
if has_groundtruth and example.is_impossible:
logging.info("impossible example")
if has_groundtruth and not example.is_impossible:
answer_text = " ".join(tokens[start_position : (end_position + 1)])
logging.info("start_position: %d" % (start_position))
logging.info("end_position: %d" % (end_position))
logging.info("answer: %s" % (answer_text))
if example_index % 100 == 0:
logging.info(f"Finished processing: {example_index}")
features.append(
InputFeatures(
unique_id=unique_id,
example_index=example_index,
doc_span_index=doc_span_index,
tokens=tokens,
token_to_orig_map=token_to_orig_map,
token_is_max_context=token_is_max_context,
input_ids=input_ids,
input_mask=input_mask,
segment_ids=segment_ids,
start_position=start_position,
end_position=end_position,
is_impossible=example.is_impossible,
)
)
unique_id += 1
return features
def _improve_answer_span(
doc_tokens: List[str], input_start: int, input_end: int, tokenizer: object, orig_answer_text: str
):
"""Returns tokenized answer spans that
better match the annotated answer."""
tok_answer_text = " ".join(tokenizer.text_to_tokens(orig_answer_text))
for new_start in range(input_start, input_end + 1):
for new_end in range(input_end, new_start - 1, -1):
text_span = " ".join(doc_tokens[new_start : (new_end + 1)])
if text_span == tok_answer_text:
return (new_start, new_end)
return (input_start, input_end)
def _check_is_max_context(doc_spans, cur_span_index, position):
"""Check if this is the 'max context' doc span for the token.
Because of the sliding window approach taken to scoring documents,
a single token can appear in multiple documents.
Example:
Doc: the man went to the store and bought a gallon of milk
Span A: the man went to the
Span B: to the store and bought
Span C: and bought a gallon of
...
Now the word 'bought' will have two scores from spans B and C. We only
want to consider the score with "maximum context", which we define as
the *minimum* of its left and right context (the *sum* of left and
right context will always be the same, of course).
In the example the maximum context for 'bought' would be span C since
it has 1 left context and 3 right context, while span B has 4 left context
and 0 right context.
Code adapted from the code by the Google AI and HuggingFace.
"""
best_score = None
best_span_index = None
for (span_index, doc_span) in enumerate(doc_spans):
end = doc_span.start + doc_span.length - 1
if position < doc_span.start:
continue
if position > end:
continue
num_left_context = position - doc_span.start
num_right_context = end - position
score = min(num_left_context, num_right_context) + 0.01 * doc_span.length
if best_score is None or score > best_score:
best_score = score
best_span_index = span_index
return cur_span_index == best_span_index
class InputFeatures(object):
"""A single set of features of data."""
def __init__(
self,
unique_id: int,
example_index: int,
doc_span_index: int,
tokens: List[str],
token_to_orig_map: Dict[int, int],
token_is_max_context: Dict[int, bool],
input_ids: List[int],
input_mask: List[int],
segment_ids: List[int],
start_position: Optional[int] = None,
end_position: Optional[int] = None,
is_impossible: Optional[int] = None,
):
self.unique_id = unique_id
self.example_index = example_index
self.doc_span_index = doc_span_index
self.tokens = tokens
self.token_to_orig_map = token_to_orig_map
self.token_is_max_context = token_is_max_context
self.input_ids = input_ids
self.input_mask = input_mask
self.segment_ids = segment_ids
self.start_position = start_position
self.end_position = end_position
self.is_impossible = is_impossible
@@ -0,0 +1,19 @@
# 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.
from nemo.collections.nlp.data.text_classification.text_classification_dataset import (
TextClassificationDataset,
calc_class_weights,
)
@@ -0,0 +1,297 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# 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.
import os
import pickle
import random
from typing import Dict, List, Optional
import numpy as np
import torch
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.data_utils.data_preprocessing import (
fill_class_weights,
get_freq_weights,
get_label_stats,
get_stats,
)
from nemo.collections.nlp.parts.utils_funcs import list2str
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, LabelsType, MaskType, NeuralType
from nemo.utils import logging
from nemo.utils.env_var_parsing import get_envint
__all__ = ['TextClassificationDataset', 'calc_class_weights']
class TextClassificationDataset(Dataset):
"""A dataset class that converts from raw data to
a dataset that can be used by DataLayerNM.
Args:
input_file: file to sequence + label.
the first line is header (sentence [tab] label)
each line should be [sentence][tab][label]
tokenizer: tokenizer object such as AutoTokenizer
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
num_samples: number of samples you want to use for the dataset.
If -1, use all dataset. Useful for testing.
shuffle: Shuffles the dataset after loading.
use_cache: Enables caching to use pickle format to store and read data from
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'label': NeuralType(('B',), LabelsType()),
}
def __init__(
self,
tokenizer: TokenizerSpec,
input_file: str = None,
queries: List[str] = None,
max_seq_length: int = -1,
num_samples: int = -1,
shuffle: bool = False,
use_cache: bool = False,
):
if not input_file and not queries:
raise ValueError("Either input_file or queries should be passed to the text classification dataset.")
if input_file and not os.path.exists(input_file):
raise FileNotFoundError(
f'Data file `{input_file}` not found! Each line of the data file should contain text sequences, where '
f'words are separated with spaces and the label separated by [TAB] following this format: '
f'[WORD][SPACE][WORD][SPACE][WORD][TAB][LABEL]'
)
self.input_file = input_file
self.tokenizer = tokenizer
self.max_seq_length = max_seq_length
self.num_samples = num_samples
self.shuffle = shuffle
self.use_cache = use_cache
self.vocab_size = self.tokenizer.vocab_size
self.pad_id = tokenizer.pad_id
self.features = None
labels, all_sents = [], []
if input_file:
data_dir, filename = os.path.split(input_file)
vocab_size = getattr(tokenizer, "vocab_size", 0)
tokenizer_name = tokenizer.name
cached_features_file = os.path.join(
data_dir,
f"cached_{filename}_{tokenizer_name}_{max_seq_length}_{vocab_size}_{num_samples}_{self.pad_id}_{shuffle}.pkl",
)
if get_envint("LOCAL_RANK", 0) == 0:
if use_cache and os.path.exists(cached_features_file):
logging.warning(
f"Processing of {input_file} is skipped as caching is enabled and a cache file "
f"{cached_features_file} already exists."
)
logging.warning(
f"You may need to delete the cache file if any of the processing parameters (eg. tokenizer) or "
f"the data are updated."
)
else:
with open(input_file, "r") as f:
lines = f.readlines()
logging.info(f'Read {len(lines)} examples from {input_file}.')
if num_samples > 0:
lines = lines[:num_samples]
logging.warning(
f"Parameter 'num_samples' is set, so just the first {len(lines)} examples are kept."
)
if shuffle:
random.shuffle(lines)
for index, line in enumerate(lines):
if index % 20000 == 0:
logging.debug(f"Processing line {index}/{len(lines)}")
line_splited = line.strip().split()
try:
label = int(line_splited[-1])
except ValueError:
logging.debug(f"Skipping line {line}")
continue
labels.append(label)
sent_words = line_splited[:-1]
all_sents.append(sent_words)
verbose = True
self.features = self.get_features(
all_sents=all_sents,
tokenizer=tokenizer,
max_seq_length=max_seq_length,
labels=labels,
verbose=verbose,
)
with open(cached_features_file, 'wb') as out_file:
pickle.dump(self.features, out_file, protocol=pickle.HIGHEST_PROTOCOL)
else:
for query in queries:
all_sents.append(query.strip().split())
labels = [-1] * len(all_sents)
verbose = False
self.features = self.get_features(
all_sents=all_sents, tokenizer=tokenizer, max_seq_length=max_seq_length, labels=labels, verbose=verbose
)
# wait until the master process writes to the processed data files
if torch.distributed.is_initialized():
torch.distributed.barrier()
if input_file:
with open(cached_features_file, "rb") as input_file:
self.features = pickle.load(input_file)
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
return self.features[idx]
def _collate_fn(self, batch):
"""collate batch of input_ids, segment_ids, input_mask, and label
Args:
batch: A list of tuples of (input_ids, segment_ids, input_mask, label).
"""
max_length = 0
for input_ids, segment_ids, input_mask, label in batch:
if len(input_ids) > max_length:
max_length = len(input_ids)
padded_input_ids = []
padded_segment_ids = []
padded_input_mask = []
labels = []
for input_ids, segment_ids, input_mask, label in batch:
if len(input_ids) < max_length:
pad_width = max_length - len(input_ids)
padded_input_ids.append(np.pad(input_ids, pad_width=[0, pad_width], constant_values=self.pad_id))
padded_segment_ids.append(np.pad(segment_ids, pad_width=[0, pad_width], constant_values=self.pad_id))
padded_input_mask.append(np.pad(input_mask, pad_width=[0, pad_width], constant_values=self.pad_id))
else:
padded_input_ids.append(input_ids)
padded_segment_ids.append(segment_ids)
padded_input_mask.append(input_mask)
labels.append(label)
return (
torch.LongTensor(padded_input_ids),
torch.LongTensor(padded_segment_ids),
torch.LongTensor(padded_input_mask),
torch.LongTensor(labels),
)
@staticmethod
def get_features(all_sents, tokenizer, max_seq_length, labels=None, verbose=True):
"""Encode a list of sentences into a list of tuples of (input_ids, segment_ids, input_mask, label)."""
features = []
sent_lengths = []
too_long_count = 0
for sent_id, sent in enumerate(all_sents):
if sent_id % 1000 == 0:
logging.debug(f"Encoding sentence {sent_id}/{len(all_sents)}")
sent_subtokens = [tokenizer.cls_token]
for word in sent:
word_tokens = tokenizer.text_to_tokens(word)
sent_subtokens.extend(word_tokens)
if max_seq_length > 0 and len(sent_subtokens) + 1 > max_seq_length:
sent_subtokens = sent_subtokens[:max_seq_length]
too_long_count += 1
sent_subtokens.append(tokenizer.sep_token)
sent_lengths.append(len(sent_subtokens))
input_ids = [tokenizer.tokens_to_ids(t) for t in sent_subtokens]
# The mask has 1 for real tokens and 0 for padding tokens.
# Only real tokens are attended to.
input_mask = [1] * len(input_ids)
segment_ids = [0] * len(input_ids)
if verbose and sent_id < 2:
logging.info("*** Example ***")
logging.info(f"example {sent_id}: {sent}")
logging.info("subtokens: %s" % " ".join(sent_subtokens))
logging.info("input_ids: %s" % list2str(input_ids))
logging.info("segment_ids: %s" % list2str(segment_ids))
logging.info("input_mask: %s" % list2str(input_mask))
logging.info("label: %s" % labels[sent_id] if labels else "**Not Provided**")
label = labels[sent_id] if labels else -1
features.append([np.asarray(input_ids), np.asarray(segment_ids), np.asarray(input_mask), label])
if max_seq_length > -1 and too_long_count > 0:
logging.warning(
f'Found {too_long_count} out of {len(all_sents)} sentences with more than {max_seq_length} subtokens. '
f'Truncated long sentences from the end.'
)
if verbose:
get_stats(sent_lengths)
return features
def calc_class_weights(file_path: str, num_classes: int):
"""
iterates over a data file and calculate the weights of each class to be used for class_balancing
Args:
file_path: path to the data file
num_classes: number of classes in the dataset
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find data file {file_path} to calculate the class weights!")
with open(file_path, 'r') as f:
input_lines = f.readlines()
labels = []
for input_line in input_lines:
parts = input_line.strip().split()
try:
label = int(parts[-1])
except ValueError:
raise ValueError(
f'No numerical labels found for {file_path}. Labels should be integers and separated by [TAB] at the end of each line.'
)
labels.append(label)
logging.info(f'Calculating stats of {file_path}...')
total_sents, sent_label_freq, max_id = get_label_stats(labels, f'{file_path}_sentence_stats.tsv', verbose=False)
if max_id >= num_classes:
raise ValueError(f'Found an invalid label in {file_path}! Labels should be from [0, num_classes-1].')
class_weights_dict = get_freq_weights(sent_label_freq)
logging.info(f'Total Sentence: {total_sents}')
logging.info(f'Sentence class frequencies: {sent_label_freq}')
logging.info(f'Class Weights: {class_weights_dict}')
class_weights = fill_class_weights(weights=class_weights_dict, max_id=num_classes - 1)
return class_weights
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,466 @@
# 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.
__all__ = ['BertPunctuationCapitalizationDataset', 'BertPunctuationCapitalizationInferDataset']
import itertools
import os
import pickle
from typing import Dict, List, Optional
import numpy as np
import torch
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.data_utils.data_preprocessing import get_label_stats, get_stats
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, LabelsType, MaskType, NeuralType
from nemo.utils import logging
def get_features(
queries: List[str],
max_seq_length: int,
tokenizer: TokenizerSpec,
punct_label_ids: dict = None,
capit_label_ids: dict = None,
pad_label: str = 'O',
punct_labels_lines=None,
capit_labels_lines=None,
ignore_extra_tokens=False,
ignore_start_end: Optional[bool] = False,
):
"""
Processes the data and returns features.
Args:
queries: text sequences
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as AutoTokenizer
pad_label: pad value use for labels. By default, it's the neutral label.
punct_label_ids: dict to map punctuation labels to label ids.
Starts with pad_label->0 and then increases in alphabetical order.
Required for training and evaluation, not needed for inference.
capit_label_ids: dict to map labels to label ids. Starts
with pad_label->0 and then increases in alphabetical order.
Required for training and evaluation, not needed for inference.
punct_labels: list of labels for every word in a sequence (str)
capit_labels: list of labels for every word in a sequence (str)
ignore_extra_tokens: whether to ignore extra tokens in the loss_mask
ignore_start_end: whether to ignore bos and eos tokens in the loss_mask
Returns:
all_input_ids: input ids for all tokens
all_segment_ids: token type ids
all_input_mask: attention mask to use for BERT model
all_subtokens_mask: masks out all subwords besides the first one
all_loss_mask: loss mask to mask out tokens during training
punct_all_labels: all labels for punctuation task (ints)
capit_all_labels: all labels for capitalization task (ints)
punct_label_ids: label (str) to id (int) map for punctuation task
capit_label_ids: label (str) to id (int) map for capitalization task
"""
all_subtokens = []
all_loss_mask = []
all_subtokens_mask = []
all_segment_ids = []
all_input_ids = []
all_input_mask = []
sent_lengths = []
punct_all_labels = []
capit_all_labels = []
with_label = False
if punct_labels_lines and capit_labels_lines:
with_label = True
for i, query in enumerate(queries):
words = query.strip().split()
# add bos token
subtokens = [tokenizer.cls_token]
loss_mask = [1 - ignore_start_end]
subtokens_mask = [0]
if with_label:
pad_id = punct_label_ids[pad_label]
punct_labels = [pad_id]
punct_query_labels = [punct_label_ids[lab] for lab in punct_labels_lines[i]]
capit_labels = [pad_id]
capit_query_labels = [capit_label_ids[lab] for lab in capit_labels_lines[i]]
for j, word in enumerate(words):
word_tokens = tokenizer.text_to_tokens(word)
subtokens.extend(word_tokens)
loss_mask.append(1)
loss_mask.extend([int(not ignore_extra_tokens)] * (len(word_tokens) - 1))
subtokens_mask.append(1)
subtokens_mask.extend([0] * (len(word_tokens) - 1))
if with_label:
punct_labels.extend([punct_query_labels[j]] * len(word_tokens))
capit_labels.extend([capit_query_labels[j]] * len(word_tokens))
# add eos token
subtokens.append(tokenizer.sep_token)
loss_mask.append(1 - ignore_start_end)
subtokens_mask.append(0)
sent_lengths.append(len(subtokens))
all_subtokens.append(subtokens)
all_loss_mask.append(loss_mask)
all_subtokens_mask.append(subtokens_mask)
all_input_mask.append([1] * len(subtokens))
if with_label:
punct_labels.append(pad_id)
punct_all_labels.append(punct_labels)
capit_labels.append(pad_id)
capit_all_labels.append(capit_labels)
max_seq_length = min(max_seq_length, max(sent_lengths))
logging.info(f'Max length: {max_seq_length}')
get_stats(sent_lengths)
too_long_count = 0
for i, subtokens in enumerate(all_subtokens):
if len(subtokens) > max_seq_length:
subtokens = [tokenizer.cls_token] + subtokens[-max_seq_length + 1 :]
all_input_mask[i] = [1] + all_input_mask[i][-max_seq_length + 1 :]
all_loss_mask[i] = [int(not ignore_start_end)] + all_loss_mask[i][-max_seq_length + 1 :]
all_subtokens_mask[i] = [0] + all_subtokens_mask[i][-max_seq_length + 1 :]
if with_label:
punct_all_labels[i] = [pad_id] + punct_all_labels[i][-max_seq_length + 1 :]
capit_all_labels[i] = [pad_id] + capit_all_labels[i][-max_seq_length + 1 :]
too_long_count += 1
all_input_ids.append(tokenizer.tokens_to_ids(subtokens))
if len(subtokens) < max_seq_length:
extra = max_seq_length - len(subtokens)
all_input_ids[i] = all_input_ids[i] + [0] * extra
all_loss_mask[i] = all_loss_mask[i] + [0] * extra
all_subtokens_mask[i] = all_subtokens_mask[i] + [0] * extra
all_input_mask[i] = all_input_mask[i] + [0] * extra
if with_label:
punct_all_labels[i] = punct_all_labels[i] + [pad_id] * extra
capit_all_labels[i] = capit_all_labels[i] + [pad_id] * extra
all_segment_ids.append([0] * max_seq_length)
logging.info(f'{too_long_count} are longer than {max_seq_length}')
for i in range(min(len(all_input_ids), 5)):
logging.info("*** Example ***")
logging.info("i: %s" % (i))
logging.info("subtokens: %s" % " ".join(list(map(str, all_subtokens[i]))))
logging.info("loss_mask: %s" % " ".join(list(map(str, all_loss_mask[i]))))
logging.info("input_mask: %s" % " ".join(list(map(str, all_input_mask[i]))))
logging.info("subtokens_mask: %s" % " ".join(list(map(str, all_subtokens_mask[i]))))
if with_label:
logging.info("punct_labels: %s" % " ".join(list(map(str, punct_all_labels[i]))))
logging.info("capit_labels: %s" % " ".join(list(map(str, capit_all_labels[i]))))
return (
all_input_ids,
all_segment_ids,
all_input_mask,
all_subtokens_mask,
all_loss_mask,
punct_all_labels,
capit_all_labels,
punct_label_ids,
capit_label_ids,
)
class BertPunctuationCapitalizationDataset(Dataset):
"""
Creates dataset to use during training for punctuaion and capitalization tasks with a pretrained model.
For dataset to use during inference without labels, see BertPunctuationCapitalizationInferDataset.
Args:
text_file: file to sequences, each line should a sentence, no header.
label_file: file to labels, each line corresponds to word labels for a sentence in the text_file. No header.
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as AutoTokenizer
num_samples: number of samples you want to use for the dataset.
If -1, use all dataset. Useful for testing.
pad_label: pad value use for labels.
by default, it's the neutral label.
punct_label_ids and capit_label_ids (dict):
dict to map labels to label ids.
Starts with pad_label->0 and then increases in alphabetical order
For dev set use label_ids generated during training to support
cases when not all labels are present in the dev set.
For training set label_ids should be None or loaded from cache
ignore_extra_tokens: whether to ignore extra tokens in the loss_mask
ignore_start_end: whether to ignore bos and eos tokens in the loss_mask
use_cache: whether to use processed data cache or not
get_label_frequencies: whether to generate label frequencies
punct_label_ids_file and capit_label_ids_file: name of the files to save in .nemo
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports. """
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
'loss_mask': NeuralType(('B', 'T'), MaskType()),
'punct_labels': NeuralType(('B', 'T'), LabelsType()),
'capit_labels': NeuralType(('B', 'T'), LabelsType()),
}
def __init__(
self,
text_file: str,
label_file: str,
max_seq_length: int,
tokenizer: TokenizerSpec,
num_samples: int = -1,
pad_label: str = 'O',
punct_label_ids: Dict[str, int] = None,
capit_label_ids: Dict[str, int] = None,
ignore_extra_tokens: bool = False,
ignore_start_end: bool = False,
use_cache: bool = True,
get_label_frequencies: bool = False,
punct_label_ids_file: str = 'punct_label_ids.csv',
capit_label_ids_file: str = 'capit_label_ids.csv',
):
""" Initializes BertPunctuationCapitalizationDataset. """
if not (os.path.exists(text_file) and os.path.exists(label_file)):
raise FileNotFoundError(
f'{text_file} or {label_file} not found. The data should be splitted into 2 files: text.txt and \
labels.txt. Each line of the text.txt file contains text sequences, where words are separated with \
spaces. The labels.txt file contains corresponding labels for each word in text.txt, the labels are \
separated with spaces. Each line of the files should follow the format: \
[WORD] [SPACE] [WORD] [SPACE] [WORD] (for text.txt) and \
[LABEL] [SPACE] [LABEL] [SPACE] [LABEL] (for labels.txt).'
)
# Cache features
data_dir = os.path.dirname(text_file)
filename = os.path.basename(text_file)
if not filename.endswith('.txt'):
raise ValueError("{text_file} should have extension .txt")
filename = filename[:-4]
vocab_size = getattr(tokenizer, "vocab_size", 0)
features_pkl = os.path.join(
data_dir,
"cached_{}_{}_{}_{}_{}".format(
filename, tokenizer.name, str(max_seq_length), str(vocab_size), str(num_samples)
),
)
self.punct_label_ids_file = os.path.join(data_dir, punct_label_ids_file)
self.capit_label_ids_file = os.path.join(data_dir, capit_label_ids_file)
master_device = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
cache_files_exist = (
os.path.exists(features_pkl)
and os.path.exists(self.punct_label_ids_file)
and os.path.exists(self.capit_label_ids_file)
)
features = None
if master_device and not (cache_files_exist and use_cache):
if num_samples == 0:
raise ValueError("num_samples has to be positive", num_samples)
logging.info(f'Processing {text_file}')
with open(text_file, 'r') as f:
text_lines = f.readlines()
# Collect all possible labels
punct_unique_labels = set()
capit_unique_labels = set()
punct_labels_lines = []
capit_labels_lines = []
with open(label_file, 'r') as f:
for line in f:
line = line.strip().split()
# extract punctuation and capitalization labels
punct_line, capit_line = zip(*line)
punct_labels_lines.append(punct_line)
capit_labels_lines.append(capit_line)
punct_unique_labels.update(punct_line)
capit_unique_labels.update(capit_line)
if len(punct_labels_lines) != len(text_lines):
raise ValueError("Labels file should contain labels for every word")
dataset = list(zip(text_lines, punct_labels_lines, capit_labels_lines))
if num_samples > 0:
dataset = dataset[:num_samples]
dataset = list(zip(*dataset))
text_lines = dataset[0]
punct_labels_lines = dataset[1]
capit_labels_lines = dataset[2]
# for dev/test sets use label mapping from training set
if punct_label_ids:
if len(punct_label_ids) != len(punct_unique_labels):
logging.info(
'Not all labels from the specified'
+ 'label_ids dictionary are present in the'
+ 'current dataset. Using the provided'
+ 'label_ids dictionary.'
)
else:
logging.info('Using the provided label_ids dictionary.')
else:
logging.info(
'Creating a new label to label_id dictionary.'
+ ' It\'s recommended to use label_ids generated'
+ ' during training for dev/test sets to avoid'
+ ' errors if some labels are not'
+ ' present in the dev/test sets.'
+ ' For training set label_ids should be None.'
)
def create_label_ids(unique_labels, pad_label=pad_label):
label_ids = {pad_label: 0}
if pad_label in unique_labels:
unique_labels.remove(pad_label)
for label in sorted(unique_labels):
label_ids[label] = len(label_ids)
return label_ids
punct_label_ids = create_label_ids(punct_unique_labels)
capit_label_ids = create_label_ids(capit_unique_labels)
self._save_label_ids(punct_label_ids, self.punct_label_ids_file)
self._save_label_ids(capit_label_ids, self.capit_label_ids_file)
features = get_features(
text_lines,
max_seq_length,
tokenizer,
pad_label=pad_label,
punct_labels_lines=punct_labels_lines,
capit_labels_lines=capit_labels_lines,
punct_label_ids=punct_label_ids,
capit_label_ids=capit_label_ids,
ignore_extra_tokens=ignore_extra_tokens,
ignore_start_end=ignore_start_end,
)
pickle.dump(features, open(features_pkl, "wb"))
logging.info(f'Features saved to {features_pkl}')
# wait until the master process writes to the processed data files
if torch.distributed.is_initialized():
torch.distributed.barrier()
if features is None:
features = pickle.load(open(features_pkl, 'rb'))
logging.info(f'Features restored from {features_pkl}')
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_subtokens_mask = features[3]
self.all_loss_mask = features[4]
self.punct_all_labels = features[5]
self.capit_all_labels = features[6]
self.punct_label_ids = features[7]
self.capit_label_ids = features[8]
if get_label_frequencies:
self.punct_label_frequencies = self._calculate_label_frequencies(self.punct_all_labels, data_dir, 'punct')
self.capit_label_frequencies = self._calculate_label_frequencies(self.capit_all_labels, data_dir, 'capit')
def _calculate_label_frequencies(self, all_labels: List[int], data_dir: str, name: str) -> Dict[str, float]:
""" Calculates labels frequencies """
merged_labels = itertools.chain.from_iterable(all_labels)
logging.info('Three most popular labels')
_, label_frequencies, _ = get_label_stats(merged_labels, data_dir + '/label_count_' + name + '.tsv')
return label_frequencies
def _save_label_ids(self, label_ids: Dict[str, int], filename: str) -> None:
""" Saves label ids map to a file """
with open(filename, 'w') as out:
labels, _ = zip(*sorted(label_ids.items(), key=lambda x: x[1]))
out.write('\n'.join(labels))
logging.info(f'Labels: {label_ids}')
logging.info(f'Labels mapping saved to : {out.name}')
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.long),
np.array(self.all_subtokens_mask[idx]),
np.array(self.all_loss_mask[idx]),
np.array(self.punct_all_labels[idx]),
np.array(self.capit_all_labels[idx]),
)
class BertPunctuationCapitalizationInferDataset(Dataset):
"""
Creates dataset to use during inference for punctuation and capitalization tasks with a pretrained model.
For dataset to use during training with labels, see BertPunctuationCapitalizationDataset.
Args:
queries file to sequences, each line should a sentence, no header.
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as AutoTokenizer
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
}
def __init__(self, queries: List[str], max_seq_length: int, tokenizer: TokenizerSpec):
""" Initializes BertPunctuationCapitalizationInferDataset. """
features = get_features(queries=queries, max_seq_length=max_seq_length, tokenizer=tokenizer)
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_subtokens_mask = features[3]
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.float32),
np.array(self.all_subtokens_mask[idx]),
)
@@ -0,0 +1,338 @@
# Copyright 2018 The Google AI Language Team Authors and
# The HuggingFace Inc. team.
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Utility functions for Token Classification NLP tasks
Some parts of this code were adapted from the HuggingFace library at
https://github.com/huggingface/pytorch-pretrained-BERT
"""
import os
import pickle
from typing import Dict, List, Optional
import numpy as np
import torch
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
from nemo.collections.nlp.data.data_utils.data_preprocessing import get_stats
from nemo.core.classes import Dataset
from nemo.core.neural_types import ChannelType, LabelsType, MaskType, NeuralType
from nemo.utils import logging
__all__ = ['BertTokenClassificationDataset', 'BertTokenClassificationInferDataset']
def get_features(
queries: List[str],
tokenizer: TokenizerSpec,
max_seq_length: int = -1,
label_ids: dict = None,
pad_label: str = 'O',
raw_labels: List[str] = None,
ignore_extra_tokens: bool = False,
ignore_start_end: bool = False,
):
"""
Processes the data and returns features.
Args:
queries: text sequences
tokenizer: such as AutoTokenizer
max_seq_length: max sequence length minus 2 for [CLS] and [SEP], when -1 - use the max len from the data
pad_label: pad value use for labels. By default, it's the neutral label.
raw_labels: list of labels for every word in a sequence
label_ids: dict to map labels to label ids.
Starts with pad_label->0 and then increases in alphabetical order.
Required for training and evaluation, not needed for inference.
ignore_extra_tokens: whether to ignore extra tokens in the loss_mask
ignore_start_end: whether to ignore bos and eos tokens in the loss_mask
"""
all_subtokens = []
all_loss_mask = []
all_subtokens_mask = []
all_segment_ids = []
all_input_ids = []
all_input_mask = []
sent_lengths = []
all_labels = []
with_label = False
if raw_labels is not None:
with_label = True
for i, query in enumerate(queries):
words = query.strip().split()
# add bos token
subtokens = [tokenizer.cls_token]
loss_mask = [1 - ignore_start_end]
subtokens_mask = [0]
if with_label:
pad_id = label_ids[pad_label]
labels = [pad_id]
query_labels = [label_ids[lab] for lab in raw_labels[i]]
for j, word in enumerate(words):
word_tokens = tokenizer.text_to_tokens(word)
subtokens.extend(word_tokens)
loss_mask.append(1)
loss_mask.extend([int(not ignore_extra_tokens)] * (len(word_tokens) - 1))
subtokens_mask.append(1)
subtokens_mask.extend([0] * (len(word_tokens) - 1))
if with_label:
labels.extend([query_labels[j]] * len(word_tokens))
# add eos token
subtokens.append(tokenizer.sep_token)
loss_mask.append(1 - ignore_start_end)
subtokens_mask.append(0)
sent_lengths.append(len(subtokens))
all_subtokens.append(subtokens)
all_loss_mask.append(loss_mask)
all_subtokens_mask.append(subtokens_mask)
all_input_mask.append([1] * len(subtokens))
if with_label:
labels.append(pad_id)
all_labels.append(labels)
max_seq_length_data = max(sent_lengths)
max_seq_length = min(max_seq_length, max_seq_length_data) if max_seq_length > 0 else max_seq_length_data
logging.info(f'Setting Max Seq length to: {max_seq_length}')
get_stats(sent_lengths)
too_long_count = 0
for i, subtokens in enumerate(all_subtokens):
if len(subtokens) > max_seq_length:
subtokens = [tokenizer.cls_token] + subtokens[-max_seq_length + 1 :]
all_input_mask[i] = [1] + all_input_mask[i][-max_seq_length + 1 :]
all_loss_mask[i] = [int(not ignore_start_end)] + all_loss_mask[i][-max_seq_length + 1 :]
all_subtokens_mask[i] = [0] + all_subtokens_mask[i][-max_seq_length + 1 :]
if with_label:
all_labels[i] = [pad_id] + all_labels[i][-max_seq_length + 1 :]
too_long_count += 1
all_input_ids.append(tokenizer.tokens_to_ids(subtokens))
if len(subtokens) < max_seq_length:
extra = max_seq_length - len(subtokens)
all_input_ids[i] = all_input_ids[i] + [0] * extra
all_loss_mask[i] = all_loss_mask[i] + [0] * extra
all_subtokens_mask[i] = all_subtokens_mask[i] + [0] * extra
all_input_mask[i] = all_input_mask[i] + [0] * extra
if with_label:
all_labels[i] = all_labels[i] + [pad_id] * extra
all_segment_ids.append([0] * max_seq_length)
logging.warning(f'{too_long_count} are longer than {max_seq_length}')
for i in range(min(len(all_input_ids), 1)):
logging.info("*** Example ***")
logging.info("i: %s", i)
logging.info("subtokens: %s", " ".join(list(map(str, all_subtokens[i]))))
logging.info("loss_mask: %s", " ".join(list(map(str, all_loss_mask[i]))))
logging.info("input_mask: %s", " ".join(list(map(str, all_input_mask[i]))))
logging.info("subtokens_mask: %s", " ".join(list(map(str, all_subtokens_mask[i]))))
if with_label:
logging.info("labels: %s", " ".join(list(map(str, all_labels[i]))))
return (all_input_ids, all_segment_ids, all_input_mask, all_subtokens_mask, all_loss_mask, all_labels)
class BertTokenClassificationDataset(Dataset):
"""
Creates dataset to use during training for token classification tasks with a pretrained model.
Converts from raw data to an instance that can be used by Dataloader.
For dataset to use during inference without labels, see BertTokenClassificationInferDataset.
Args:
text_file: file to sequences, each line should a sentence, no header.
label_file: file to labels, each line corresponds to word labels for a sentence in the text_file. No header.
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as AutoTokenizer
num_samples: number of samples you want to use for the dataset.
If -1, use all dataset. Useful for testing.
pad_label: pad value use for labels. By default, it's the neutral label.
label_ids: label_ids (dict): dict to map labels to label ids.
Starts with pad_label->0 and then increases in alphabetical order
For dev set use label_ids generated during training to support
cases when not all labels are present in the dev set.
For training set label_ids should be None.
ignore_extra_tokens: whether to ignore extra tokens in the loss_mask
ignore_start_end: whether to ignore bos and eos tokens in the loss_mask
use_cache: whether to use processed data cache or not
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
'loss_mask': NeuralType(('B', 'T'), MaskType()),
'labels': NeuralType(('B', 'T'), LabelsType()),
}
def __init__(
self,
text_file: str,
label_file: str,
max_seq_length: int,
tokenizer: TokenizerSpec,
num_samples: int = -1,
pad_label: str = 'O',
label_ids: Dict[str, int] = None,
ignore_extra_tokens: bool = False,
ignore_start_end: bool = False,
use_cache: bool = True,
):
""" Initializes BertTokenClassificationDataset. """
data_dir = os.path.dirname(text_file)
filename = os.path.basename(text_file)
if not filename.endswith('.txt'):
raise ValueError("{text_file} should have extension .txt")
vocab_size = getattr(tokenizer, "vocab_size", 0)
features_pkl = os.path.join(
data_dir,
"cached_{}_{}_{}_{}_{}".format(
filename, tokenizer.name, str(max_seq_length), str(vocab_size), str(num_samples)
),
)
master_device = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
features = None
if master_device and (not use_cache or not os.path.exists(features_pkl)):
if num_samples == 0:
raise ValueError("num_samples has to be positive", num_samples)
with open(text_file, 'r') as f:
text_lines = f.readlines()
labels_lines = []
with open(label_file, 'r') as f:
for line in f:
line = line.strip().split()
labels_lines.append(line)
if len(labels_lines) != len(text_lines):
raise ValueError("Labels file should contain labels for every word")
if num_samples > 0:
dataset = list(zip(text_lines, labels_lines))
dataset = dataset[:num_samples]
dataset = list(zip(*dataset))
text_lines = dataset[0]
labels_lines = dataset[1]
features = get_features(
queries=text_lines,
max_seq_length=max_seq_length,
tokenizer=tokenizer,
pad_label=pad_label,
raw_labels=labels_lines,
label_ids=label_ids,
ignore_extra_tokens=ignore_extra_tokens,
ignore_start_end=ignore_start_end,
)
pickle.dump(features, open(features_pkl, "wb"))
logging.info(f'features saved to {features_pkl}')
# wait until the master process writes to the processed data files
if torch.distributed.is_initialized():
torch.distributed.barrier()
if features is None:
features = pickle.load(open(features_pkl, 'rb'))
logging.info(f'features restored from {features_pkl}')
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_subtokens_mask = features[3]
self.all_loss_mask = features[4]
self.all_labels = features[5]
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.long),
np.array(self.all_subtokens_mask[idx]),
np.array(self.all_loss_mask[idx]),
np.array(self.all_labels[idx]),
)
class BertTokenClassificationInferDataset(Dataset):
"""
Creates dataset to use during inference for token classification tasks with a pretrained model.
For dataset to use during training with labels, see BertTokenClassificationDataset.
"""
@property
def output_types(self) -> Optional[Dict[str, NeuralType]]:
"""Returns definitions of module output ports.
"""
return {
'input_ids': NeuralType(('B', 'T'), ChannelType()),
'segment_ids': NeuralType(('B', 'T'), ChannelType()),
'input_mask': NeuralType(('B', 'T'), MaskType()),
'subtokens_mask': NeuralType(('B', 'T'), MaskType()),
}
def __init__(
self, queries: List[str], max_seq_length: int, tokenizer: TokenizerSpec,
):
"""
Initializes BertTokenClassificationInferDataset
Args:
queries: text sequences
max_seq_length: max sequence length minus 2 for [CLS] and [SEP]
tokenizer: such as AutoTokenizer
"""
features = get_features(queries=queries, max_seq_length=max_seq_length, tokenizer=tokenizer)
self.all_input_ids = features[0]
self.all_segment_ids = features[1]
self.all_input_mask = features[2]
self.all_subtokens_mask = features[3]
def __len__(self):
return len(self.all_input_ids)
def __getitem__(self, idx):
return (
np.array(self.all_input_ids[idx]),
np.array(self.all_segment_ids[idx]),
np.array(self.all_input_mask[idx], dtype=np.long),
np.array(self.all_subtokens_mask[idx]),
)
@@ -0,0 +1,118 @@
# 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.
import os
import pickle
from typing import Dict
from nemo.collections.nlp.data.data_utils.data_preprocessing import (
fill_class_weights,
get_freq_weights,
get_label_stats,
)
from nemo.utils import logging
__all__ = ['get_label_ids']
def get_label_ids(
label_file: str,
is_training: bool = False,
pad_label: str = 'O',
label_ids_dict: Dict[str, int] = None,
get_weights: bool = True,
class_labels_file_artifact='label_ids.csv',
):
"""
Generates str to int labels mapping for training data or checks correctness of the label_ids_dict
file for non-training files or if label_ids_dict is specified
Args:
label_file: the path of the label file to process
is_training: indicates whether the label_file is used for training
pad_label: token used for padding
label_ids_dict: str label name to int ids mapping. Required for non-training data.
If specified, the check that all labels from label_file are present in label_ids_dict will be performed.
For training data, if label_ids_dict is None, a new mapping will be generated from label_file.
get_weights: set to True to calculate class weights, required for Weighted Loss.
class_labels_file_artifact: name of the file to save in .nemo
"""
if not os.path.exists(label_file):
raise ValueError(f'File {label_file} was not found.')
logging.info(f'Processing {label_file}')
if not is_training and label_ids_dict is None:
raise ValueError(
f'For non training data, label_ids_dict created during preprocessing of the training data '
f'should be provided'
)
# collect all labels from the label_file
data_dir = os.path.dirname(label_file)
unique_labels = set(pad_label)
all_labels = []
with open(label_file, 'r') as f:
for line in f:
line = line.strip().split()
all_labels.extend(line)
unique_labels.update(line)
# check that all labels from label_file are present in the specified label_ids_dict
# or generate label_ids_dict from data (for training only)
if label_ids_dict:
logging.info(f'Using provided labels mapping {label_ids_dict}')
for name in unique_labels:
if name not in label_ids_dict:
raise ValueError(f'{name} class from {label_file} not found in the provided mapping: {label_ids_dict}')
else:
label_ids_dict = {pad_label: 0}
if pad_label in unique_labels:
unique_labels.remove(pad_label)
for label in sorted(unique_labels):
label_ids_dict[label] = len(label_ids_dict)
label_ids_filename = os.path.join(data_dir, class_labels_file_artifact)
if is_training:
with open(label_ids_filename, 'w') as f:
labels, _ = zip(*sorted(label_ids_dict.items(), key=lambda x: x[1]))
f.write('\n'.join(labels))
logging.info(f'Labels mapping {label_ids_dict} saved to : {label_ids_filename}')
# calculate label statistics
base_name = os.path.splitext(os.path.basename(label_file))[0]
stats_file = os.path.join(data_dir, f'{base_name}_label_stats.tsv')
if os.path.exists(stats_file) and not is_training and not get_weights:
logging.info(f'{stats_file} found, skipping stats calculation.')
else:
all_labels = [label_ids_dict[label] for label in all_labels]
logging.info(f'Three most popular labels in {label_file}:')
total_labels, label_frequencies, max_id = get_label_stats(all_labels, stats_file)
logging.info(f'Total labels: {total_labels}. Label frequencies - {label_frequencies}')
if get_weights:
class_weights_pkl = os.path.join(data_dir, f'{base_name}_weights.p')
if os.path.exists(class_weights_pkl):
class_weights = pickle.load(open(class_weights_pkl, 'rb'))
logging.info(f'Class weights restored from {class_weights_pkl}')
else:
class_weights_dict = get_freq_weights(label_frequencies)
logging.info(f'Class Weights: {class_weights_dict}')
class_weights = fill_class_weights(class_weights_dict, max_id)
pickle.dump(class_weights, open(class_weights_pkl, "wb"))
logging.info(f'Class weights saved to {class_weights_pkl}')
else:
class_weights = None
return label_ids_dict, label_ids_filename, class_weights