textslinger 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- textslinger/__init__.py +0 -0
- textslinger/causal.py +437 -0
- textslinger/classifier.py +227 -0
- textslinger/exceptions.py +25 -0
- textslinger/language_model.py +52 -0
- textslinger/mixture.py +126 -0
- textslinger/ngram.py +110 -0
- textslinger/uniform.py +65 -0
- textslinger-0.1.0.dist-info/METADATA +84 -0
- textslinger-0.1.0.dist-info/RECORD +14 -0
- textslinger-0.1.0.dist-info/WHEEL +5 -0
- textslinger-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- textslinger-0.1.0.dist-info/top_level.txt +1 -0
- textslinger-0.1.0.dist-info/zip-safe +1 -0
textslinger/__init__.py
ADDED
|
File without changes
|
textslinger/causal.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from typing import List, Tuple
|
|
3
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
4
|
+
import itertools
|
|
5
|
+
import heapq
|
|
6
|
+
from textslinger.language_model import LanguageModel, DEFAULT_SYMBOL_SET
|
|
7
|
+
from textslinger.exceptions import InvalidLanguageModelException
|
|
8
|
+
from scipy.special import logsumexp
|
|
9
|
+
from scipy.special import softmax
|
|
10
|
+
import time
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CausalLanguageModel(LanguageModel):
|
|
16
|
+
"""Character language model based on a pre-trained causal model, GPT-2 by default."""
|
|
17
|
+
|
|
18
|
+
def __init__(self,
|
|
19
|
+
symbol_set: List[str],
|
|
20
|
+
lang_model_name: str,
|
|
21
|
+
lm_path: str = None,
|
|
22
|
+
lm_device: str = "cpu",
|
|
23
|
+
lm_left_context: str = "",
|
|
24
|
+
beam_width: int = None,
|
|
25
|
+
fp16: bool = False,
|
|
26
|
+
mixed_case_context: bool = False,
|
|
27
|
+
case_simple: bool = False,
|
|
28
|
+
max_completed: int = None,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Initialize instance variables and load the language model with given path
|
|
32
|
+
Args:
|
|
33
|
+
symbol_set - list of symbol strings
|
|
34
|
+
lang_model_name - name of the Hugging Face casual language model to load
|
|
35
|
+
lm_path - load fine-tuned model from specified directory
|
|
36
|
+
lm_device - device to use for making predictions (cpu, mps, or cuda)
|
|
37
|
+
lm_left_context - text to condition start of sentence on
|
|
38
|
+
beam_width - how many hypotheses to keep during the search, None=off
|
|
39
|
+
fp16 - convert model to fp16 to save memory/compute on CUDA
|
|
40
|
+
mixed_case_context - use mixed case for language model left context
|
|
41
|
+
case_simple - simple fixing of left context case
|
|
42
|
+
max_completed - stop search once we reach this many completed hypotheses, None=don't prune
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(symbol_set=symbol_set)
|
|
45
|
+
self.model = None
|
|
46
|
+
self.tokenizer = None
|
|
47
|
+
self.vocab_size = 0
|
|
48
|
+
self.valid_vocab = []
|
|
49
|
+
self.vocab = defaultdict(list)
|
|
50
|
+
# Since subword token ids are integers, use a list instead of a dictionary
|
|
51
|
+
self.index_to_word = []
|
|
52
|
+
self.index_to_word_lower = []
|
|
53
|
+
self.symbol_set_lower = None
|
|
54
|
+
self.device = lm_device
|
|
55
|
+
self.left_context = lm_left_context
|
|
56
|
+
self.fp16 = fp16
|
|
57
|
+
self.mixed_case_context = mixed_case_context
|
|
58
|
+
self.case_simple = case_simple
|
|
59
|
+
self.max_completed = max_completed
|
|
60
|
+
|
|
61
|
+
if not max_completed and not beam_width:
|
|
62
|
+
print(f"WARNING: using causal language model without any pruning, this can be slow!")
|
|
63
|
+
else:
|
|
64
|
+
print(f"Causal language model, beam_width {beam_width}, max_completed {max_completed}")
|
|
65
|
+
|
|
66
|
+
# We optionally load the model from a local directory, but if this is not
|
|
67
|
+
# specified, we load a Hugging Face model
|
|
68
|
+
self.model_name = lang_model_name
|
|
69
|
+
self.model_dir = lm_path if lm_path else self.model_name
|
|
70
|
+
|
|
71
|
+
# Parameters for the search
|
|
72
|
+
self.beam_width = beam_width
|
|
73
|
+
|
|
74
|
+
# Simple heuristic to correct case in the LM context
|
|
75
|
+
self.simple_upper_words = {"i": "I",
|
|
76
|
+
"i'll": "I'll",
|
|
77
|
+
"i've": "I've",
|
|
78
|
+
"i'd": "I'd",
|
|
79
|
+
"i'm": "I'm"}
|
|
80
|
+
|
|
81
|
+
# Track how much time spent in different parts of the predict function
|
|
82
|
+
self.predict_total_ns = 0
|
|
83
|
+
self.predict_inference_ns = 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Are we a model that automatically inserts a start token that we need to get rid of
|
|
87
|
+
self.drop_first_token = False
|
|
88
|
+
|
|
89
|
+
self.load()
|
|
90
|
+
|
|
91
|
+
def _build_vocab(self) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Build a vocabulary table mapping token index to word strings
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Loop over all the subword tokens in the LLM
|
|
97
|
+
for i in range(self.vocab_size):
|
|
98
|
+
# Create a map from the subword token integer ID to the mixed and lowercase string versions
|
|
99
|
+
word = self.tokenizer.decode([i])
|
|
100
|
+
word_lower = word.lower()
|
|
101
|
+
self.index_to_word += word,
|
|
102
|
+
self.index_to_word_lower += word_lower,
|
|
103
|
+
|
|
104
|
+
# Check if all the characters in the subword token are in our valid symbol set
|
|
105
|
+
valid = True
|
|
106
|
+
for ch in word_lower:
|
|
107
|
+
if ch == ' ':
|
|
108
|
+
continue
|
|
109
|
+
elif ch not in self.symbol_set_lower:
|
|
110
|
+
valid = False
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
# If the subword token symbols are all valid, then add it to the list of valid token IDs
|
|
114
|
+
if valid:
|
|
115
|
+
self.valid_vocab += i,
|
|
116
|
+
# Add this token ID to all lists for its valid text prefixes
|
|
117
|
+
for j in range(len(word)):
|
|
118
|
+
key = word_lower[0:j + 1]
|
|
119
|
+
self.vocab[key] += i,
|
|
120
|
+
|
|
121
|
+
# When done, self.vocab can be used to map to possible following subword tokens given some text, e.g.:
|
|
122
|
+
# self.vocab["cyclo"] = [47495, 49484]
|
|
123
|
+
# self.index_to_word[self.vocab["cyclo"][0]] = cyclop
|
|
124
|
+
# self.index_to_word[self.vocab["cyclo"][1]] = cyclopedia
|
|
125
|
+
|
|
126
|
+
(self.model_name.startswith("facebook/opt") or self.model_name.startswith("figmtu/opt") or "Llama-3.1" in self.model_name)
|
|
127
|
+
|
|
128
|
+
# Get the index we use for the start or end pseudo-word
|
|
129
|
+
if self.left_context == "":
|
|
130
|
+
if "gpt2" in self.model_name:
|
|
131
|
+
self.left_context = "<|endoftext|>"
|
|
132
|
+
elif "Llama-3.1" in self.model_name:
|
|
133
|
+
self.left_context = "<|begin_of_text|>"
|
|
134
|
+
# Seems to have both sentence start and end tokens: https://docs.mistral.ai/guides/tokenization/
|
|
135
|
+
elif "Mistral" in self.model_name:
|
|
136
|
+
self.left_context = "<s>"
|
|
137
|
+
else:
|
|
138
|
+
self.left_context = "</s>"
|
|
139
|
+
|
|
140
|
+
# OPT, Llama and Mistral all insert start token
|
|
141
|
+
self.drop_first_token = (self.model_name.startswith("facebook/opt") or
|
|
142
|
+
self.model_name.startswith("figmtu/opt") or
|
|
143
|
+
"Llama-3.1" in self.model_name or
|
|
144
|
+
"Mistral" in self.model_name)
|
|
145
|
+
|
|
146
|
+
# Get token id(s) for the left context we condition all sentences on
|
|
147
|
+
self.left_context_tokens = self._encode(self.left_context)
|
|
148
|
+
print(f"Causal: left_context = '{self.left_context}', left_context_tokens = {self.left_context_tokens}")
|
|
149
|
+
|
|
150
|
+
def _encode(self, text: str) -> List[int]:
|
|
151
|
+
tokens = self.tokenizer.encode(text)
|
|
152
|
+
# Both OPT and Llama automatically insert a start token which we want to control ourselves
|
|
153
|
+
if len(tokens) > 1 and self.drop_first_token:
|
|
154
|
+
tokens = tokens[1:]
|
|
155
|
+
|
|
156
|
+
return tokens
|
|
157
|
+
|
|
158
|
+
def _sequence_string(self, sequence: List[int]) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Convert a sequence of subword token IDs into a string with each token in ()'s
|
|
161
|
+
:param sequence: List of subword token IDs
|
|
162
|
+
:return: String
|
|
163
|
+
"""
|
|
164
|
+
return "".join([f"({self.index_to_word[x]})" for x in sequence])
|
|
165
|
+
|
|
166
|
+
def get_all_tokens_text(self):
|
|
167
|
+
"""
|
|
168
|
+
Return an array with the text of all subword tokens.
|
|
169
|
+
The array is in order by the integer index into the vocabulary.
|
|
170
|
+
This is mostly just for exploring the tokens in different LLMs.
|
|
171
|
+
:return: Array of subword token text strings.
|
|
172
|
+
"""
|
|
173
|
+
result = []
|
|
174
|
+
for i in range(self.vocab_size):
|
|
175
|
+
result.append(self.tokenizer.decode([i]))
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def predict(self, evidence: List[str]) -> List[Tuple]:
|
|
179
|
+
"""
|
|
180
|
+
Given an evidence of typed string, predict the probability distribution of
|
|
181
|
+
the next symbol
|
|
182
|
+
Args:
|
|
183
|
+
evidence - a list of characters (typed by the user)
|
|
184
|
+
Response:
|
|
185
|
+
A list of symbols with probability
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
assert self.model is not None, "language model does not exist!"
|
|
189
|
+
start_ns = time.time_ns()
|
|
190
|
+
|
|
191
|
+
context = "".join(evidence)
|
|
192
|
+
|
|
193
|
+
# If using the simple case feature, we need to go through the actual
|
|
194
|
+
# left context and capitalize the first letter in the sentence as
|
|
195
|
+
# well as any word in our list of words that should be capitalized.
|
|
196
|
+
if self.case_simple and len(context) > 0:
|
|
197
|
+
cased_context = ""
|
|
198
|
+
words = context.split()
|
|
199
|
+
for i, word in enumerate(words):
|
|
200
|
+
if i == 0 and word[0] >= 'a' and word[0] <= 'z':
|
|
201
|
+
word = word[0].upper() + word[1:]
|
|
202
|
+
if i > 0:
|
|
203
|
+
if word in self.simple_upper_words:
|
|
204
|
+
word = self.simple_upper_words[word]
|
|
205
|
+
cased_context += " "
|
|
206
|
+
cased_context += word
|
|
207
|
+
# Handle ending space in the context
|
|
208
|
+
if context[-1] == ' ':
|
|
209
|
+
cased_context += " "
|
|
210
|
+
context = cased_context
|
|
211
|
+
|
|
212
|
+
context_lower = context.lower()
|
|
213
|
+
|
|
214
|
+
# Index in the hypothesis string that is the next character after our context
|
|
215
|
+
target_pos = len(context_lower)
|
|
216
|
+
|
|
217
|
+
# For stats purposes track length of the prefix we are extending from space to match
|
|
218
|
+
# prefix_len = target_pos
|
|
219
|
+
|
|
220
|
+
# Look for the last space in the context, or -1 if no begin_text in context yet
|
|
221
|
+
pos = context_lower.rfind(" ")
|
|
222
|
+
tokens = []
|
|
223
|
+
tokens.extend(self.left_context_tokens)
|
|
224
|
+
if pos >= 0:
|
|
225
|
+
# Optionally, we condition on upper and lower case left context
|
|
226
|
+
if self.mixed_case_context:
|
|
227
|
+
truncated_context = context[0:pos]
|
|
228
|
+
else:
|
|
229
|
+
truncated_context = context_lower[0:pos]
|
|
230
|
+
tokens.extend(self._encode(truncated_context))
|
|
231
|
+
#prefix_len -= pos
|
|
232
|
+
|
|
233
|
+
#print(f"DEBUG, {context_lower} pos {pos}, prefix_len {prefix_len}")
|
|
234
|
+
|
|
235
|
+
# Constant indexes for use with the hypotheses tuples
|
|
236
|
+
LOGP: Final[int] = 0
|
|
237
|
+
SEQ: Final[int] = 1
|
|
238
|
+
LEN: Final[int] = 2
|
|
239
|
+
|
|
240
|
+
# Our starting hypothesis that we'll be extending.
|
|
241
|
+
# Format is (log likelihood, token id sequence, text length).
|
|
242
|
+
# Note: we only include tokens after any in left context.
|
|
243
|
+
start_length = 0
|
|
244
|
+
for x in tokens[len(self.left_context_tokens):]:
|
|
245
|
+
start_length += len(self.index_to_word_lower[x])
|
|
246
|
+
current_hypos = [(0.0, tokens, start_length)]
|
|
247
|
+
|
|
248
|
+
# We use a priority queue to track the top hypotheses during the beam search.
|
|
249
|
+
# For a beam of 8, empirical testing showed this was about the same amount
|
|
250
|
+
# of time as a simpler list that used a linear search to replace when full.
|
|
251
|
+
heapq.heapify(current_hypos)
|
|
252
|
+
|
|
253
|
+
# Create a hash mapping each valid following character to a list of log probabilities
|
|
254
|
+
char_to_log_probs = defaultdict(list)
|
|
255
|
+
|
|
256
|
+
# Add new extended hypotheses to this heap
|
|
257
|
+
next_hypos = []
|
|
258
|
+
|
|
259
|
+
# Tracks count of completed hypotheses
|
|
260
|
+
completed = 0
|
|
261
|
+
|
|
262
|
+
# Used to signal to while loop to stop the search
|
|
263
|
+
done = False
|
|
264
|
+
|
|
265
|
+
# Start a beam search forward from the backed off token sequence.
|
|
266
|
+
# Each iteration of this while loop extends hypotheses by all valid tokens.
|
|
267
|
+
# We only keep at most self.beam_width hypotheses in the valid heap.
|
|
268
|
+
# Stop extending search once we reach our max completed target.
|
|
269
|
+
while len(current_hypos) > 0 and not done:
|
|
270
|
+
# We'll explore hypothesis in order from most probable to least.
|
|
271
|
+
# This has little impact on how long it takes since this is only sorting a small number of things.
|
|
272
|
+
# But it is important with max_completed pruning since we want to bias for completing high probability things.
|
|
273
|
+
current_hypos.sort(reverse=True)
|
|
274
|
+
|
|
275
|
+
# Work on the hypotheses from the last round of extension.
|
|
276
|
+
# Create the torch tensor for the inference with a row for each hypothesis.
|
|
277
|
+
tokens_tensor = torch.tensor([x[SEQ] for x in current_hypos]).reshape(len(current_hypos), -1).to(self.device)
|
|
278
|
+
|
|
279
|
+
before_inference_ns = time.time_ns()
|
|
280
|
+
# Ask the LLM to predict tokens that come after our current set of hypotheses
|
|
281
|
+
with torch.no_grad():
|
|
282
|
+
# Compute the probabilities from the logits
|
|
283
|
+
log_probs = torch.log_softmax(self.model(tokens_tensor).logits[:, -1, :], dim=1)
|
|
284
|
+
|
|
285
|
+
# Create a big 2D tensor where each row is that hypothesis' current likelihood.
|
|
286
|
+
# First create a list of just the hypotheses' likelihoods.
|
|
287
|
+
# Then reshape to be a column vector.
|
|
288
|
+
# Then duplicate the column based on the number of subword tokens in the LLM.
|
|
289
|
+
add_tensor = torch.tensor([x[LOGP] for x in current_hypos]).reshape((log_probs.size()[0], 1)).repeat(1, log_probs.size()[1]).to(self.device)
|
|
290
|
+
|
|
291
|
+
# Add the current likelihoods with each subtoken's probability.
|
|
292
|
+
# Move it back to the CPU and convert to numpy since this makes it a lot faster to access for some reason.
|
|
293
|
+
new_log_probs = torch.add(log_probs, add_tensor).detach().cpu().numpy()
|
|
294
|
+
self.predict_inference_ns += time.time_ns() - before_inference_ns
|
|
295
|
+
|
|
296
|
+
for current_index, current in enumerate(current_hypos):
|
|
297
|
+
vocab = []
|
|
298
|
+
extra_vocab = []
|
|
299
|
+
# Extending this hypothesis must match the remaining text
|
|
300
|
+
remaining_context = context_lower[current[LEN]:]
|
|
301
|
+
if len(remaining_context) == 0:
|
|
302
|
+
# There is no remaining context thus all subword tokens that are valid under our symbol set
|
|
303
|
+
# should be considered when computing the probability of the next character.
|
|
304
|
+
vocab = self.valid_vocab
|
|
305
|
+
else:
|
|
306
|
+
if remaining_context in self.vocab:
|
|
307
|
+
# We have a list of subword tokens that match the remaining text.
|
|
308
|
+
# They could be the same length as the remaining text or longer and have the remaining text as a prefix.
|
|
309
|
+
vocab = self.vocab[remaining_context]
|
|
310
|
+
|
|
311
|
+
# We may need to use a subword token that doesn't completely consume the remaining text.
|
|
312
|
+
# Find these by tokenizing all possible lengths of text starting from the current position.
|
|
313
|
+
for i in range(1, len(remaining_context)):
|
|
314
|
+
tokenization = self._encode(context_lower[current[LEN]:current[LEN] + i])
|
|
315
|
+
# Ignore tokenizations involving multiple tokens since they involve an ID we would have already added.
|
|
316
|
+
if len(tokenization) == 1:
|
|
317
|
+
extra_vocab += tokenization[0],
|
|
318
|
+
|
|
319
|
+
# The below code takes the most time, results from pprofile on 5 phrases on an 2080 GPU:
|
|
320
|
+
# 299| 22484582| 89.5763| 3.9839e-06| 14.24%| for token_id in itertools.chain(vocab, extra_vocab):
|
|
321
|
+
# 300| 0| 0| 0| 0.00%| # For a hypothesis to finish it must extend beyond the existing typed context
|
|
322
|
+
# 301| 22483271| 93.7939| 4.17172e-06| 14.91%| subword_len = len(self.index_to_word_lower[token_id])
|
|
323
|
+
# 302| 22483271| 92.8608| 4.13022e-06| 14.76%| if (current[LEN] + subword_len) > len(context):
|
|
324
|
+
# 303| 0| 0| 0| 0.00%| # Add this likelihood to the list for the character at the prediction position.
|
|
325
|
+
# 304| 0| 0| 0| 0.00%| # Tracking the list and doing logsumpexp later was faster than doing it for each add.
|
|
326
|
+
# 305| 22480431| 106.353| 4.73094e-06| 16.90%| char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id],
|
|
327
|
+
# 306| 22480431| 92.689| 4.1231e-06| 14.73%| completed += 1
|
|
328
|
+
# 307| 2840| 0.0124488| 4.38338e-06| 0.00%| elif not self.beam_width or len(next_hypos) <
|
|
329
|
+
#
|
|
330
|
+
# Tuning notes:
|
|
331
|
+
# - With a beam of 8 and max completed of 32,000, getting around 5x speedup on written dev set.
|
|
332
|
+
# - This results in a PPL increase of 0.0025 versus old results using only beam of >= 8.
|
|
333
|
+
# - Pruning based on log probability difference and based on minimum number of hypotheses per symbol in alphabet did worse.
|
|
334
|
+
# - Code for these other pruning methods was removed.
|
|
335
|
+
# Possible ways to make it faster:
|
|
336
|
+
# - Stop part way through the below for loop over (vocab, extra_vocab). But this seems weird since the token IDs are in
|
|
337
|
+
# no particular order, we'd be just stopping early on the last hypothesis being explored by the enclosing loop.
|
|
338
|
+
# - Sort the rows in the log prob results on the GPU. Use these to limit which token IDs we explore in the below
|
|
339
|
+
# for loop. Is it possible to do this without introducing too much extra work to limit to the high probability ones?
|
|
340
|
+
|
|
341
|
+
# Create a list of token indexes that are a prefix of the target text.
|
|
342
|
+
# We go over all the integer IDs in the vocab and extra_vocab lists.
|
|
343
|
+
for token_id in itertools.chain(vocab, extra_vocab):
|
|
344
|
+
# For a hypothesis to finish it must extend beyond the existing typed context
|
|
345
|
+
subword_len = len(self.index_to_word_lower[token_id])
|
|
346
|
+
if (current[LEN] + subword_len) > len(context):
|
|
347
|
+
# Add this likelihood to the list for the character at the prediction position.
|
|
348
|
+
# Tracking the list and doing logsumpexp later was faster than doing it for each add.
|
|
349
|
+
char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id],
|
|
350
|
+
completed += 1
|
|
351
|
+
elif not self.beam_width or len(next_hypos) < self.beam_width:
|
|
352
|
+
# If we are under the beam limit then just add it
|
|
353
|
+
heapq.heappush(next_hypos,
|
|
354
|
+
(new_log_probs[current_index][token_id],
|
|
355
|
+
current[SEQ] + [token_id],
|
|
356
|
+
current[LEN] + subword_len))
|
|
357
|
+
elif new_log_probs[current_index][token_id] > next_hypos[0][LOGP]:
|
|
358
|
+
# Or replace the worst hypotheses with the new one
|
|
359
|
+
heapq.heappushpop(next_hypos,
|
|
360
|
+
(new_log_probs[current_index][token_id],
|
|
361
|
+
current[SEQ] + [token_id],
|
|
362
|
+
current[LEN] + subword_len))
|
|
363
|
+
|
|
364
|
+
# Break out of the for loop over hypotheses and while loop if we reach our max completed goal
|
|
365
|
+
if self.max_completed and completed >= self.max_completed:
|
|
366
|
+
done = True
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
# Swap in the extended set as the new current working set
|
|
370
|
+
current_hypos = next_hypos
|
|
371
|
+
next_hypos = []
|
|
372
|
+
|
|
373
|
+
# Parallel array to symbol_set for storing the marginals
|
|
374
|
+
char_probs = []
|
|
375
|
+
for ch in self.symbol_set_lower:
|
|
376
|
+
# Handle cases when symbols are never seen
|
|
377
|
+
if ch in char_to_log_probs:
|
|
378
|
+
char_probs += logsumexp(char_to_log_probs[ch]),
|
|
379
|
+
else:
|
|
380
|
+
char_probs += float("-inf"),
|
|
381
|
+
|
|
382
|
+
# Normalize to a distribution that sums to 1
|
|
383
|
+
char_probs = softmax(char_probs)
|
|
384
|
+
|
|
385
|
+
next_char_pred = {}
|
|
386
|
+
for i, ch in enumerate(self.symbol_set_lower):
|
|
387
|
+
# This works even if ch is a space
|
|
388
|
+
next_char_pred[ch.upper()] = char_probs[i]
|
|
389
|
+
|
|
390
|
+
end_ns = time.time_ns()
|
|
391
|
+
self.predict_total_ns += end_ns - start_ns
|
|
392
|
+
|
|
393
|
+
return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
|
|
394
|
+
|
|
395
|
+
def dump_predict_times(self) -> None:
|
|
396
|
+
"""Print some stats about the prediction timing"""
|
|
397
|
+
if self.predict_total_ns > 0:
|
|
398
|
+
print(f"Predict %: "
|
|
399
|
+
f"inference {self.predict_inference_ns / self.predict_total_ns * 100.0:.3f}")
|
|
400
|
+
|
|
401
|
+
def load(self) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Load the language model and tokenizer, initialize class variables
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
|
|
407
|
+
except BaseException:
|
|
408
|
+
raise InvalidLanguageModelException(f"{self.model_name} is not a valid model identifier on HuggingFace.")
|
|
409
|
+
self.vocab_size = self.tokenizer.vocab_size
|
|
410
|
+
try:
|
|
411
|
+
self.model = AutoModelForCausalLM.from_pretrained(self.model_dir)
|
|
412
|
+
if self.fp16 and self.device == "cuda":
|
|
413
|
+
self.model = self.model.half()
|
|
414
|
+
except:
|
|
415
|
+
raise InvalidLanguageModelException(f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.")
|
|
416
|
+
|
|
417
|
+
self.model.eval()
|
|
418
|
+
|
|
419
|
+
self.model.to(self.device)
|
|
420
|
+
|
|
421
|
+
self.symbol_set_lower = []
|
|
422
|
+
|
|
423
|
+
for ch in self.symbol_set:
|
|
424
|
+
# This works even if ch is a space
|
|
425
|
+
self.symbol_set_lower.append(ch.lower())
|
|
426
|
+
|
|
427
|
+
self._build_vocab()
|
|
428
|
+
|
|
429
|
+
def get_num_parameters(self) -> int:
|
|
430
|
+
"""
|
|
431
|
+
Find out how many parameters the loaded model has
|
|
432
|
+
Args:
|
|
433
|
+
Response:
|
|
434
|
+
Integer number of parameters in the transformer model
|
|
435
|
+
"""
|
|
436
|
+
return sum(p.numel() for p in self.model.parameters())
|
|
437
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
import torch
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
5
|
+
from textslinger.language_model import LanguageModel
|
|
6
|
+
from textslinger.exceptions import InvalidLanguageModelException, WordPredictionsNotSupportedException
|
|
7
|
+
from scipy.special import softmax
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClassifierLanguageModel(LanguageModel):
|
|
11
|
+
"""Character language model based on a transformer with a classification head"""
|
|
12
|
+
|
|
13
|
+
def __init__(self,
|
|
14
|
+
symbol_set: List[str],
|
|
15
|
+
lang_model_name: str,
|
|
16
|
+
lm_path: str = None,
|
|
17
|
+
lm_device: str = "cpu",
|
|
18
|
+
lm_left_context: str = "",
|
|
19
|
+
beam_width: int = 8,
|
|
20
|
+
batch_size: int = 8,
|
|
21
|
+
fp16: bool = False,
|
|
22
|
+
mixed_case_context = False,
|
|
23
|
+
case_simple = False,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Initialize instance variables and load the language model with given path
|
|
27
|
+
Args:
|
|
28
|
+
response_type - SYMBOL only
|
|
29
|
+
symbol_set - list of symbol strings
|
|
30
|
+
lang_model_name - name of the Hugging Face casual language model to load
|
|
31
|
+
lm_path - load fine-tuned model from specified directory
|
|
32
|
+
lm_device - device to use for making predictions (cpu, mps, or cuda)
|
|
33
|
+
lm_left_context - text to condition start of sentence on
|
|
34
|
+
beam_width - how many hypotheses to keep during the search
|
|
35
|
+
batch_size - how many sequences to pass in at a time during inference
|
|
36
|
+
fp16 - convert model to fp16 to save memory/compute on CUDA
|
|
37
|
+
mixed_case_context - use mixed case for language model left context
|
|
38
|
+
case_simple - simple fixing of left context case
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(symbol_set=symbol_set)
|
|
41
|
+
self.model = None
|
|
42
|
+
self.tokenizer = None
|
|
43
|
+
self.vocab_size = 0
|
|
44
|
+
self.valid_vocab = []
|
|
45
|
+
self.vocab = {}
|
|
46
|
+
self.longest_token = 0
|
|
47
|
+
self.index_to_word = {}
|
|
48
|
+
self.index_to_word_lower = {}
|
|
49
|
+
self.symbol_set_lower = None
|
|
50
|
+
self.device = lm_device
|
|
51
|
+
self.left_context = lm_left_context
|
|
52
|
+
self.fp16 = fp16
|
|
53
|
+
self.mixed_case_context = mixed_case_context
|
|
54
|
+
self.case_simple = case_simple
|
|
55
|
+
|
|
56
|
+
# We optionally load the model from a local directory, but if this is not
|
|
57
|
+
# specified, we load a Hugging Face model
|
|
58
|
+
self.model_name = lang_model_name
|
|
59
|
+
self.model_dir = lm_path if lm_path else self.model_name
|
|
60
|
+
|
|
61
|
+
# parameters for search
|
|
62
|
+
self.beam_width = beam_width
|
|
63
|
+
self.batch_size = batch_size
|
|
64
|
+
|
|
65
|
+
self.simple_upper_words = {"i": "I",
|
|
66
|
+
"i'll": "I'll",
|
|
67
|
+
"i've": "I've",
|
|
68
|
+
"i'd": "I'd",
|
|
69
|
+
"i'm": "I'm"}
|
|
70
|
+
self.load()
|
|
71
|
+
|
|
72
|
+
def _build_vocab(self) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Build a vocabulary table mapping token index to word strings
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
for i in range(self.vocab_size):
|
|
78
|
+
word = self.tokenizer.decode([i])
|
|
79
|
+
word_lower = word.lower()
|
|
80
|
+
self.index_to_word[i] = word
|
|
81
|
+
self.index_to_word_lower[i] = word_lower
|
|
82
|
+
valid = True
|
|
83
|
+
for ch in word_lower:
|
|
84
|
+
if ch not in self.symbol_set_lower:
|
|
85
|
+
valid = False
|
|
86
|
+
break
|
|
87
|
+
if valid:
|
|
88
|
+
self.valid_vocab += i,
|
|
89
|
+
length = len(word)
|
|
90
|
+
if length > self.longest_token:
|
|
91
|
+
self.longest_token = length
|
|
92
|
+
for j in range(length):
|
|
93
|
+
key = word_lower[0:j + 1]
|
|
94
|
+
if key not in self.vocab:
|
|
95
|
+
self.vocab[key] = []
|
|
96
|
+
self.vocab[key] += i,
|
|
97
|
+
|
|
98
|
+
# Get the index we use for the start or end pseudo-word
|
|
99
|
+
if self.left_context == "":
|
|
100
|
+
if "gpt2" in self.model_name:
|
|
101
|
+
self.left_context = "<|endoftext|>"
|
|
102
|
+
else:
|
|
103
|
+
self.left_context = "</s>"
|
|
104
|
+
# Get token id(s) for the left context we condition all sentences on
|
|
105
|
+
self.left_context_tokens = self._encode(self.left_context)
|
|
106
|
+
# print(f"left_context_tokens = {self.left_context_tokens}")
|
|
107
|
+
|
|
108
|
+
def _encode(self, text: str) -> List[int]:
|
|
109
|
+
if text == "":
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
tokens = self.tokenizer.encode(text)
|
|
113
|
+
if len(tokens) > 1 and (self.model_name.startswith("facebook/opt") or self.model_name.startswith("figmtu/opt")):
|
|
114
|
+
tokens = tokens[1:]
|
|
115
|
+
|
|
116
|
+
return tokens
|
|
117
|
+
|
|
118
|
+
def predict_character(self, evidence: List[str]) -> List[Tuple]:
|
|
119
|
+
"""
|
|
120
|
+
Given an evidence of typed string, predict the probability distribution of
|
|
121
|
+
the next symbol
|
|
122
|
+
Args:
|
|
123
|
+
evidence - a list of characters (typed by the user)
|
|
124
|
+
Response:
|
|
125
|
+
A list of symbols with probability
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
assert self.model is not None, "language model does not exist!"
|
|
129
|
+
|
|
130
|
+
context = "".join(evidence)
|
|
131
|
+
|
|
132
|
+
if self.case_simple and len(context) > 0:
|
|
133
|
+
cased_context = ""
|
|
134
|
+
words = context.split()
|
|
135
|
+
for i, word in enumerate(words):
|
|
136
|
+
if i == 0 and word[0] >= 'a' and word[0] <= 'z':
|
|
137
|
+
word = word[0].upper() + word[1:]
|
|
138
|
+
if i > 0:
|
|
139
|
+
if word in self.simple_upper_words:
|
|
140
|
+
word = self.simple_upper_words[word]
|
|
141
|
+
cased_context += " "
|
|
142
|
+
cased_context += word
|
|
143
|
+
# Handle ending space in the context
|
|
144
|
+
if context[-1] == ' ':
|
|
145
|
+
cased_context += " "
|
|
146
|
+
#print(f"Simple casing of left context, from '{context}' to '{cased_context}'")
|
|
147
|
+
context = cased_context
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
context = context.lower()
|
|
151
|
+
|
|
152
|
+
tokens = []
|
|
153
|
+
tokens.extend(self.left_context_tokens)
|
|
154
|
+
|
|
155
|
+
# Optionally, we condition on upper and lower case left context
|
|
156
|
+
if not self.mixed_case_context:
|
|
157
|
+
context = context.lower()
|
|
158
|
+
tokens.extend(self._encode(context))
|
|
159
|
+
|
|
160
|
+
tensor = torch.tensor([tokens]).to(self.device)
|
|
161
|
+
|
|
162
|
+
with torch.no_grad():
|
|
163
|
+
logits = self.model(tensor).logits
|
|
164
|
+
char_probs = torch.softmax(logits, dim=1).to("cpu").numpy()[0]
|
|
165
|
+
|
|
166
|
+
keys = [*"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' "]
|
|
167
|
+
next_char_pred = Counter(dict(zip(keys, char_probs)))
|
|
168
|
+
|
|
169
|
+
for low in self.symbol_set_lower:
|
|
170
|
+
if low.isalpha():
|
|
171
|
+
next_char_pred[low.upper()] += next_char_pred[low]
|
|
172
|
+
del next_char_pred[low]
|
|
173
|
+
|
|
174
|
+
return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
|
|
175
|
+
|
|
176
|
+
def predict_word(self,
|
|
177
|
+
left_context: List[str],
|
|
178
|
+
right_context: List[str] = [" "],
|
|
179
|
+
nbest: int = 3,
|
|
180
|
+
) -> List[Tuple]:
|
|
181
|
+
"""
|
|
182
|
+
Using the provided data, compute log likelihoods over the next sequence of symbols
|
|
183
|
+
Args:
|
|
184
|
+
left_context - The text that precedes the desired prediction.
|
|
185
|
+
right_context - The text that will follow the desired prediction. For simple word
|
|
186
|
+
predictions, this should be a single space.
|
|
187
|
+
nbest - The number of top predictions to return
|
|
188
|
+
|
|
189
|
+
Response:
|
|
190
|
+
A list of tuples, (predicted text, log probability)
|
|
191
|
+
"""
|
|
192
|
+
raise WordPredictionsNotSupportedException("Word predictions are not supported for this model.")
|
|
193
|
+
|
|
194
|
+
def load(self) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Load the language model and tokenizer, initialize class variables
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
|
|
200
|
+
except BaseException:
|
|
201
|
+
raise InvalidLanguageModelException(f"{self.model_name} is not a valid model identifier on HuggingFace.")
|
|
202
|
+
self.vocab_size = self.tokenizer.vocab_size
|
|
203
|
+
try:
|
|
204
|
+
self.model = AutoModelForSequenceClassification.from_pretrained(self.model_dir)
|
|
205
|
+
if self.fp16 and self.device == "cuda":
|
|
206
|
+
self.model = self.model.half()
|
|
207
|
+
except:
|
|
208
|
+
raise InvalidLanguageModelException(f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.")
|
|
209
|
+
|
|
210
|
+
self.model.eval()
|
|
211
|
+
|
|
212
|
+
self.model.to(self.device)
|
|
213
|
+
|
|
214
|
+
self.symbol_set_lower = []
|
|
215
|
+
for ch in self.symbol_set:
|
|
216
|
+
self.symbol_set_lower.append(ch.lower())
|
|
217
|
+
|
|
218
|
+
self._build_vocab()
|
|
219
|
+
|
|
220
|
+
def get_num_parameters(self) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Find out how many parameters the loaded model has
|
|
223
|
+
Args:
|
|
224
|
+
Response:
|
|
225
|
+
Integer number of parameters in the transformer model
|
|
226
|
+
"""
|
|
227
|
+
return sum(p.numel() for p in self.model.parameters())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
class TextPredictException(Exception):
|
|
3
|
+
"""textpredict core exception.
|
|
4
|
+
|
|
5
|
+
Thrown when an error occurs specific to textpredict core concepts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message, errors=None):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.message = message
|
|
11
|
+
self.errors = errors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidLanguageModelException(TextPredictException):
|
|
15
|
+
"""Invalid Language Model Exception.
|
|
16
|
+
|
|
17
|
+
Thrown when attempting to load a language model from an invalid path"""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KenLMInstallationException(TextPredictException):
|
|
22
|
+
"""KenLM Installation Exception.
|
|
23
|
+
|
|
24
|
+
Thrown when attempting to import kenlm without installing the module"""
|
|
25
|
+
...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Defines the language model base class."""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
from string import ascii_uppercase
|
|
5
|
+
|
|
6
|
+
# Returns all uppercase English letters
|
|
7
|
+
def alphabet():
|
|
8
|
+
"""Alphabet.
|
|
9
|
+
|
|
10
|
+
Function used to standardize the symbols we use as alphabet.
|
|
11
|
+
|
|
12
|
+
Returns
|
|
13
|
+
-------
|
|
14
|
+
array of letters.
|
|
15
|
+
"""
|
|
16
|
+
return list(ascii_uppercase)
|
|
17
|
+
|
|
18
|
+
DEFAULT_SYMBOL_SET = alphabet() + [' ']
|
|
19
|
+
|
|
20
|
+
class LanguageModel(ABC):
|
|
21
|
+
"""Parent class for Language Models."""
|
|
22
|
+
|
|
23
|
+
symbol_set: List[str] = None
|
|
24
|
+
|
|
25
|
+
def __init__(self,
|
|
26
|
+
symbol_set: List[str] = DEFAULT_SYMBOL_SET):
|
|
27
|
+
self.symbol_set = symbol_set
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def name(cls) -> str:
|
|
31
|
+
"""Model name used for configuration"""
|
|
32
|
+
suffix = 'LanguageModel'
|
|
33
|
+
if cls.__name__.endswith(suffix):
|
|
34
|
+
return cls.__name__[0:-len(suffix)].upper()
|
|
35
|
+
return cls.__name__.upper()
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def predict(self, evidence: List[str]) -> List[Tuple]:
|
|
39
|
+
"""
|
|
40
|
+
Using the provided data, compute log likelihoods over the entire symbol set.
|
|
41
|
+
Args:
|
|
42
|
+
evidence - ['A', 'B']
|
|
43
|
+
|
|
44
|
+
Response:
|
|
45
|
+
probability - dependent on response type, a list of words or symbols with probability
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def load(self) -> None:
|
|
51
|
+
"""Load model from the provided assets/path"""
|
|
52
|
+
...
|
textslinger/mixture.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from typing import Optional, Dict, List, Tuple
|
|
3
|
+
from math import isclose
|
|
4
|
+
|
|
5
|
+
from textslinger.language_model import LanguageModel
|
|
6
|
+
from textslinger.exceptions import InvalidLanguageModelException
|
|
7
|
+
|
|
8
|
+
# pylint: disable=unused-import
|
|
9
|
+
# flake8: noqa
|
|
10
|
+
"""All supported models must be imported"""
|
|
11
|
+
from textslinger.causal import CausalLanguageModel
|
|
12
|
+
from textslinger.ngram import NGramLanguageModel
|
|
13
|
+
|
|
14
|
+
class MixtureLanguageModel(LanguageModel):
|
|
15
|
+
"""
|
|
16
|
+
Character language model that mixes any combination of other models
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
supported_lm_types = ["CAUSAL", "NGRAM", "UNIFORM"]
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def language_models_by_name() -> Dict[str, LanguageModel]:
|
|
23
|
+
"""Returns available language models indexed by name."""
|
|
24
|
+
return {lm.name(): lm for lm in LanguageModel.__subclasses__()}
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def validate_parameters(types: List[str], weights: List[float], params: List[Dict[str, str]]):
|
|
28
|
+
if params is not None:
|
|
29
|
+
if (types is None) or (len(types) != len(params)):
|
|
30
|
+
raise InvalidLanguageModelException("Length of parameters does not match length of types")
|
|
31
|
+
|
|
32
|
+
if weights is not None:
|
|
33
|
+
if (types is None) or (len(types) != len(weights)):
|
|
34
|
+
raise InvalidLanguageModelException("Length of weights does not match length of types")
|
|
35
|
+
if not isclose(sum(weights), 1.0, abs_tol=1e-05):
|
|
36
|
+
raise InvalidLanguageModelException("Weights do not sum to 1")
|
|
37
|
+
|
|
38
|
+
if types is not None:
|
|
39
|
+
if weights is None:
|
|
40
|
+
raise InvalidLanguageModelException("Model weights not provided")
|
|
41
|
+
if params is None:
|
|
42
|
+
raise InvalidLanguageModelException("Model parameters not provided")
|
|
43
|
+
if not all(x in MixtureLanguageModel.supported_lm_types for x in types):
|
|
44
|
+
raise InvalidLanguageModelException(f"Supported model types: {MixtureLanguageModel.supported_lm_types}")
|
|
45
|
+
|
|
46
|
+
def __init__(self,
|
|
47
|
+
symbol_set: List[str],
|
|
48
|
+
lm_types: List[str],
|
|
49
|
+
lm_weights: List[float],
|
|
50
|
+
lm_params: List[Dict[str, str]] = None):
|
|
51
|
+
"""
|
|
52
|
+
Initialize instance variables and load the language model with given path
|
|
53
|
+
Args:
|
|
54
|
+
response_type - SYMBOL only
|
|
55
|
+
symbol_set - list of symbol strings
|
|
56
|
+
lm_types - list of types of models to mix
|
|
57
|
+
lm_weights - list of weights to use when mixing the models
|
|
58
|
+
lm_params - list of dictionaries to pass as parameters for each model's instantiation
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
MixtureLanguageModel.validate_parameters(lm_types, lm_weights, lm_params)
|
|
62
|
+
|
|
63
|
+
super().__init__(symbol_set=symbol_set)
|
|
64
|
+
self.models = list()
|
|
65
|
+
self.symbol_set = symbol_set
|
|
66
|
+
|
|
67
|
+
self.lm_types = lm_types
|
|
68
|
+
self.lm_weights = lm_weights
|
|
69
|
+
self.lm_params = lm_params
|
|
70
|
+
|
|
71
|
+
MixtureLanguageModel.validate_parameters(self.lm_types, self.lm_weights, self.lm_params)
|
|
72
|
+
|
|
73
|
+
self.load()
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def interpolate_language_models(lms: List[Dict[str, float]], coeffs: List[float]) -> List[Tuple]:
|
|
77
|
+
"""
|
|
78
|
+
interpolate two or more language models
|
|
79
|
+
Args:
|
|
80
|
+
lms - output from the language models (a list of dicts with char as keys and prob as values)
|
|
81
|
+
coeffs - list of rescale coefficients, lms[0] will be scaled by coeffs[0] and so on
|
|
82
|
+
Response:
|
|
83
|
+
a list of (char, prob) tuples representing an interpolated language model
|
|
84
|
+
"""
|
|
85
|
+
combined_lm = Counter()
|
|
86
|
+
|
|
87
|
+
for i, lm in enumerate(lms):
|
|
88
|
+
for char in lm:
|
|
89
|
+
combined_lm[char] += lm[char] * coeffs[i]
|
|
90
|
+
|
|
91
|
+
return list(sorted(combined_lm.items(), key=lambda item: item[1], reverse=True))
|
|
92
|
+
|
|
93
|
+
def predict(self, evidence: List[str]) -> List[Tuple]:
|
|
94
|
+
"""
|
|
95
|
+
Given an evidence of typed string, predict the probability distribution of
|
|
96
|
+
the next symbol
|
|
97
|
+
Args:
|
|
98
|
+
evidence - a list of characters (typed by the user)
|
|
99
|
+
Response:
|
|
100
|
+
A list of symbols with probability
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
pred_list = list()
|
|
104
|
+
|
|
105
|
+
# Generate predictions from each component language model
|
|
106
|
+
pred_list = [dict(model.predict(evidence)) for model in self.models]
|
|
107
|
+
|
|
108
|
+
# Mix the component models
|
|
109
|
+
next_char_pred = MixtureLanguageModel.interpolate_language_models(pred_list, self.lm_weights)
|
|
110
|
+
|
|
111
|
+
return next_char_pred
|
|
112
|
+
|
|
113
|
+
def load(self) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Load the language models to be mixed
|
|
116
|
+
"""
|
|
117
|
+
language_models = MixtureLanguageModel.language_models_by_name()
|
|
118
|
+
for lm_type, params in zip(self.lm_types, self.lm_params):
|
|
119
|
+
model = language_models[lm_type]
|
|
120
|
+
lm = None
|
|
121
|
+
try:
|
|
122
|
+
lm = model(self.symbol_set, **params)
|
|
123
|
+
except InvalidLanguageModelException as e:
|
|
124
|
+
raise InvalidLanguageModelException(f"Error in creation of model type {lm_type}: {e.message}")
|
|
125
|
+
|
|
126
|
+
self.models.append(lm)
|
textslinger/ngram.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from typing import Optional, List, Tuple
|
|
3
|
+
from textslinger.language_model import LanguageModel
|
|
4
|
+
from textslinger.exceptions import InvalidLanguageModelException
|
|
5
|
+
import kenlm
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NGramLanguageModel(LanguageModel):
|
|
10
|
+
"""Character n-gram language model using the KenLM library for querying"""
|
|
11
|
+
|
|
12
|
+
def __init__(self,
|
|
13
|
+
symbol_set: List[str],
|
|
14
|
+
lm_path: str,
|
|
15
|
+
skip_symbol_norm: Optional[bool] = False):
|
|
16
|
+
|
|
17
|
+
super().__init__(symbol_set=symbol_set)
|
|
18
|
+
print(f"Creating n-gram language model, lm_path = {lm_path}")
|
|
19
|
+
self.model = None
|
|
20
|
+
self.lm_path = lm_path
|
|
21
|
+
self.skip_symbol_norm = skip_symbol_norm
|
|
22
|
+
self.load()
|
|
23
|
+
|
|
24
|
+
def predict(self, evidence: List[str]) -> List[Tuple]:
|
|
25
|
+
"""
|
|
26
|
+
Given an evidence of typed string, predict the probability distribution of
|
|
27
|
+
the next symbol
|
|
28
|
+
Args:
|
|
29
|
+
evidence - a list of characters (typed by the user)
|
|
30
|
+
Response:
|
|
31
|
+
A list of symbols with probability
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Do not modify the original parameter, could affect mixture model
|
|
35
|
+
context = evidence.copy()
|
|
36
|
+
|
|
37
|
+
if len(context) > 11:
|
|
38
|
+
context = context[-11:]
|
|
39
|
+
|
|
40
|
+
evidence_str = ''.join(context).lower()
|
|
41
|
+
|
|
42
|
+
for i, ch in enumerate(context):
|
|
43
|
+
if ch == ' ':
|
|
44
|
+
context[i] = "<sp>"
|
|
45
|
+
|
|
46
|
+
self.model.BeginSentenceWrite(self.state)
|
|
47
|
+
|
|
48
|
+
# Update the state one token at a time based on evidence, alternate states
|
|
49
|
+
for i, token in enumerate(context):
|
|
50
|
+
if i % 2 == 0:
|
|
51
|
+
self.model.BaseScore(self.state, token.lower(), self.state2)
|
|
52
|
+
else:
|
|
53
|
+
self.model.BaseScore(self.state2, token.lower(), self.state)
|
|
54
|
+
|
|
55
|
+
next_char_pred = None
|
|
56
|
+
|
|
57
|
+
# Generate the probability distribution based on the final state
|
|
58
|
+
if len(context) % 2 == 0:
|
|
59
|
+
next_char_pred = self.prob_dist(self.state)
|
|
60
|
+
else:
|
|
61
|
+
next_char_pred = self.prob_dist(self.state2)
|
|
62
|
+
|
|
63
|
+
return next_char_pred
|
|
64
|
+
|
|
65
|
+
def load(self) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Load the language model, initialize state variables
|
|
68
|
+
Args:
|
|
69
|
+
path: language model file path
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
self.model = kenlm.LanguageModel(self.lm_path)
|
|
74
|
+
except BaseException:
|
|
75
|
+
raise InvalidLanguageModelException(
|
|
76
|
+
f"A valid model path must be provided for the KenLMLanguageModel.\nPath{self.lm_path} is not valid.")
|
|
77
|
+
|
|
78
|
+
self.state = kenlm.State()
|
|
79
|
+
self.state2 = kenlm.State()
|
|
80
|
+
|
|
81
|
+
def prob_dist(self, state: kenlm.State) -> List[Tuple]:
|
|
82
|
+
"""
|
|
83
|
+
Take in a state and generate the probability distribution of next character
|
|
84
|
+
Args:
|
|
85
|
+
state - the kenlm state updated with the evidence
|
|
86
|
+
Response:
|
|
87
|
+
A list of symbols with probability
|
|
88
|
+
"""
|
|
89
|
+
next_char_pred = Counter()
|
|
90
|
+
|
|
91
|
+
temp_state = kenlm.State()
|
|
92
|
+
|
|
93
|
+
for char in self.symbol_set:
|
|
94
|
+
# Replace the space character with KenLM's <sp> token
|
|
95
|
+
if char == ' ':
|
|
96
|
+
score = self.model.BaseScore(state, '<sp>', temp_state)
|
|
97
|
+
else:
|
|
98
|
+
score = self.model.BaseScore(state, char.lower(), temp_state)
|
|
99
|
+
|
|
100
|
+
# BaseScore returns log probs, convert by putting 10 to its power
|
|
101
|
+
next_char_pred[char] = pow(10, score)
|
|
102
|
+
|
|
103
|
+
# We can optionally disable normalization over our symbol set
|
|
104
|
+
# This is useful if we want to compare against SRILM with a LM with a larger vocab
|
|
105
|
+
if not self.skip_symbol_norm:
|
|
106
|
+
sum = np.sum(list(next_char_pred.values()))
|
|
107
|
+
for char in self.symbol_set:
|
|
108
|
+
next_char_pred[char] /= sum
|
|
109
|
+
|
|
110
|
+
return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
|
textslinger/uniform.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Uniform language model"""
|
|
2
|
+
from typing import Dict, List, Tuple, Union, Optional
|
|
3
|
+
import numpy as np
|
|
4
|
+
from textslinger.language_model import LanguageModel, DEFAULT_SYMBOL_SET
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UniformLanguageModel(LanguageModel):
|
|
8
|
+
"""Language model in which probabilities for symbols are uniformly
|
|
9
|
+
distributed.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
response_type - SYMBOL only
|
|
14
|
+
symbol_set - optional specify the symbol set, otherwise uses DEFAULT_SYMBOL_SET
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self,
|
|
18
|
+
symbol_set: Optional[List[str]] = DEFAULT_SYMBOL_SET):
|
|
19
|
+
super().__init__(symbol_set=symbol_set)
|
|
20
|
+
|
|
21
|
+
def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]:
|
|
22
|
+
"""
|
|
23
|
+
Using the provided data, compute probabilities over the entire symbol.
|
|
24
|
+
set.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
evidence - list of previously typed symbols
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
list of (symbol, probability) tuples
|
|
33
|
+
"""
|
|
34
|
+
probs = equally_probable(self.symbol_set)
|
|
35
|
+
return list(zip(self.symbol_set, probs))
|
|
36
|
+
|
|
37
|
+
def load(self) -> None:
|
|
38
|
+
"""Restore model state from the provided checkpoint"""
|
|
39
|
+
|
|
40
|
+
def equally_probable(alphabet: List[str],
|
|
41
|
+
specified: Optional[Dict[str, float]] = None) -> List[float]:
|
|
42
|
+
"""Returns a list of probabilities which correspond to the provided
|
|
43
|
+
alphabet. Unless overridden by the specified values, all items will
|
|
44
|
+
have the same probability. All probabilities sum to 1.0.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
----------
|
|
48
|
+
alphabet - list of symbols; a probability will be generated for each.
|
|
49
|
+
specified - dict of symbol => probability values for which we want to
|
|
50
|
+
override the default probability.
|
|
51
|
+
Returns:
|
|
52
|
+
--------
|
|
53
|
+
list of probabilities (floats)
|
|
54
|
+
"""
|
|
55
|
+
n_letters = len(alphabet)
|
|
56
|
+
if not specified:
|
|
57
|
+
return np.full(n_letters, 1 / n_letters)
|
|
58
|
+
|
|
59
|
+
# copy specified dict ignoring non-alphabet items
|
|
60
|
+
overrides = {k: specified[k] for k in alphabet if k in specified}
|
|
61
|
+
assert sum(overrides.values()) < 1
|
|
62
|
+
|
|
63
|
+
prob = (1 - sum(overrides.values())) / (n_letters - len(overrides))
|
|
64
|
+
# override specified values
|
|
65
|
+
return [overrides[sym] if sym in overrides else prob for sym in alphabet]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: textslinger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TextSlinger text prediction toolkit.
|
|
5
|
+
Author-email: Dylan Gaines <dcgaines@mtu.edu>, Keith Vertanen <vertanen@mtu.edu>, CAMBI <cambi_support@googlegroups.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Keith Vertanen, Dylan Gaines
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://www.cambi.tech/
|
|
28
|
+
Project-URL: Source, https://github.com/kdv123/textpredict
|
|
29
|
+
Platform: Linux
|
|
30
|
+
Platform: Windows
|
|
31
|
+
Platform: Mac OS-X
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
|
|
34
|
+
Classifier: Natural Language :: English
|
|
35
|
+
Classifier: Intended Audience :: Science/Research
|
|
36
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
37
|
+
Classifier: Intended Audience :: Developers
|
|
38
|
+
Classifier: Programming Language :: Python
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Requires-Python: <3.11,>=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE.md
|
|
44
|
+
Requires-Dist: torch==2.2.0
|
|
45
|
+
Requires-Dist: torchvision==0.17.0
|
|
46
|
+
Requires-Dist: torchaudio==2.2.0
|
|
47
|
+
Requires-Dist: datasets==2.0.0
|
|
48
|
+
Requires-Dist: bitsandbytes==0.42.0
|
|
49
|
+
Requires-Dist: requests==2.32.3
|
|
50
|
+
Requires-Dist: kenlm==0.1
|
|
51
|
+
Requires-Dist: nlpaug==1.1.11
|
|
52
|
+
Requires-Dist: psutil==5.7.2
|
|
53
|
+
Requires-Dist: ipywidgets==8.1.3
|
|
54
|
+
Requires-Dist: sentencepiece==0.2.0
|
|
55
|
+
Requires-Dist: protobuf==4.25.3
|
|
56
|
+
Requires-Dist: evaluate==0.4.0
|
|
57
|
+
Requires-Dist: scikit-learn==1.2.2
|
|
58
|
+
Requires-Dist: accelerate==0.33.0
|
|
59
|
+
Requires-Dist: transformers==4.47.0
|
|
60
|
+
Requires-Dist: numpy==1.24.4
|
|
61
|
+
Requires-Dist: tqdm==4.62.2
|
|
62
|
+
Provides-Extra: release
|
|
63
|
+
Requires-Dist: twine==5.0.0; extra == "release"
|
|
64
|
+
Requires-Dist: build==1.2.2.post1; extra == "release"
|
|
65
|
+
Requires-Dist: wheel==0.43.0; extra == "release"
|
|
66
|
+
Dynamic: license-file
|
|
67
|
+
|
|
68
|
+
Python library for making text predictions using a language model.
|
|
69
|
+
|
|
70
|
+
**** Setting up a Python environment ****
|
|
71
|
+
These instructions are the same as those in the classify-aac which actually does training of models.
|
|
72
|
+
But the same environment should also work for evaluation using the models (though you may only need a subset of the packages).
|
|
73
|
+
This was tested on cheetah on 9/14/24.
|
|
74
|
+
|
|
75
|
+
If you don't have anaconda installed in your user account you'll first need to do that.
|
|
76
|
+
See: https://docs.anaconda.com/anaconda/install/linux/
|
|
77
|
+
|
|
78
|
+
% conda create -n aacllm python=3.10
|
|
79
|
+
% conda activate aacllm
|
|
80
|
+
% conda install pytorch torchvision torchaudio pytorch-cuda cuda mpi4py -c pytorch -c nvidia
|
|
81
|
+
% pip install 'git+https://github.com/potamides/uniformers.git#egg=uniformers'
|
|
82
|
+
% pip install --upgrade transformers
|
|
83
|
+
% pip install kenlm==0.1 --global-option="--max_order=12"
|
|
84
|
+
% pip install rbloom bitsandbytes requests nlpaug ipywidgets psutil datasets sentencepiece protobuf evaluate scikit-learn deepspeed accelerate
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
textslinger/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
textslinger/causal.py,sha256=QgDf6EvP1XuVmA9vevMwSyqkeTfpZhjN6ktn2s0Z8p0,21989
|
|
3
|
+
textslinger/classifier.py,sha256=7p1zPocHlv93WaCtC4ddCpqxhwK4JgHKOy-t45Sjm70,8972
|
|
4
|
+
textslinger/exceptions.py,sha256=UuetkKZj7YWTNDVxyRGXyGjLB70f1Padq3VHkhjcGG8,663
|
|
5
|
+
textslinger/language_model.py,sha256=bVwjRei78MV_-PbH15b_icfLCa7Isusj6t-nUDrbGUo,1388
|
|
6
|
+
textslinger/mixture.py,sha256=pa7WWCFDcSBpgKkK_FjfgupiiMMtc2hADRIl8_PlDaY,5026
|
|
7
|
+
textslinger/ngram.py,sha256=2qU_vXF2C1WTw5V96vaFcg58YAQVoaOXaFGgBmNR8Xo,3848
|
|
8
|
+
textslinger/uniform.py,sha256=-osO-sw96TBF26XYdo2SuoJP-cvVFCiooEmA3oy38Xc,2258
|
|
9
|
+
textslinger-0.1.0.dist-info/licenses/LICENSE.md,sha256=9GrMOuuZ2WSNM16xWY2bWj2r0PgqJ98dzowfYcNtDjs,1084
|
|
10
|
+
textslinger-0.1.0.dist-info/METADATA,sha256=3HIjEAwB1o37Z3nez0SFaFfn_yi41qSwlGXSR6bGjj4,3946
|
|
11
|
+
textslinger-0.1.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
12
|
+
textslinger-0.1.0.dist-info/top_level.txt,sha256=cYLIX2fs3IGchHPWv9BvkQ59hycDDNy2fc68WTpTGpo,12
|
|
13
|
+
textslinger-0.1.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
14
|
+
textslinger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Keith Vertanen, Dylan Gaines
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
textslinger
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|