retrain on more mix-up groups: fixes about 40% of wrong words on the new test bench, up from 24%
Browse files- README.md +19 -10
- train_realword.py +215 -30
- wrong-word-confusions.txt +177 -16
- wrong-word-vocab.txt +22 -22
- 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>
|
| 55 |
<td align="center" width="33%"><h2>~3 ms</h2>per check in Chrome</td>
|
| 56 |
</tr>
|
| 57 |
</table>
|
| 58 |
|
| 59 |
-
Measured
|
| 60 |
|
| 61 |
-
|
|
| 62 |
-
|---|---|---|
|
| 63 |
-
|
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
|
| 68 |
### What it misses
|
| 69 |
|
| 70 |
-
- It misses more
|
|
|
|
|
|
|
| 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` |
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 178 |
-
|
| 179 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
self.is_target = torch.tensor([is_target_word(token) for token in vocabulary], device=device)
|
| 302 |
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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) <
|
| 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] &
|
| 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) <
|
| 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 =
|
| 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-
|
| 416 |
-
parser.add_argument("--run-name",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 19980 |
-
|
| 19981 |
-
|
| 19982 |
-
|
| 19983 |
-
|
| 19984 |
-
|
| 19985 |
-
|
| 19986 |
-
|
| 19987 |
-
|
| 19988 |
-
|
| 19989 |
-
|
| 19990 |
-
|
| 19991 |
-
|
| 19992 |
-
|
| 19993 |
-
|
| 19994 |
-
|
| 19995 |
-
|
| 19996 |
-
|
| 19997 |
-
|
| 19998 |
-
|
| 19999 |
-
|
| 20000 |
-
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:35d76c0f2cc1fbb5450188a749f4c10b0d569d8806bb939bd66fa926561b47e9
|
| 3 |
+
size 2092692
|