jsture commited on
Commit
6625342
·
verified ·
1 Parent(s): fdd34dc

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.

Files changed (1) hide show
  1. tokenization_ape.py +82 -8
tokenization_ape.py CHANGED
@@ -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?|Si?|Se?|Li?|Na?|Mg?|Al?|Ca?|Fe?|Zn?|"
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 pre_tokenize_molecule(molecule: str, representation: str) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  active_representation = _normalize_representation(representation)
 
87
  if active_representation == "SELFIES":
88
- return SELFIES_RE.findall(molecule)
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
- pieces = pre_tokenize_molecule(text, representation)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- tokens = self.pre_tokenize(str(sentence))
 
 
 
 
 
 
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
- new_tokenized_corpus.append(new_tokens)
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