ONNX
English
autocorrect
spelling-correction
browser
offline-page
cfahlgren1 HF Staff commited on
Commit
f4cab9b
·
verified ·
1 Parent(s): 7536642

retrain on more mix-up groups: fixes about 40% of wrong words on the new test bench, up from 24%

Browse files
Files changed (5) hide show
  1. README.md +19 -10
  2. train_realword.py +215 -30
  3. wrong-word-confusions.txt +177 -16
  4. wrong-word-vocab.txt +22 -22
  5. wrong-word.onnx +2 -2
README.md CHANGED
@@ -51,23 +51,30 @@ tinyfix reads the words on both sides of a correctly spelled word and picks the
51
  <table>
52
  <tr>
53
  <td align="center" width="33%"><h2>94%</h2>of its fixes are right</td>
54
- <td align="center" width="33%"><h2>31%</h2>of real-word typos fixed<br><sub>in Eager mode</sub></td>
55
  <td align="center" width="33%"><h2>~3 ms</h2>per check in Chrome</td>
56
  </tr>
57
  </table>
58
 
59
- Measured on 2,460 real-word typos from GitHub commits and 45,000 correct words of email and documentation that it never trained on.
60
 
61
- | Setting | Real-word typos fixed | Fixes that were right | Correct email words changed | Correct GitHub words changed |
62
- |---|---|---|---|---|
63
- | Careful *(default)* | **19%** | **94%** | about 1 in 2,500 | about 1 in 1,700 |
64
- | Eager *(demo and offline.page)* | **31%** | **94%** | about 1 in 910 | about 1 in 430 |
 
 
 
 
 
65
 
66
- It also helps choose the right fix for misspelled words, raising that fix rate from 56% to 61.5% while 96.6% of fixes stay right.
67
 
68
  ### What it misses
69
 
70
- - It misses more real-word typos than it catches, including “don” typed for “do”.
 
 
71
  - It can only judge the 20,000 words it knows, so a rarer word like “ware” typed for “were” is left alone.
72
  - It needs the words that come after, so these fixes land a couple of words later.
73
  - English only.
@@ -97,14 +104,16 @@ IDs are line numbers in `wrong-word-vocab.txt`, starting from 0. Words are lower
97
  |---|---|
98
  | `wrong-word.onnx` | The quantized model |
99
  | `wrong-word-vocab.txt` | The 20,000 words it knows, one per line |
100
- | `wrong-word-confusions.txt` | Mix-ups like “their” and “there” that are far apart on the keyboard |
101
  | `train_realword.py` | The training script |
102
 
 
 
103
  </details>
104
 
105
  ## Training
106
 
107
- A word-level GRU that reads 16 words before and up to 4 words after, trained for 25 minutes on an A10G. It learned from 118M tokens of [FineWeb-Edu](https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu), [SmolTalk](https://huggingface.co/datasets/HuggingFaceTB/smoltalk) and [OpenAssistant](https://huggingface.co/datasets/OpenAssistant/oasst1), by swapping real words for nearby real words and learning to swap them back.
108
 
109
  <br>
110
 
 
51
  <table>
52
  <tr>
53
  <td align="center" width="33%"><h2>94%</h2>of its fixes are right</td>
54
+ <td align="center" width="33%"><h2>40%</h2>of wrong words fixed<br><sub>24% before this version</sub></td>
55
  <td align="center" width="33%"><h2>~3 ms</h2>per check in Chrome</td>
56
  </tr>
57
  </table>
58
 
59
+ Measured by typing test text into the code [offline.page](https://github.com/cfahlgren1/offline-page) runs, on text the model never trained on and that nothing was tuned on. The 40% is the average of five test sets:
60
 
61
+ | Test set | Wrong words fixed | Fixes that were right |
62
+ |---|---|---|
63
+ | Classic mix-ups put into email, chat and encyclopedia text (their/there, to/too, its/it's…) | **69%** | 99% |
64
+ | Hand-written sentences | **58%** | 100% |
65
+ | Finger slips that make a real word | **29%** | 87% |
66
+ | Real errors from children's writing | **28%** | 91% |
67
+ | Real typos from GitHub commits | **18%** | 96% |
68
+
69
+ On about 110,000 words of correct email, chat, encyclopedia, news, documentation and fiction it wrongly changed about 1 word in 1,800, the same as the version before it.
70
 
71
+ The first two sets are built from the mix-up pairs the model was trained to watch for, so they flatter it. The last three are the fairer picture of everyday typing.
72
 
73
  ### What it misses
74
 
75
+ - It still misses more wrong words than it catches, and about four in five real typos from GitHub commits.
76
+ - When both words make sense it stays quiet: “writing this form a plane” is left alone, because “writing this form” is something people say.
77
+ - Two-letter words are hit and miss. It fixes “I am to tired” but not every “to” that should be “too”.
78
  - It can only judge the 20,000 words it knows, so a rarer word like “ware” typed for “were” is left alone.
79
  - It needs the words that come after, so these fixes land a couple of words later.
80
  - English only.
 
104
  |---|---|
105
  | `wrong-word.onnx` | The quantized model |
106
  | `wrong-word-vocab.txt` | The 20,000 words it knows, one per line |
107
+ | `wrong-word-confusions.txt` | 234 words people mix up, like “their” and “there”, each with the words they are mistaken for |
108
  | `train_realword.py` | The training script |
109
 
110
+ The model only scores words. The rules that decide when to make a swap (which words to check, the extra confidence needed at the start of a sentence, and so on) are in offline.page's [`src/tinyfix`](https://github.com/cfahlgren1/offline-page/tree/main/src/tinyfix).
111
+
112
  </details>
113
 
114
  ## Training
115
 
116
+ A word-level GRU that reads 16 words before and up to 4 words after, trained for 120,000 steps on one GPU. It learned from [FineWeb-Edu](https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu), [SmolTalk](https://huggingface.co/datasets/HuggingFaceTB/smoltalk) and [OpenAssistant](https://huggingface.co/datasets/OpenAssistant/oasst1) text, by swapping real words for nearby real words and learning to swap them back. About one example in four is centered on a known mix-up, half of them used correctly, so it also learns when to leave a word alone.
117
 
118
  <br>
119
 
train_realword.py CHANGED
@@ -53,6 +53,32 @@ HOMOPHONES = [
53
  ]
54
  TYPO_PAIRS_DATASET = "chirunder/github_typo_corrections"
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  WORD_LIST_URL = "https://raw.githubusercontent.com/wolfgarbe/SymSpell/master/SymSpell.FrequencyDictionary/en-80k.txt"
57
  TOKEN = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+(?:[.,]\d+)*|[^\s\w]")
58
  SENTENCE_OPENERS = {".", "!", "?", ":", '"'}
@@ -106,14 +132,70 @@ def load_corpus(token_budgets: dict[str, int]) -> tuple[list[np.ndarray], dict[s
106
  return documents, raw_ids
107
 
108
 
109
- def build_vocabulary(documents: list[np.ndarray], raw_ids: dict[str, int], size: int) -> tuple[list[str], np.ndarray]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  with urllib.request.urlopen(WORD_LIST_URL) as response:
111
  dictionary = {line.split()[0] for line in response.read().decode().splitlines() if line.strip()}
112
  counts = np.bincount(np.concatenate(documents), minlength=len(raw_ids))
113
  allowed = [(token, identifier) for token, identifier in raw_ids.items() if (WORD.match(token) and token in dictionary) or (len(token) == 1 and not token.isalnum())]
114
  allowed = [pair for pair in allowed if pair[0] != "<s>"]
115
  allowed.sort(key=lambda pair: (-counts[pair[1]], pair[0]))
116
- vocabulary = SPECIAL_TOKENS + [token for token, _ in allowed[: size - len(SPECIAL_TOKENS)]]
 
 
 
 
 
 
 
 
117
 
118
  token_to_id = {token: index for index, token in enumerate(vocabulary)}
119
  lookup = np.full(len(raw_ids), UNK, dtype=np.int32)
@@ -169,16 +251,33 @@ def real_mistakes_for_training(vocabulary: set[str]) -> Counter:
169
  return Counter({pair: count for pair, count in counts.items() if count >= MIN_REAL_MISTAKE_COUNT})
170
 
171
 
172
- def build_confusions(vocabulary: list[str], use_real_mistakes: bool) -> dict[str, list[tuple[str, int]]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  """Maps a typed word to words people really meant by it, most likely first."""
174
  known = set(vocabulary)
175
  counts = real_mistakes_for_training(known) if use_real_mistakes else Counter()
176
  print(f"real mistake pairs={len(counts):,}", flush=True)
177
- for group in HOMOPHONES:
178
- for typed in group:
179
- for expected in group:
180
- if typed != expected and typed in known and expected in known and len(typed) >= MIN_TYPED_LENGTH:
181
- counts[(typed, expected)] += HOMOPHONE_WEIGHT
182
  confusions = defaultdict(list)
183
  for (typed, expected), count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
184
  confusions[typed].append((expected, count))
@@ -282,41 +381,98 @@ class WrongWordModel(nn.Module):
282
  return scores + self.typed_bonus * self.typed_position
283
 
284
 
 
 
 
 
 
 
 
 
 
 
 
285
  class Sampler:
286
  """Draws training cases by swapping real words for nearby real words."""
287
 
288
- def __init__(self, tokens: torch.Tensor, vocabulary: list[str], neighbors: np.ndarray, real_typed: np.ndarray, real_weights: np.ndarray, device: str):
 
289
  self.tokens = tokens
290
  self.device = device
291
  self.vocabulary = vocabulary
292
- self.neighbor_table = neighbors
293
- self.real_typed_table = real_typed
294
- self.real_weights_table = real_weights
295
  self.real_typed = torch.from_numpy(real_typed).to(device).long()
296
  self.real_weights = torch.from_numpy(real_weights).to(device)
297
  self.has_real = self.real_weights.sum(1) > 0
298
  self.neighbors = torch.from_numpy(neighbors).to(device).long()
299
  self.neighbor_counts = (self.neighbors != PAD).sum(1)
300
- self.lengths = torch.tensor([len(token) for token in vocabulary], device=device)
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  self.is_target = torch.tensor([is_target_word(token) for token in vocabulary], device=device)
302
 
303
- def batch(self, size: int, corrupt_rate: float):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  positions = torch.randint(LEFT_WINDOW + 1, len(self.tokens) - RIGHT_WINDOW - 1, (size,), device=self.device)
 
 
 
 
 
 
 
 
 
 
305
  target = self.tokens[positions]
306
  counts = self.neighbor_counts[target]
307
- choice = (torch.rand(size, device=self.device) * counts.clamp(min=1)).long()
308
  corrupted = self.neighbors[target, choice]
309
  weights = self.real_weights[target]
310
  weights = weights.where(self.has_real[target].unsqueeze(1), torch.ones_like(weights))
311
  real_choice = torch.multinomial(weights, 1).squeeze(1)
312
- uses_real = self.has_real[target] & (torch.rand(size, device=self.device) < REAL_MISTAKE_SHARE)
313
  corrupted = torch.where(uses_real, self.real_typed[target, real_choice], corrupted)
314
  counts = counts.where(~uses_real, torch.ones_like(counts))
315
  is_corrupted = (torch.rand(size, device=self.device) < corrupt_rate) & (counts > 0)
316
  typed = torch.where(is_corrupted, corrupted, target)
317
  candidates = torch.cat([typed.unsqueeze(1), self.neighbors[typed]], dim=1)
318
  matches = candidates == target.unsqueeze(1)
319
- valid = self.is_target[target] & (self.lengths[typed] >= MIN_TYPED_LENGTH) & (self.neighbor_counts[typed] > 0) & matches.any(1)
320
 
321
  positions, candidates, matches, is_corrupted = positions[valid], candidates[valid], matches[valid], is_corrupted[valid]
322
  count = len(positions)
@@ -327,7 +483,7 @@ class Sampler:
327
  visible_right = torch.randint(0, RIGHT_WINDOW + 1, (count,), device=self.device).unsqueeze(1)
328
  right = right.where(torch.arange(RIGHT_WINDOW, device=self.device).unsqueeze(0) < visible_right, torch.tensor(PAD, device=self.device)).flip(1)
329
 
330
- is_short = torch.rand(count, device=self.device) < 0.2
331
  kept = torch.randint(0, LEFT_WINDOW, (count,), device=self.device).unsqueeze(1)
332
  distance_from_end = (-left_offsets).unsqueeze(0)
333
  hidden_left = is_short.unsqueeze(1) & (distance_from_end > kept + 1)
@@ -385,7 +541,7 @@ def export_onnx(model: WrongWordModel, output: Path, sampler: Sampler) -> dict:
385
  for name, operators in quantized.items():
386
  quantize_dynamic(float_path, output / name, weight_type=QuantType.QUInt8, op_types_to_quantize=operators)
387
 
388
- sampler_cpu = Sampler(sampler.tokens.cpu(), sampler.vocabulary, sampler.neighbor_table, sampler.real_typed_table, sampler.real_weights_table, "cpu")
389
  left, right, candidates, label, _ = sampler_cpu.batch(4096, 0.5)
390
  inputs = {"left": left.numpy().astype(np.int64), "right": right.numpy().astype(np.int64), "candidates": candidates.numpy().astype(np.int64)}
391
  with torch.no_grad():
@@ -412,8 +568,19 @@ def export_onnx(model: WrongWordModel, output: Path, sampler: Sampler) -> dict:
412
 
413
  def main():
414
  parser = argparse.ArgumentParser()
415
- parser.add_argument("--repo", default="cfahlgren1/tinyfix-realword")
416
- parser.add_argument("--run-name", default="v4")
 
 
 
 
 
 
 
 
 
 
 
417
  parser.add_argument("--real-mistakes", action="store_true", help="Also train on GitHub word swaps. Scored worse than homophones alone in v3.")
418
  parser.add_argument("--fineweb-tokens", type=int, default=70_000_000)
419
  parser.add_argument("--smoltalk-tokens", type=int, default=50_000_000)
@@ -425,15 +592,26 @@ def main():
425
  parser.add_argument("--learning-rate", type=float, default=2e-3)
426
  parser.add_argument("--output", default="/tmp/tinyfix-realword")
427
  parser.add_argument("--no-upload", action="store_true")
 
 
428
  args = parser.parse_args()
 
429
 
430
  device = "cuda" if torch.cuda.is_available() else "cpu"
431
  output = Path(args.output)
432
  output.mkdir(parents=True, exist_ok=True)
433
  print(f"device={device} args={vars(args)}", flush=True)
434
 
435
- documents, raw_ids = load_corpus({"fineweb": args.fineweb_tokens, "smoltalk": args.smoltalk_tokens, "oasst": 10**12})
436
- vocabulary, lookup = build_vocabulary(documents, raw_ids, args.vocabulary)
 
 
 
 
 
 
 
 
437
  print(f"documents={len(documents):,} raw_types={len(raw_ids):,} vocabulary={len(vocabulary):,}", flush=True)
438
 
439
  order = np.random.default_rng(7).permutation(len(documents))
@@ -443,15 +621,22 @@ def main():
443
  print(f"train={len(train_tokens):,} validation={len(validation_tokens):,} unknown={(train_tokens == UNK).float().mean().item():.3f}", flush=True)
444
  del documents
445
 
446
- confusions = build_confusions(vocabulary, args.real_mistakes)
 
447
  (output / "confusions.txt").write_text("".join(f"{typed} {' '.join(expected for expected, _ in options)}\n" for typed, options in confusions.items()))
448
  neighbors = build_neighbors(vocabulary, confusions)
449
  real_typed, real_weights = build_real_mistake_tables(vocabulary, confusions)
450
  print(f"words with neighbors={(neighbors[:, 0] != PAD).sum():,}", flush=True)
451
  (output / "vocab.txt").write_text("\n".join(vocabulary) + "\n")
452
 
453
- train_sampler = Sampler(train_tokens, vocabulary, neighbors, real_typed, real_weights, device)
454
- validation_sampler = Sampler(validation_tokens, vocabulary, neighbors, real_typed, real_weights, device)
 
 
 
 
 
 
455
 
456
  model = WrongWordModel(len(vocabulary), args.embedding, args.hidden).to(device)
457
  parameters = sum(parameter.numel() for parameter in model.parameters())
@@ -463,7 +648,7 @@ def main():
463
  running_loss = 0.0
464
  report_every = max(args.steps // 40, 1)
465
  for step in range(1, args.steps + 1):
466
- left, right, candidates, label, _ = train_sampler.batch(args.batch, 0.5)
467
  loss = F.cross_entropy(mask_empty(model(left, right, candidates), candidates), label)
468
  optimizer.zero_grad(set_to_none=True)
469
  loss.backward()
@@ -479,6 +664,7 @@ def main():
479
 
480
  validation = evaluate(model, validation_sampler, 200, args.batch)
481
  print(f"final validation: {validation}", flush=True)
 
482
  export_report = export_onnx(model, output, validation_sampler)
483
  print(f"export: {export_report}", flush=True)
484
 
@@ -496,10 +682,9 @@ def main():
496
  if args.no_upload:
497
  print(f"Skipping upload; artifacts in {output}", flush=True)
498
  return
499
- api = HfApi(token=os.environ["HF_TOKEN"])
500
  api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
501
- api.upload_folder(folder_path=str(output), repo_id=args.repo, path_in_repo=args.run_name, repo_type="model")
502
- print(f"Uploaded to {args.repo}/{args.run_name}", flush=True)
503
 
504
 
505
  if __name__ == "__main__":
 
53
  ]
54
  TYPO_PAIRS_DATASET = "chirunder/github_typo_corrections"
55
 
56
+ # Sound-alikes and classic mix-ups. Every member can be typed for every other member.
57
+ EXTENDED_GROUPS = HOMOPHONES + [
58
+ ("to", "too", "two"), ("of", "off"), ("no", "know", "now"), ("by", "buy", "bye"), ("for", "four"), ("one", "won"),
59
+ ("new", "knew"), ("wait", "weight"), ("way", "weigh"), ("plane", "plain"), ("made", "maid"), ("pair", "pear"),
60
+ ("tale", "tail"), ("flour", "flower"), ("threw", "through"), ("son", "sun"), ("bought", "brought"), ("wear", "were"),
61
+ ("bare", "bear"), ("dear", "deer"), ("hour", "our"), ("knight", "night"), ("knot", "not"), ("sea", "see"),
62
+ ("steal", "steel"), ("stair", "stare"), ("tide", "tied"), ("which", "witch"), ("course", "coarse"), ("sent", "scent", "cent"),
63
+ ("die", "dye"), ("flew", "flu"), ("great", "grate"), ("heal", "heel"), ("heard", "herd"), ("lessen", "lesson"),
64
+ ("main", "mane"), ("medal", "meddle"), ("missed", "mist"), ("morning", "mourning"), ("pain", "pane"), ("pause", "paws"),
65
+ ("peak", "peek"), ("pole", "poll"), ("poor", "pour", "pore"), ("pray", "prey"), ("rain", "reign", "rein"), ("read", "red"),
66
+ ("real", "reel"), ("road", "rode"), ("root", "route"), ("rose", "rows"), ("scene", "seen"), ("seam", "seem"),
67
+ ("sole", "soul"), ("some", "sum"), ("stationary", "stationery"), ("throne", "thrown"), ("vain", "vein"), ("waive", "wave"),
68
+ ("ate", "eight"), ("blew", "blue"), ("cell", "sell"), ("cereal", "serial"), ("council", "counsel"), ("desert", "dessert"),
69
+ ("dual", "duel"), ("fourth", "forth"), ("guessed", "guest"), ("hall", "haul"), ("higher", "hire"), ("loan", "lone"),
70
+ ("hours", "ours"), ("presence", "presents"), ("sail", "sale"), ("stake", "steak"), ("breath", "breathe"), ("chose", "choose"),
71
+ ("clothes", "cloths"), ("dose", "does"), ("later", "latter"), ("personal", "personnel"), ("precede", "proceed"),
72
+ ("quite", "quit"), ("woman", "women"), ("form", "from"), ("lose", "loss"), ("whit", "with"),
73
+ ("grown", "groan"), ("idle", "idol"), ("sweet", "suite"),
74
+ ("ensure", "insure"), ("emigrate", "immigrate"),
75
+ ("we", "wee"), ("so", "sew"),
76
+ ]
77
+ # Mix-ups that only make sense one way ("could of" for "could have").
78
+ ONE_WAY = [("of", "have"), ("then", "than"), ("loose", "lose"), ("defiantly", "definitely")]
79
+ GROUP_SETS = {"shipped": HOMOPHONES, "extended": EXTENDED_GROUPS}
80
+ SLIP_WORDS = ["fro", "nit", "grate", "manger", "bye", "whit", "wee", "thee", "lest", "sew"]
81
+
82
  WORD_LIST_URL = "https://raw.githubusercontent.com/wolfgarbe/SymSpell/master/SymSpell.FrequencyDictionary/en-80k.txt"
83
  TOKEN = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+(?:[.,]\d+)*|[^\s\w]")
84
  SENTENCE_OPENERS = {".", "!", "?", ":", '"'}
 
132
  return documents, raw_ids
133
 
134
 
135
+ CACHE_REPO = "cfahlgren1/tinyfix-lab"
136
+
137
+
138
+ def load_corpus_cached(token_budgets: dict[str, int], api: HfApi) -> tuple[list[np.ndarray], dict[str, int]]:
139
+ """Tokenizing 120M tokens takes a while, so the first run saves the result to the private lab repo."""
140
+ folder = f"data/corpus-{token_budgets['fineweb']}-{token_budgets['smoltalk']}"
141
+ try:
142
+ paths = {name: api.hf_hub_download(CACHE_REPO, f"{folder}/{name}") for name in ("tokens.npy", "lengths.npy", "raw_tokens.json")}
143
+ tokens, lengths = np.load(paths["tokens.npy"]), np.load(paths["lengths.npy"])
144
+ raw_tokens = json.loads(Path(paths["raw_tokens.json"]).read_text())
145
+ print(f"loaded cached corpus {folder}: {len(tokens):,} tokens", flush=True)
146
+ return np.split(tokens, np.cumsum(lengths)[:-1]), {token: index for index, token in enumerate(raw_tokens)}
147
+ except Exception as error:
148
+ print(f"no cached corpus ({type(error).__name__}); building it", flush=True)
149
+ documents, raw_ids = load_corpus(token_budgets)
150
+ cache = Path("/tmp/corpus-cache")
151
+ cache.mkdir(exist_ok=True)
152
+ np.save(cache / "tokens.npy", np.concatenate(documents))
153
+ np.save(cache / "lengths.npy", np.array([len(document) for document in documents], dtype=np.int64))
154
+ (cache / "raw_tokens.json").write_text(json.dumps(list(raw_ids)))
155
+ api.create_repo(CACHE_REPO, repo_type="model", private=True, exist_ok=True)
156
+ api.upload_folder(folder_path=str(cache), repo_id=CACHE_REPO, path_in_repo=folder, repo_type="model")
157
+ print(f"cached corpus to {CACHE_REPO}/{folder}", flush=True)
158
+ return documents, raw_ids
159
+
160
+
161
+ def slip_words(ranked: list[str], counts_by_token: dict[str, int], start: int, how_many: int, min_count: int) -> list[str]:
162
+ """Words just outside the vocabulary that are one edit from a common word, so a slip that lands on them can be judged."""
163
+ common = ranked[:3000]
164
+ buckets = defaultdict(list)
165
+ for word in common:
166
+ if len(word) < 3:
167
+ continue
168
+ for position in range(len(word)):
169
+ buckets[word[:position] + word[position + 1 :]].append(word)
170
+ buckets[word].append(word)
171
+ found = []
172
+ for word in ranked[start:]:
173
+ if len(found) >= how_many or counts_by_token[word] < min_count:
174
+ break
175
+ if len(word) < 3 or "'" in word:
176
+ continue
177
+ keys = {word} | {word[:position] + word[position + 1 :] for position in range(len(word))}
178
+ if any(within_one_edit(word, other) for key in keys for other in buckets.get(key, [])):
179
+ found.append(word)
180
+ return found
181
+
182
+
183
+ def build_vocabulary(documents: list[np.ndarray], raw_ids: dict[str, int], size: int, forced: list[str], slips: int) -> tuple[list[str], np.ndarray]:
184
  with urllib.request.urlopen(WORD_LIST_URL) as response:
185
  dictionary = {line.split()[0] for line in response.read().decode().splitlines() if line.strip()}
186
  counts = np.bincount(np.concatenate(documents), minlength=len(raw_ids))
187
  allowed = [(token, identifier) for token, identifier in raw_ids.items() if (WORD.match(token) and token in dictionary) or (len(token) == 1 and not token.isalnum())]
188
  allowed = [pair for pair in allowed if pair[0] != "<s>"]
189
  allowed.sort(key=lambda pair: (-counts[pair[1]], pair[0]))
190
+ ranked = [token for token, _ in allowed]
191
+ room = size - len(SPECIAL_TOKENS)
192
+ extra = [word for word in forced if word in raw_ids and word not in ranked[:room]]
193
+ if slips:
194
+ counts_by_token = {token: int(counts[identifier]) for token, identifier in allowed}
195
+ extra += [word for word in slip_words(ranked, counts_by_token, room, slips, 20) if word not in extra]
196
+ missing = [word for word in forced if word not in raw_ids]
197
+ print(f"vocabulary: forcing {len(extra)} extra words, e.g. {extra[:40]}; not in corpus: {missing}", flush=True)
198
+ vocabulary = SPECIAL_TOKENS + ranked[: room - len(extra)] + extra
199
 
200
  token_to_id = {token: index for index, token in enumerate(vocabulary)}
201
  lookup = np.full(len(raw_ids), UNK, dtype=np.int32)
 
251
  return Counter({pair: count for pair, count in counts.items() if count >= MIN_REAL_MISTAKE_COUNT})
252
 
253
 
254
+ PAIRS_FILE: list[tuple[str, str]] = []
255
+
256
+
257
+ def load_pairs_file(api: HfApi, repo: str, path: str) -> list[tuple[str, str]]:
258
+ """Reads a mix-up list ("typed meant1 meant2 ...") from the lab repo, so training and the engine use the same pairs."""
259
+ text = Path(api.hf_hub_download(repo, path)).read_text()
260
+ pairs = [(parts[0], meant) for parts in (line.split() for line in text.splitlines()) if parts for meant in parts[1:]]
261
+ return list(dict.fromkeys(pairs))
262
+
263
+
264
+ def group_pairs(groups: str) -> list[tuple[str, str]]:
265
+ if PAIRS_FILE:
266
+ return PAIRS_FILE
267
+ pairs = [(typed, expected) for group in GROUP_SETS[groups] for typed in group for expected in group if typed != expected]
268
+ if groups != "shipped":
269
+ pairs += ONE_WAY
270
+ return list(dict.fromkeys(pairs))
271
+
272
+
273
+ def build_confusions(vocabulary: list[str], use_real_mistakes: bool, groups: str, min_typed: int) -> dict[str, list[tuple[str, int]]]:
274
  """Maps a typed word to words people really meant by it, most likely first."""
275
  known = set(vocabulary)
276
  counts = real_mistakes_for_training(known) if use_real_mistakes else Counter()
277
  print(f"real mistake pairs={len(counts):,}", flush=True)
278
+ for typed, expected in group_pairs(groups):
279
+ if typed in known and expected in known and len(typed) >= min_typed:
280
+ counts[(typed, expected)] += HOMOPHONE_WEIGHT
 
 
281
  confusions = defaultdict(list)
282
  for (typed, expected), count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
283
  confusions[typed].append((expected, count))
 
381
  return scores + self.typed_bonus * self.typed_position
382
 
383
 
384
+ INFLECTION_ENDINGS = {"", "s", "es", "d", "ed", "ing", "r", "er", "ly"}
385
+
386
+
387
+ def is_inflection(first: str, second: str) -> bool:
388
+ """True for pairs like friendship/friendships and continued/continues that only differ in an ending."""
389
+ shared = 0
390
+ while shared < min(len(first), len(second)) and first[shared] == second[shared]:
391
+ shared += 1
392
+ return shared >= 3 and first[shared:] in INFLECTION_ENDINGS and second[shared:] in INFLECTION_ENDINGS
393
+
394
+
395
  class Sampler:
396
  """Draws training cases by swapping real words for nearby real words."""
397
 
398
+ def __init__(self, tokens: torch.Tensor, vocabulary: list[str], neighbors: np.ndarray, real_typed: np.ndarray, real_weights: np.ndarray, device: str, options: dict):
399
+ self.init_args = (vocabulary, neighbors, real_typed, real_weights, options)
400
  self.tokens = tokens
401
  self.device = device
402
  self.vocabulary = vocabulary
403
+ self.options = options
 
 
404
  self.real_typed = torch.from_numpy(real_typed).to(device).long()
405
  self.real_weights = torch.from_numpy(real_weights).to(device)
406
  self.has_real = self.real_weights.sum(1) > 0
407
  self.neighbors = torch.from_numpy(neighbors).to(device).long()
408
  self.neighbor_counts = (self.neighbors != PAD).sum(1)
409
+ neighbor_weights = (neighbors != PAD).astype(np.float32)
410
+ if options["inflection_weight"] != 1:
411
+ for row in np.nonzero(neighbors[:, 0] != PAD)[0]:
412
+ for column in range(MAX_NEIGHBORS):
413
+ other = neighbors[row, column]
414
+ if other != PAD and is_inflection(vocabulary[row], vocabulary[other]):
415
+ neighbor_weights[row, column] = options["inflection_weight"]
416
+ neighbor_weights[neighbor_weights.sum(1) == 0] = 1
417
+ self.neighbor_weights = torch.from_numpy(neighbor_weights).to(device)
418
+ lengths = torch.tensor([len(token) for token in vocabulary], device=device)
419
+ typed_ok = lengths >= options["min_typed"]
420
+ if options["short_listed"]:
421
+ typed_ok |= (lengths >= 2) & self.has_listed(real_typed, len(vocabulary), device)
422
+ self.typed_ok = typed_ok
423
  self.is_target = torch.tensor([is_target_word(token) for token in vocabulary], device=device)
424
 
425
+ self.group_share = options["group_share"]
426
+ if self.group_share > 0:
427
+ group_ids = torch.tensor(sorted(options["group_ids"]), device=device)
428
+ inside = torch.zeros(len(tokens), dtype=torch.bool, device=device)
429
+ inside[LEFT_WINDOW + 1 : len(tokens) - RIGHT_WINDOW - 1] = True
430
+ where = torch.nonzero(torch.isin(tokens, group_ids) & inside).squeeze(1)
431
+ order = torch.argsort(tokens[where], stable=True)
432
+ self.group_positions = where[order]
433
+ type_counts = torch.bincount(tokens[self.group_positions], minlength=len(vocabulary))
434
+ self.group_starts = torch.cumsum(type_counts, 0) - type_counts
435
+ self.group_counts = type_counts
436
+ self.group_type_weights = type_counts.float().pow(options["group_power"])
437
+ print(f"group positions={len(where):,} types={(type_counts > 0).sum().item()}", flush=True)
438
+
439
+ @staticmethod
440
+ def has_listed(real_typed: np.ndarray, size: int, device: str) -> torch.Tensor:
441
+ listed = torch.zeros(size, dtype=torch.bool, device=device)
442
+ typed = torch.from_numpy(real_typed[real_typed != PAD]).to(device).long()
443
+ listed[typed] = True
444
+ return listed
445
+
446
+ def to(self, device: str) -> "Sampler":
447
+ return Sampler(self.tokens.to(device), *self.init_args[:4], device, self.init_args[4])
448
+
449
+ def positions(self, size: int) -> torch.Tensor:
450
  positions = torch.randint(LEFT_WINDOW + 1, len(self.tokens) - RIGHT_WINDOW - 1, (size,), device=self.device)
451
+ if self.group_share <= 0:
452
+ return positions
453
+ from_groups = torch.rand(size, device=self.device) < self.group_share
454
+ types = torch.multinomial(self.group_type_weights, size, replacement=True)
455
+ offsets = (torch.rand(size, device=self.device) * self.group_counts[types]).long()
456
+ chosen = self.group_positions[(self.group_starts[types] + offsets).clamp(max=len(self.group_positions) - 1)]
457
+ return torch.where(from_groups, chosen, positions)
458
+
459
+ def batch(self, size: int, corrupt_rate: float):
460
+ positions = self.positions(size)
461
  target = self.tokens[positions]
462
  counts = self.neighbor_counts[target]
463
+ choice = torch.multinomial(self.neighbor_weights[target], 1).squeeze(1)
464
  corrupted = self.neighbors[target, choice]
465
  weights = self.real_weights[target]
466
  weights = weights.where(self.has_real[target].unsqueeze(1), torch.ones_like(weights))
467
  real_choice = torch.multinomial(weights, 1).squeeze(1)
468
+ uses_real = self.has_real[target] & (torch.rand(size, device=self.device) < self.options["real_share"])
469
  corrupted = torch.where(uses_real, self.real_typed[target, real_choice], corrupted)
470
  counts = counts.where(~uses_real, torch.ones_like(counts))
471
  is_corrupted = (torch.rand(size, device=self.device) < corrupt_rate) & (counts > 0)
472
  typed = torch.where(is_corrupted, corrupted, target)
473
  candidates = torch.cat([typed.unsqueeze(1), self.neighbors[typed]], dim=1)
474
  matches = candidates == target.unsqueeze(1)
475
+ valid = self.is_target[target] & self.typed_ok[typed] & (self.neighbor_counts[typed] > 0) & matches.any(1)
476
 
477
  positions, candidates, matches, is_corrupted = positions[valid], candidates[valid], matches[valid], is_corrupted[valid]
478
  count = len(positions)
 
483
  visible_right = torch.randint(0, RIGHT_WINDOW + 1, (count,), device=self.device).unsqueeze(1)
484
  right = right.where(torch.arange(RIGHT_WINDOW, device=self.device).unsqueeze(0) < visible_right, torch.tensor(PAD, device=self.device)).flip(1)
485
 
486
+ is_short = torch.rand(count, device=self.device) < self.options["short_share"]
487
  kept = torch.randint(0, LEFT_WINDOW, (count,), device=self.device).unsqueeze(1)
488
  distance_from_end = (-left_offsets).unsqueeze(0)
489
  hidden_left = is_short.unsqueeze(1) & (distance_from_end > kept + 1)
 
541
  for name, operators in quantized.items():
542
  quantize_dynamic(float_path, output / name, weight_type=QuantType.QUInt8, op_types_to_quantize=operators)
543
 
544
+ sampler_cpu = sampler.to("cpu")
545
  left, right, candidates, label, _ = sampler_cpu.batch(4096, 0.5)
546
  inputs = {"left": left.numpy().astype(np.int64), "right": right.numpy().astype(np.int64), "candidates": candidates.numpy().astype(np.int64)}
547
  with torch.no_grad():
 
568
 
569
  def main():
570
  parser = argparse.ArgumentParser()
571
+ parser.add_argument("--repo", default="cfahlgren1/tinyfix-lab")
572
+ parser.add_argument("--run-name", required=True)
573
+ parser.add_argument("--groups", choices=sorted(GROUP_SETS), default="shipped")
574
+ parser.add_argument("--min-typed", type=int, default=MIN_TYPED_LENGTH)
575
+ parser.add_argument("--short-listed", action="store_true", help="Also train two-letter typed words that have listed mix-ups.")
576
+ parser.add_argument("--real-share", type=float, default=REAL_MISTAKE_SHARE)
577
+ parser.add_argument("--group-share", type=float, default=0.0, help="Share of cases centered on a word from a mix-up group.")
578
+ parser.add_argument("--group-power", type=float, default=0.5)
579
+ parser.add_argument("--inflection-weight", type=float, default=1.0)
580
+ parser.add_argument("--short-share", type=float, default=0.2)
581
+ parser.add_argument("--corrupt-rate", type=float, default=0.5)
582
+ parser.add_argument("--force-group-words", action="store_true")
583
+ parser.add_argument("--slip-words", type=int, default=0)
584
  parser.add_argument("--real-mistakes", action="store_true", help="Also train on GitHub word swaps. Scored worse than homophones alone in v3.")
585
  parser.add_argument("--fineweb-tokens", type=int, default=70_000_000)
586
  parser.add_argument("--smoltalk-tokens", type=int, default=50_000_000)
 
592
  parser.add_argument("--learning-rate", type=float, default=2e-3)
593
  parser.add_argument("--output", default="/tmp/tinyfix-realword")
594
  parser.add_argument("--no-upload", action="store_true")
595
+ parser.add_argument("--pairs-file", default="", help="Path in --repo of a mix-up list to train on instead of --groups.")
596
+ parser.add_argument("--seed", type=int, default=0)
597
  args = parser.parse_args()
598
+ torch.manual_seed(args.seed)
599
 
600
  device = "cuda" if torch.cuda.is_available() else "cpu"
601
  output = Path(args.output)
602
  output.mkdir(parents=True, exist_ok=True)
603
  print(f"device={device} args={vars(args)}", flush=True)
604
 
605
+ api = HfApi(token=os.environ["HF_TOKEN"])
606
+ if args.pairs_file:
607
+ PAIRS_FILE.extend(load_pairs_file(api, args.repo, args.pairs_file))
608
+ print(f"pairs from {args.pairs_file}: {len(PAIRS_FILE)}", flush=True)
609
+ documents, raw_ids = load_corpus_cached({"fineweb": args.fineweb_tokens, "smoltalk": args.smoltalk_tokens, "oasst": 10**12}, api)
610
+ pairs = group_pairs(args.groups)
611
+ forced = list(dict.fromkeys(word for pair in pairs for word in pair)) if args.force_group_words else []
612
+ if args.slip_words:
613
+ forced += [word for word in SLIP_WORDS if word not in forced]
614
+ vocabulary, lookup = build_vocabulary(documents, raw_ids, args.vocabulary, forced, args.slip_words)
615
  print(f"documents={len(documents):,} raw_types={len(raw_ids):,} vocabulary={len(vocabulary):,}", flush=True)
616
 
617
  order = np.random.default_rng(7).permutation(len(documents))
 
621
  print(f"train={len(train_tokens):,} validation={len(validation_tokens):,} unknown={(train_tokens == UNK).float().mean().item():.3f}", flush=True)
622
  del documents
623
 
624
+ min_typed = 2 if args.short_listed else args.min_typed
625
+ confusions = build_confusions(vocabulary, args.real_mistakes, args.groups, min_typed)
626
  (output / "confusions.txt").write_text("".join(f"{typed} {' '.join(expected for expected, _ in options)}\n" for typed, options in confusions.items()))
627
  neighbors = build_neighbors(vocabulary, confusions)
628
  real_typed, real_weights = build_real_mistake_tables(vocabulary, confusions)
629
  print(f"words with neighbors={(neighbors[:, 0] != PAD).sum():,}", flush=True)
630
  (output / "vocab.txt").write_text("\n".join(vocabulary) + "\n")
631
 
632
+ token_to_id = {token: index for index, token in enumerate(vocabulary)}
633
+ options = {
634
+ "min_typed": args.min_typed, "short_listed": args.short_listed, "inflection_weight": args.inflection_weight,
635
+ "group_share": args.group_share, "group_power": args.group_power, "real_share": args.real_share, "short_share": args.short_share,
636
+ "group_ids": {token_to_id[word] for pair in pairs for word in pair if word in token_to_id},
637
+ }
638
+ train_sampler = Sampler(train_tokens, vocabulary, neighbors, real_typed, real_weights, device, options)
639
+ validation_sampler = Sampler(validation_tokens, vocabulary, neighbors, real_typed, real_weights, device, {**options, "group_share": 0.0})
640
 
641
  model = WrongWordModel(len(vocabulary), args.embedding, args.hidden).to(device)
642
  parameters = sum(parameter.numel() for parameter in model.parameters())
 
648
  running_loss = 0.0
649
  report_every = max(args.steps // 40, 1)
650
  for step in range(1, args.steps + 1):
651
+ left, right, candidates, label, _ = train_sampler.batch(args.batch, args.corrupt_rate)
652
  loss = F.cross_entropy(mask_empty(model(left, right, candidates), candidates), label)
653
  optimizer.zero_grad(set_to_none=True)
654
  loss.backward()
 
664
 
665
  validation = evaluate(model, validation_sampler, 200, args.batch)
666
  print(f"final validation: {validation}", flush=True)
667
+ torch.save(model.state_dict(), output / "model.pt")
668
  export_report = export_onnx(model, output, validation_sampler)
669
  print(f"export: {export_report}", flush=True)
670
 
 
682
  if args.no_upload:
683
  print(f"Skipping upload; artifacts in {output}", flush=True)
684
  return
 
685
  api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
686
+ api.upload_folder(folder_path=str(output), repo_id=args.repo, path_in_repo=f"combine/{args.run_name}", repo_type="model")
687
+ print(f"Uploaded to {args.repo}/combine/{args.run_name}", flush=True)
688
 
689
 
690
  if __name__ == "__main__":
wrong-word-confusions.txt CHANGED
@@ -5,13 +5,15 @@ your you're
5
  you're your
6
  its it's
7
  it's its
8
- too two
9
- two too
 
10
  then than
11
- than then
12
  were where we're
13
- where were we're
14
- we're were where
 
15
  whose who's
16
  who's whose
17
  hear here
@@ -22,17 +24,18 @@ peace piece
22
  piece peace
23
  affect effect
24
  effect affect
25
- lose loose
26
- loose lose
27
  accept except
28
  except accept
29
  advice advise
30
  advise advice
31
  quiet quite
32
  quite quiet
33
- through though thorough
34
- though through thorough
35
- thorough through though
 
36
  passed past
37
  past passed
38
  principal principle
@@ -43,13 +46,12 @@ brake break
43
  break brake
44
  waist waste
45
  waste waist
46
- right write
47
- write right
 
48
  sight site cite
49
  site sight cite
50
  cite sight site
51
- wear where
52
- where wear
53
  led lead
54
  lead led
55
  meet meat
@@ -62,8 +64,8 @@ hole whole
62
  whole hole
63
  male mail
64
  mail male
65
- sale sell
66
- sell sale
67
  role roll
68
  roll role
69
  aloud allowed
@@ -72,3 +74,162 @@ board bored
72
  bored board
73
  fair fare
74
  fare fair
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  you're your
6
  its it's
7
  it's its
8
+ too two to
9
+ two too to
10
+ to too two
11
  then than
12
+ than then that
13
  were where we're
14
+ where were we're wear
15
+ we're were where wear
16
+ wear were where we're
17
  whose who's
18
  who's whose
19
  hear here
 
24
  piece peace
25
  affect effect
26
  effect affect
27
+ lose loose loss
28
+ loose lose loss
29
  accept except
30
  except accept
31
  advice advise
32
  advise advice
33
  quiet quite
34
  quite quiet
35
+ through though thorough threw
36
+ though through thorough threw
37
+ thorough through though threw
38
+ threw through though thorough
39
  passed past
40
  past passed
41
  principal principle
 
46
  break brake
47
  waist waste
48
  waste waist
49
+ right write rite
50
+ write right rite
51
+ rite right write
52
  sight site cite
53
  site sight cite
54
  cite sight site
 
 
55
  led lead
56
  lead led
57
  meet meat
 
64
  whole hole
65
  male mail
66
  mail male
67
+ sale sell sail
68
+ sell sale cell
69
  role roll
70
  roll role
71
  aloud allowed
 
74
  bored board
75
  fair fare
76
  fare fair
77
+ of off
78
+ off of
79
+ no know
80
+ know no now
81
+ now no know
82
+ by buy bye
83
+ buy by bye
84
+ bye by buy
85
+ for four
86
+ four for
87
+ one won
88
+ won one
89
+ son sun
90
+ sun son
91
+ our hour are
92
+ hour our are
93
+ are our hour
94
+ way weigh
95
+ weigh way
96
+ wait weight
97
+ weight wait
98
+ bear bare
99
+ bare bear
100
+ plane plain
101
+ plain plane
102
+ made maid
103
+ maid made
104
+ pair pear
105
+ pear pair
106
+ tale tail
107
+ tail tale
108
+ flour flower
109
+ flower flour
110
+ knew new
111
+ new knew
112
+ die dye
113
+ dye die
114
+ bee be
115
+ see sea
116
+ sea see
117
+ heard herd
118
+ herd heard
119
+ red read
120
+ steal steel
121
+ steel steal
122
+ feet feat
123
+ feat feet
124
+ night knight
125
+ knight night
126
+ sum some
127
+ some sum
128
+ do due dew
129
+ due do dew
130
+ dew do due
131
+ form from
132
+ from form
133
+ desert dessert
134
+ dessert desert
135
+ breath breathe
136
+ breathe breath
137
+ diary dairy
138
+ dairy diary
139
+ trial trail
140
+ trail trial
141
+ let's lets
142
+ cell sell
143
+ cent scent sent
144
+ scent cent sent
145
+ council counsel
146
+ counsel council
147
+ dear deer
148
+ deer dear
149
+ hair hare
150
+ hare hair
151
+ heal heel
152
+ heel heal
153
+ idle idol
154
+ idol idle
155
+ knot not
156
+ knows nose
157
+ nose knows
158
+ lessen lesson
159
+ lesson lessen
160
+ loan lone
161
+ lone loan
162
+ main mane
163
+ mane main
164
+ miner minor
165
+ minor miner
166
+ morning mourning
167
+ mourning morning
168
+ naval navel
169
+ navel naval
170
+ pail pale
171
+ pale pail
172
+ pain pane
173
+ pane pain
174
+ peak peek
175
+ peek peak
176
+ pole poll
177
+ poll pole
178
+ pray prey
179
+ prey pray
180
+ rain reign rein
181
+ reign rain rein
182
+ rein rain reign
183
+ real reel
184
+ reel real
185
+ road rode rowed
186
+ rode road rowed
187
+ rowed road rode
188
+ root route
189
+ route root
190
+ sail sale
191
+ scene seen
192
+ seen scene
193
+ seam seem
194
+ seem seam
195
+ sole soul
196
+ soul sole
197
+ stair stare
198
+ stare stair
199
+ stationary stationery
200
+ stationery stationary
201
+ suite sweet
202
+ sweet suite
203
+ tide tied
204
+ tied tide
205
+ vain vein
206
+ vein vain
207
+ warn worn
208
+ worn warn
209
+ yoke yolk
210
+ yolk yoke
211
+ throne thrown
212
+ thrown throne
213
+ shone shown
214
+ shown shone
215
+ farther further
216
+ loss lose loose
217
+ chose choose
218
+ choose chose
219
+ that than
220
+ altar alter
221
+ base bass
222
+ bass base
223
+ berth birth
224
+ birth berth
225
+ creak creek
226
+ creek creak
227
+ flea flee
228
+ flee flea
229
+ mist missed
230
+ nun none
231
+ pause paws
232
+ paws pause
233
+ raise rays
234
+ rays raise
235
+ rows rose
wrong-word-vocab.txt CHANGED
@@ -19976,25 +19976,25 @@ grating
19976
  icing
19977
  immunology
19978
  inoculation
19979
- instantaneously
19980
- intelligible
19981
- laura
19982
- malignancy
19983
- mocking
19984
- momentarily
19985
- objected
19986
- oft
19987
- parenthood
19988
- proclaiming
19989
- proprietor
19990
- racket
19991
- rang
19992
- reconciled
19993
- reflexive
19994
- restrain
19995
- sanitized
19996
- sanitizing
19997
- sharpening
19998
- shrunk
19999
- stout
20000
- supercomputer
 
19976
  icing
19977
  immunology
19978
  inoculation
19979
+ bye
19980
+ mane
19981
+ navel
19982
+ pail
19983
+ rein
19984
+ rowed
19985
+ stair
19986
+ stationery
19987
+ yoke
19988
+ berth
19989
+ creak
19990
+ nun
19991
+ cloths
19992
+ defiantly
19993
+ emigrate
19994
+ immigrate
19995
+ grate
19996
+ groan
19997
+ meddle
19998
+ waive
19999
+ wee
20000
+ whit
wrong-word.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:43e3660ebc01749470e8c66bf0c94bcb1c838e81b7154d8040be6f65d853407c
3
- size 2092693
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:35d76c0f2cc1fbb5450188a749f4c10b0d569d8806bb939bd66fa926561b47e9
3
+ size 2092692