Instructions to use HauserGroup/ApeTokenizer-SELFIES with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use HauserGroup/ApeTokenizer-SELFIES with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("HauserGroup/ApeTokenizer-SELFIES", dtype="auto") - Notebooks
- Google Colab
- Kaggle
Harden APE tokenizer: malformed-row tolerance, freq accuracy, perf
Browse files- Skip malformed sequences during training instead of aborting the run,
and map malformed input to <unk> at encode time so it stays detectable
via unk_rate rather than raising mid-batch.
- Debit both constituents when a merge is applied so *_freq.json reflects
accurate post-merge counts; primitive keys are retained for coverage.
- Drop the per-merge full copy of pair_counts (never read) and reuse
untouched sequences by reference during rebuild. Output is unchanged;
merge order stays pinned for reproducibility.
- Document that encoding is greedy longest-match, not a replay of learned
merge order, and that both phases share it.
- Remove dead two-letter metal branches from SMILES_RE (always bracketed
in canonical SMILES); verified identical splits on the shipped vocab.
- Correct the misleading "unbalanced parens = defect" comment: boundary-
crossing SMILES tokens are inherent to APE and match the paper.
- tokenization_ape.py +82 -8
|
@@ -23,8 +23,14 @@ VOCAB_FILES_NAMES = {
|
|
| 23 |
"smiles_vocab_file": "smiles_vocab.json",
|
| 24 |
}
|
| 25 |
SELFIES_RE = re.compile(r"\[[^\]]+\]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
SMILES_RE = re.compile(
|
| 27 |
-
r"(\[[^\]]+\]|Br?|Cl?|
|
| 28 |
r"N|O|S|P|F|I|K|B|C|H|"
|
| 29 |
r"b|c|n|o|s|p|"
|
| 30 |
r"\%\d{2}|\d|"
|
|
@@ -82,20 +88,42 @@ def _select_vocab_file(
|
|
| 82 |
return vocab_file
|
| 83 |
|
| 84 |
|
| 85 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
active_representation = _normalize_representation(representation)
|
|
|
|
| 87 |
if active_representation == "SELFIES":
|
| 88 |
-
return
|
| 89 |
|
| 90 |
tokens: list[str] = []
|
| 91 |
cursor = 0
|
|
|
|
| 92 |
for match in SMILES_RE.finditer(molecule):
|
| 93 |
if match.start() > cursor:
|
| 94 |
tokens.extend(molecule[cursor : match.start()])
|
|
|
|
| 95 |
tokens.append(match.group(0))
|
| 96 |
cursor = match.end()
|
|
|
|
| 97 |
if cursor < len(molecule):
|
| 98 |
tokens.extend(molecule[cursor:])
|
|
|
|
| 99 |
return [token for token in tokens if token and not token.isspace()]
|
| 100 |
|
| 101 |
|
|
@@ -106,7 +134,24 @@ def ape_tokenize(
|
|
| 106 |
unk_token: str = "<unk>",
|
| 107 |
max_piece_span: int | None = None,
|
| 108 |
) -> list[str]:
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
if not pieces:
|
| 111 |
return [unk_token]
|
| 112 |
|
|
@@ -550,9 +595,16 @@ class APEPreTrainedTokenizer(PreTrainedTokenizer):
|
|
| 550 |
tokenized_corpus = []
|
| 551 |
vocabulary_frequency: defaultdict[str, int] = defaultdict(int)
|
| 552 |
saw_tokens = False
|
|
|
|
| 553 |
|
| 554 |
for sentence in corpus:
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
if not tokens:
|
| 557 |
continue
|
| 558 |
saw_tokens = True
|
|
@@ -560,6 +612,8 @@ class APEPreTrainedTokenizer(PreTrainedTokenizer):
|
|
| 560 |
vocabulary_frequency[token] += 1
|
| 561 |
if len(tokens) > 1:
|
| 562 |
tokenized_corpus.append(tokens)
|
|
|
|
|
|
|
| 563 |
print(
|
| 564 |
f"Pretokenization complete, found {len(vocabulary_frequency)} tokens",
|
| 565 |
flush=True,
|
|
@@ -598,7 +652,6 @@ class APEPreTrainedTokenizer(PreTrainedTokenizer):
|
|
| 598 |
|
| 599 |
pair_counts[pair] += 1
|
| 600 |
|
| 601 |
-
self.pair_counts = dict(pair_counts)
|
| 602 |
if not pair_counts:
|
| 603 |
return ("", ""), 0
|
| 604 |
|
|
@@ -664,14 +717,35 @@ class APEPreTrainedTokenizer(PreTrainedTokenizer):
|
|
| 664 |
flush=True,
|
| 665 |
)
|
| 666 |
merged_counter += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
vocabulary_frequency[merged_word] += freq
|
|
|
|
|
|
|
| 668 |
|
| 669 |
new_tokenized_corpus = []
|
|
|
|
| 670 |
for tokens in tokenized_corpus:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
new_tokens = []
|
| 672 |
append_token = new_tokens.append
|
| 673 |
i = 0
|
| 674 |
-
token_count = len(tokens)
|
| 675 |
while i < token_count:
|
| 676 |
if (
|
| 677 |
i < token_count - 1
|
|
@@ -685,7 +759,7 @@ class APEPreTrainedTokenizer(PreTrainedTokenizer):
|
|
| 685 |
i += 1
|
| 686 |
|
| 687 |
if len(new_tokens) > 1:
|
| 688 |
-
|
| 689 |
|
| 690 |
tokenized_corpus = new_tokenized_corpus
|
| 691 |
|
|
|
|
| 23 |
"smiles_vocab_file": "smiles_vocab.json",
|
| 24 |
}
|
| 25 |
SELFIES_RE = re.compile(r"\[[^\]]+\]")
|
| 26 |
+
# Only the organic subset (B C N O P S F Cl Br I) may appear unbracketed in
|
| 27 |
+
# canonical SMILES; two-letter metals (Si, Se, Na, Mg, Al, Ca, Fe, Zn, ...) are
|
| 28 |
+
# always bracketed and matched by the leading \[[^\]]+\] branch. The previous
|
| 29 |
+
# pattern listed those metals as optional-second-letter alternatives (Si?, Na?,
|
| 30 |
+
# ...), which could match bare invalid single letters (L, M, A, Z) and was dead
|
| 31 |
+
# weight for valid input. Keep only Br?/Cl? (B, C, Br, Cl all valid bare).
|
| 32 |
SMILES_RE = re.compile(
|
| 33 |
+
r"(\[[^\]]+\]|Br?|Cl?|"
|
| 34 |
r"N|O|S|P|F|I|K|B|C|H|"
|
| 35 |
r"b|c|n|o|s|p|"
|
| 36 |
r"\%\d{2}|\d|"
|
|
|
|
| 88 |
return vocab_file
|
| 89 |
|
| 90 |
|
| 91 |
+
def _pre_tokenize_selfies(molecule: str, *, strict: bool = True) -> list[str]:
|
| 92 |
+
pieces = SELFIES_RE.findall(molecule)
|
| 93 |
+
|
| 94 |
+
if strict and "".join(pieces) != molecule:
|
| 95 |
+
raise ValueError(
|
| 96 |
+
"Malformed SELFIES string contains unmatched text outside "
|
| 97 |
+
f"bracketed SELFIES tokens: {molecule!r}"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return pieces
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def pre_tokenize_molecule(
|
| 104 |
+
molecule: str,
|
| 105 |
+
representation: str,
|
| 106 |
+
*,
|
| 107 |
+
strict_selfies: bool = True,
|
| 108 |
+
) -> list[str]:
|
| 109 |
active_representation = _normalize_representation(representation)
|
| 110 |
+
|
| 111 |
if active_representation == "SELFIES":
|
| 112 |
+
return _pre_tokenize_selfies(molecule, strict=strict_selfies)
|
| 113 |
|
| 114 |
tokens: list[str] = []
|
| 115 |
cursor = 0
|
| 116 |
+
|
| 117 |
for match in SMILES_RE.finditer(molecule):
|
| 118 |
if match.start() > cursor:
|
| 119 |
tokens.extend(molecule[cursor : match.start()])
|
| 120 |
+
|
| 121 |
tokens.append(match.group(0))
|
| 122 |
cursor = match.end()
|
| 123 |
+
|
| 124 |
if cursor < len(molecule):
|
| 125 |
tokens.extend(molecule[cursor:])
|
| 126 |
+
|
| 127 |
return [token for token in tokens if token and not token.isspace()]
|
| 128 |
|
| 129 |
|
|
|
|
| 134 |
unk_token: str = "<unk>",
|
| 135 |
max_piece_span: int | None = None,
|
| 136 |
) -> list[str]:
|
| 137 |
+
"""Segment a molecule against the APE vocabulary by greedy longest match.
|
| 138 |
+
|
| 139 |
+
Note this is *not* a replay of the training merges in learned order: train()
|
| 140 |
+
learns which substrings become vocab entries, but decoding here just takes
|
| 141 |
+
the longest vocab token at each position (up to ``max_piece_span`` pieces).
|
| 142 |
+
The two can disagree on segmentation. That is fine and intended — both
|
| 143 |
+
pretraining and fine-tuning encode through this same function, so the model
|
| 144 |
+
only ever sees greedy-longest-match output and stays internally consistent.
|
| 145 |
+
The learned merge *order* is intentionally discarded; only the vocab set is
|
| 146 |
+
used at inference.
|
| 147 |
+
"""
|
| 148 |
+
# A single malformed SELFIES (stray text outside bracket tokens) must not
|
| 149 |
+
# crash encoding. Map the whole string to <unk> so it stays detectable via
|
| 150 |
+
# the validator's unk_rate gate instead of raising mid-batch.
|
| 151 |
+
try:
|
| 152 |
+
pieces = pre_tokenize_molecule(text, representation)
|
| 153 |
+
except ValueError:
|
| 154 |
+
return [unk_token]
|
| 155 |
if not pieces:
|
| 156 |
return [unk_token]
|
| 157 |
|
|
|
|
| 595 |
tokenized_corpus = []
|
| 596 |
vocabulary_frequency: defaultdict[str, int] = defaultdict(int)
|
| 597 |
saw_tokens = False
|
| 598 |
+
skipped_malformed = 0
|
| 599 |
|
| 600 |
for sentence in corpus:
|
| 601 |
+
# One malformed row must not abort a multi-hour training run. Skip and
|
| 602 |
+
# count it; surface the total so a corrupt corpus is still visible.
|
| 603 |
+
try:
|
| 604 |
+
tokens = self.pre_tokenize(str(sentence))
|
| 605 |
+
except ValueError:
|
| 606 |
+
skipped_malformed += 1
|
| 607 |
+
continue
|
| 608 |
if not tokens:
|
| 609 |
continue
|
| 610 |
saw_tokens = True
|
|
|
|
| 612 |
vocabulary_frequency[token] += 1
|
| 613 |
if len(tokens) > 1:
|
| 614 |
tokenized_corpus.append(tokens)
|
| 615 |
+
if skipped_malformed:
|
| 616 |
+
print(f"Skipped {skipped_malformed} malformed sequences", flush=True)
|
| 617 |
print(
|
| 618 |
f"Pretokenization complete, found {len(vocabulary_frequency)} tokens",
|
| 619 |
flush=True,
|
|
|
|
| 652 |
|
| 653 |
pair_counts[pair] += 1
|
| 654 |
|
|
|
|
| 655 |
if not pair_counts:
|
| 656 |
return ("", ""), 0
|
| 657 |
|
|
|
|
| 717 |
flush=True,
|
| 718 |
)
|
| 719 |
merged_counter += 1
|
| 720 |
+
# Each merged occurrence consumes one left + one right piece, so debit
|
| 721 |
+
# both constituents to keep vocabulary_frequency (the *_freq.json
|
| 722 |
+
# diagnostic) an accurate post-merge count. Keys are never removed —
|
| 723 |
+
# a primitive merged to zero must stay in vocab for coverage.
|
| 724 |
vocabulary_frequency[merged_word] += freq
|
| 725 |
+
vocabulary_frequency[left_token] = max(0, vocabulary_frequency[left_token] - freq)
|
| 726 |
+
vocabulary_frequency[right_token] = max(0, vocabulary_frequency[right_token] - freq)
|
| 727 |
|
| 728 |
new_tokenized_corpus = []
|
| 729 |
+
append_seq = new_tokenized_corpus.append
|
| 730 |
for tokens in tokenized_corpus:
|
| 731 |
+
token_count = len(tokens)
|
| 732 |
+
|
| 733 |
+
# Fast path: a sequence with no adjacent (left, right) is
|
| 734 |
+
# unchanged by this merge. Keep the existing list by reference
|
| 735 |
+
# instead of reallocating + re-appending every token. Most
|
| 736 |
+
# sequences are untouched per merge, so this avoids the bulk of
|
| 737 |
+
# the per-iteration allocation without altering the output.
|
| 738 |
+
has_pair = any(
|
| 739 |
+
tokens[i] == left_token and tokens[i + 1] == right_token
|
| 740 |
+
for i in range(token_count - 1)
|
| 741 |
+
)
|
| 742 |
+
if not has_pair:
|
| 743 |
+
append_seq(tokens)
|
| 744 |
+
continue
|
| 745 |
+
|
| 746 |
new_tokens = []
|
| 747 |
append_token = new_tokens.append
|
| 748 |
i = 0
|
|
|
|
| 749 |
while i < token_count:
|
| 750 |
if (
|
| 751 |
i < token_count - 1
|
|
|
|
| 759 |
i += 1
|
| 760 |
|
| 761 |
if len(new_tokens) > 1:
|
| 762 |
+
append_seq(new_tokens)
|
| 763 |
|
| 764 |
tokenized_corpus = new_tokenized_corpus
|
| 765 |
|