forked from joshualoehr/ngram-language-model
-
Notifications
You must be signed in to change notification settings - Fork 0
/
language_model.py
228 lines (171 loc) · 8.74 KB
/
language_model.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
#!/bin/env python
import argparse
from itertools import product
import math
import nltk
from pathlib import Path
from preprocess import preprocess
def load_data(data_dir):
"""Load train and test corpora from a directory.
Directory must contain two files: train.txt and test.txt.
Newlines will be stripped out.
Args:
data_dir (Path) -- pathlib.Path of the directory to use.
Returns:
The train and test sets, as lists of sentences.
"""
train_path = data_dir.joinpath('train.txt').absolute().as_posix()
test_path = data_dir.joinpath('test.txt').absolute().as_posix()
with open(train_path, 'r') as f:
train = [l.strip() for l in f.readlines()]
with open(test_path, 'r') as f:
test = [l.strip() for l in f.readlines()]
return train, test
class LanguageModel(object):
"""An n-gram language model trained on a given corpus.
For a given n and given training corpus, constructs an n-gram language
model for the corpus by:
1. preprocessing the corpus (adding SOS/EOS/UNK tokens)
2. calculating (smoothed) probabilities for each n-gram
Also contains methods for calculating the perplexity of the model
against another corpus, and for generating sentences.
Args:
train_data (list of str): list of sentences comprising the training corpus.
n (int): the order of language model to build (i.e. 1 for unigram, 2 for bigram, etc.).
laplace (int): lambda multiplier to use for laplace smoothing (default 1 for add-1 smoothing).
"""
def __init__(self, train_data, n, laplace=1):
self.n = n
self.laplace = laplace
self.tokens = preprocess(train_data, n)
self.vocab = nltk.FreqDist(self.tokens)
self.model = self._create_model()
self.masks = list(reversed(list(product((0,1), repeat=n))))
def _smooth(self):
"""Apply Laplace smoothing to n-gram frequency distribution.
Here, n_grams refers to the n-grams of the tokens in the training corpus,
while m_grams refers to the first (n-1) tokens of each n-gram.
Returns:
dict: Mapping of each n-gram (tuple of str) to its Laplace-smoothed
probability (float).
"""
vocab_size = len(self.vocab)
n_grams = nltk.ngrams(self.tokens, self.n)
n_vocab = nltk.FreqDist(n_grams)
m_grams = nltk.ngrams(self.tokens, self.n-1)
m_vocab = nltk.FreqDist(m_grams)
def smoothed_count(n_gram, n_count):
m_gram = n_gram[:-1]
m_count = m_vocab[m_gram]
return (n_count + self.laplace) / (m_count + self.laplace * vocab_size)
return { n_gram: smoothed_count(n_gram, count) for n_gram, count in n_vocab.items() }
def _create_model(self):
"""Create a probability distribution for the vocabulary of the training corpus.
If building a unigram model, the probabilities are simple relative frequencies
of each token with the entire corpus.
Otherwise, the probabilities are Laplace-smoothed relative frequencies.
Returns:
A dict mapping each n-gram (tuple of str) to its probability (float).
"""
if self.n == 1:
num_tokens = len(self.tokens)
return { (unigram,): count / num_tokens for unigram, count in self.vocab.items() }
else:
return self._smooth()
def _convert_oov(self, ngram):
"""Convert, if necessary, a given n-gram to one which is known by the model.
Starting with the unmodified ngram, check each possible permutation of the n-gram
with each index of the n-gram containing either the original token or <UNK>. Stop
when the model contains an entry for that permutation.
This is achieved by creating a 'bitmask' for the n-gram tuple, and swapping out
each flagged token for <UNK>. Thus, in the worst case, this function checks 2^n
possible n-grams before returning.
Returns:
The n-gram with <UNK> tokens in certain positions such that the model
contains an entry for it.
"""
mask = lambda ngram, bitmask: tuple((token if flag == 1 else "<UNK>" for token,flag in zip(ngram, bitmask)))
ngram = (ngram,) if type(ngram) is str else ngram
for possible_known in [mask(ngram, bitmask) for bitmask in self.masks]:
if possible_known in self.model:
return possible_known
def perplexity(self, test_data):
"""Calculate the perplexity of the model against a given test corpus.
Args:
test_data (list of str): sentences comprising the training corpus.
Returns:
The perplexity of the model as a float.
"""
test_tokens = preprocess(test_data, self.n)
test_ngrams = nltk.ngrams(test_tokens, self.n)
N = len(test_tokens)
known_ngrams = (self._convert_oov(ngram) for ngram in test_ngrams)
probabilities = [self.model[ngram] for ngram in known_ngrams]
return math.exp((-1/N) * sum(map(math.log, probabilities)))
def _best_candidate(self, prev, i, without=[]):
"""Choose the most likely next token given the previous (n-1) tokens.
If selecting the first word of the sentence (after the SOS tokens),
the i'th best candidate will be selected, to create variety.
If no candidates are found, the EOS token is returned with probability 1.
Args:
prev (tuple of str): the previous n-1 tokens of the sentence.
i (int): which candidate to select if not the most probable one.
without (list of str): tokens to exclude from the candidates list.
Returns:
A tuple with the next most probable token and its corresponding probability.
"""
blacklist = ["<UNK>"] + without
candidates = ((ngram[-1],prob) for ngram,prob in self.model.items() if ngram[:-1]==prev)
candidates = filter(lambda candidate: candidate[0] not in blacklist, candidates)
candidates = sorted(candidates, key=lambda candidate: candidate[1], reverse=True)
if len(candidates) == 0:
return ("</s>", 1)
else:
return candidates[0 if prev != () and prev[-1] != "<s>" else i]
def generate_sentences(self, num, min_len=12, max_len=24):
"""Generate num random sentences using the language model.
Sentences always begin with the SOS token and end with the EOS token.
While unigram model sentences will only exclude the UNK token, n>1 models
will also exclude all other words already in the sentence.
Args:
num (int): the number of sentences to generate.
min_len (int): minimum allowed sentence length.
max_len (int): maximum allowed sentence length.
Yields:
A tuple with the generated sentence and the combined probability
(in log-space) of all of its n-grams.
"""
for i in range(num):
sent, prob = ["<s>"] * max(1, self.n-1), 1
while sent[-1] != "</s>":
prev = () if self.n == 1 else tuple(sent[-(self.n-1):])
blacklist = sent + (["</s>"] if len(sent) < min_len else [])
next_token, next_prob = self._best_candidate(prev, i, without=blacklist)
sent.append(next_token)
prob *= next_prob
if len(sent) >= max_len:
sent.append("</s>")
yield ' '.join(sent), -1/math.log(prob)
if __name__ == '__main__':
parser = argparse.ArgumentParser("N-gram Language Model")
parser.add_argument('--data', type=str, required=True,
help='Location of the data directory containing train.txt and test.txt')
parser.add_argument('--n', type=int, required=True,
help='Order of N-gram model to create (i.e. 1 for unigram, 2 for bigram, etc.)')
parser.add_argument('--laplace', type=float, default=0.01,
help='Lambda parameter for Laplace smoothing (default is 0.01 -- use 1 for add-1 smoothing)')
parser.add_argument('--num', type=int, default=10,
help='Number of sentences to generate (default 10)')
args = parser.parse_args()
# Load and prepare train/test data
data_path = Path(args.data)
train, test = load_data(data_path)
print("Loading {}-gram model...".format(args.n))
lm = LanguageModel(train, args.n, laplace=args.laplace)
print("Vocabulary size: {}".format(len(lm.vocab)))
print("Generating sentences...")
for sentence, prob in lm.generate_sentences(args.num):
print("{} ({:.5f})".format(sentence, prob))
perplexity = lm.perplexity(test)
print("Model perplexity: {:.3f}".format(perplexity))
print("")