bi0s commited on
Commit
ad8888f
·
verified ·
1 Parent(s): 55071bc

Add STAMP hybrid AO-GPT 30M d=512/l=8 ep7 (GenMol 79.00%)

Browse files

Checkpoint + hybrid motif+char vocab (2481 tokens) + self-contained HybridVocab class + config + model card.

Files changed (6) hide show
  1. README.md +201 -0
  2. config.json +50 -0
  3. hybrid_vocab.json +0 -0
  4. hybrid_vocab.py +268 -0
  5. model.pt +3 -0
  6. motif_vocab.txt +0 -0
README.md ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - chemistry
5
+ - molecular-generation
6
+ - smiles
7
+ - stamp
8
+ - drug-discovery
9
+ ---
10
+
11
+ # STAMP Hybrid AO-GPT (31M, d=512/l=8, epoch 7)
12
+
13
+ Pretrained AO-GPT (any-order GPT) over **STAMP** molecular token sequences
14
+ with a **hybrid motif + character vocabulary**. Trained on 30M unique
15
+ filtered molecules. Achieves **79.00% GenMol quality** — matching the
16
+ 112M-parameter AR baseline (79.64%) with 28% of the parameters and 20% of
17
+ the vocabulary size.
18
+
19
+ ## Highlights
20
+
21
+ - **Small vocab (2481)**: 2387 high-frequency atomic motifs (freq ≥ 5000)
22
+ + 49 SMILES character tokens + ~45 STAMP structural tokens. Covers ~91%
23
+ of motif occurrences as atomic tokens; rare motifs expand to chars.
24
+ - **Training-time char fallback** with log-interpolated probability
25
+ (~2% at the most frequent motif, ~15% at the cutoff). The model sees
26
+ each atomic motif in both atomic and char form, closing the
27
+ train/inference gap for OOV motifs.
28
+ - **STAMP structural tokens** (`[J_*]`, `[B_*]`, `[S_*]`, `[END]`) act as
29
+ natural motif boundaries — no extra `[MS]`/`[ME]` markers needed.
30
+ - **Drug-like outputs**: 100% validity, 100% uniqueness (at N=1024),
31
+ 79.00% pass the GenMol filter (QED ≥ 0.6 AND SA ≤ 4.0).
32
+
33
+ ## Files
34
+
35
+ | file | what it is |
36
+ |---|---|
37
+ | `model.pt` | torch checkpoint: `{model_state, cfg, epoch, representation, model_type}` |
38
+ | `hybrid_vocab.json` | full vocab with atomic motif map, frequencies, and char expansions |
39
+ | `motif_vocab.txt` | source motif-freq file (v3_cm_union format: `smiles\tn_heavy\tfreq`) |
40
+ | `hybrid_vocab.py` | self-contained `HybridVocab` class for decoding |
41
+ | `config.json` | architecture summary + default sampling + eval numbers |
42
+
43
+ ## Evaluation (N=1024 at T=0.95, top_p=0.85)
44
+
45
+ | metric | value |
46
+ |---|---:|
47
+ | validity | 100.00% |
48
+ | uniqueness (raw SMILES) | 100.00% |
49
+ | quality over valid (QED ≥ 0.6 ∧ SA ≤ 4) | 79.16% |
50
+ | **GenMol score** | **79.00%** |
51
+ | QED mean | 0.727 |
52
+ | SA mean | 2.92 |
53
+ | diversity (1 − pairwise Tanimoto, 1024-bit Morgan r=2) | 0.860 |
54
+
55
+ **Reference (AR baseline, old 12573-token vocab, d=768/l=12, 112M params): 79.64%.**
56
+ The hybrid model matches within noise at 28% of the parameter count.
57
+
58
+ ## Usage
59
+
60
+ ### 1. Load vocab
61
+
62
+ ```python
63
+ from hybrid_vocab import HybridVocab
64
+ vocab = HybridVocab.load("hybrid_vocab.json")
65
+ # vocab.itos -> list of 2481 token strings
66
+ # vocab.atomic_motifs -> {smiles: id} for the 2387 motifs
67
+ # vocab.motif_freq -> {smiles: freq}
68
+ # vocab.motif_expansion -> {smiles: [char_id, ...]}
69
+ ```
70
+
71
+ ### 2. Load model
72
+
73
+ ```python
74
+ import torch
75
+ from dataclasses import dataclass, field
76
+ from typing import Optional
77
+
78
+ # Option A: clone https://github.com/... (STAMP repo) to get `stamp.benchmark.lm`
79
+ from stamp.benchmark.lm import LMConfig, TinyDecoderLM
80
+
81
+ ckpt = torch.load("model.pt", map_location="cpu", weights_only=False)
82
+ cfg = LMConfig(**ckpt["cfg"])
83
+ cfg.use_adaln = True # AO-GPT arch
84
+ model = TinyDecoderLM(vocab_size=len(vocab.itos), cfg=cfg, bidirectional=False)
85
+
86
+ state = ckpt["model_state"]
87
+ # Strip torch.compile prefix if present.
88
+ if any(k.startswith("_orig_mod.") for k in state):
89
+ state = {k.replace("_orig_mod.", "", 1): v for k, v in state.items()}
90
+ model.load_state_dict(state)
91
+ model.eval().cuda()
92
+ ```
93
+
94
+ ### 3. Sample (AR, top-p)
95
+
96
+ ```python
97
+ import torch
98
+ from hybrid_vocab import is_stamp_structural
99
+
100
+ BOS, EOS = vocab.bos_id, vocab.eos_id
101
+ PAD, UNK, MASK = vocab.pad_id, vocab.unk_id, vocab.mask_id
102
+ struct_ids = {vocab.stoi[t] for t in vocab.itos if is_stamp_structural(t)}
103
+ suppress = {PAD, BOS, MASK, UNK}
104
+ T, P = 0.95, 0.85
105
+ n, max_new = 64, 64
106
+
107
+ @torch.no_grad()
108
+ def sample(n_samples=64):
109
+ x = torch.full((n_samples, 1), BOS, dtype=torch.long, device="cuda")
110
+ finished = torch.zeros(n_samples, dtype=torch.bool, device="cuda")
111
+ for step in range(max_new):
112
+ orders = torch.arange(x.size(1), device="cuda").unsqueeze(0).expand(x.size(0), -1)
113
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
114
+ logits = model(x[:, -cfg.max_seq_len:], orders=orders)[:, -1, :].float()
115
+ for sid in suppress:
116
+ logits[:, sid] = float("-inf")
117
+ # top-p
118
+ sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1)
119
+ sorted_probs = torch.softmax(sorted_logits / T, dim=-1)
120
+ cum = torch.cumsum(sorted_probs, dim=-1)
121
+ remove = cum > P
122
+ remove[..., 1:] = remove[..., :-1].clone()
123
+ remove[..., 0] = False
124
+ sorted_logits = sorted_logits.masked_fill(remove, float("-inf"))
125
+ logits = torch.zeros_like(logits).scatter_(-1, sorted_idx, sorted_logits)
126
+ probs = torch.softmax(logits / T, dim=-1)
127
+ nxt = torch.multinomial(probs, 1).squeeze(-1)
128
+ nxt = torch.where(finished, torch.full_like(nxt, EOS), nxt)
129
+ x = torch.cat([x, nxt.unsqueeze(1)], dim=1)
130
+ finished = finished | (nxt == EOS)
131
+ if finished.all():
132
+ break
133
+ return x
134
+ ```
135
+
136
+ ### 4. Decode token stream → SMILES
137
+
138
+ ```python
139
+ def decode_to_stamp_tokens(ids):
140
+ """Flush character runs to motif SMILES at structural token boundaries."""
141
+ special = {PAD, BOS, EOS, MASK, UNK}
142
+ out, buf = [], []
143
+ for i in ids:
144
+ if i in special: continue
145
+ tok = vocab.itos[i]
146
+ if i in struct_ids:
147
+ if buf: out.append("".join(buf)); buf = []
148
+ out.append(tok)
149
+ else:
150
+ buf.append(tok)
151
+ if buf: out.append("".join(buf))
152
+ return out
153
+
154
+ # Then run through the STAMP codec in the stamp repo:
155
+ # from stamp.benchmark.representations import build_representation
156
+ # rep = build_representation("stamp")
157
+ # text = rep.detokenize(stamp_tokens)
158
+ # mol = rep.codec.decode_stamp_to_mol(text)
159
+ ```
160
+
161
+ ## Sample outputs
162
+
163
+ Ten representative draws from this checkpoint (all drug-like, QED ≥ 0.6 ∧ SA ≤ 4):
164
+
165
+ ```
166
+ Cn1nc(CNCc2cc(Cl)ccc2Cl)n(C)c1=O QED=0.935 SA=2.46 MW=300
167
+ CCn1ncc(NC[C@@H]2CCCC[C@@H]2C)c(Br)c1=O QED=0.923 SA=3.31 MW=327
168
+ Cc1cccc(Cl)c1NC(=O)CN1CCO[C@@H](C(F)F)CC1 QED=0.921 SA=2.86 MW=332
169
+ CCN1CCN(CC(=O)Nc2cc(C(F)(F)F)ccc2Cl)CC1 QED=0.908 SA=1.94 MW=349
170
+ COc1ccc(F)c(CNC(=O)C2=CCCCC2)c1 QED=0.907 SA=2.16 MW=263
171
+ CN1CC[C@@H]2[C@@H](CCCN2C(=O)NCc2ccc(OC(F)F)cc2)C1 QED=0.905 SA=3.03 MW=353
172
+ CC[C@H](C(=O)NCc1c(F)cc(F)cc1F)N1CCCC1=O QED=0.905 SA=2.93 MW=314
173
+ Cc1ccc(C2CCN(C(=O)NCc3cccc(F)c3F)CC2)c(=O)n1C QED=0.897 SA=2.45 MW=375
174
+ CN(CC(=O)NCc1ccccc1)C(=O)C12CC3CC(CC(C3)C1)C2 QED=0.896 SA=3.37 MW=340
175
+ NCC1CCN(c2cc3c(cc2F)c(=O)c(C(=O)O)cn3C2CC2)C1 QED=0.884 SA=2.96 MW=345
176
+ ```
177
+
178
+ ## Architecture notes
179
+
180
+ - **AO-GPT**: decoder-only transformer with causal attention over a
181
+ shuffled token order per batch (random permutation of middle tokens,
182
+ BOS/EOS pinned at ends). Target position is conditioned via AdaLN so
183
+ the model learns "any-order" decoding.
184
+ - **Hybrid vocab**: structural tokens + SMILES char tokens + atomic motif
185
+ tokens share a single id space. At training time, atomic motif tokens
186
+ may be expanded to their SMILES char form with a
187
+ log-frequency-weighted probability (`HybridVocab.fallback_prob`) so
188
+ the model is not brittle at char-level decoding.
189
+ - **Decoder**: the STAMP structural tokens delimit motifs; consecutive
190
+ character tokens between structural tokens concatenate to a single
191
+ motif SMILES, which the STAMP codec parses to a molecule via a stack
192
+ machine with safety fallbacks.
193
+
194
+ ## License
195
+
196
+ Apache-2.0.
197
+
198
+ ## Citation
199
+
200
+ Cite the STAMP representation paper and this repository. (Placeholder —
201
+ fill in with your actual citation info.)
config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "ao_gpt_hybrid",
3
+ "architecture": "TinyDecoderLM",
4
+ "vocab_size": 2481,
5
+ "atomic_motifs": 2387,
6
+ "freq_cutoff": 5000,
7
+ "d_model": 512,
8
+ "n_heads": 8,
9
+ "n_layers": 8,
10
+ "d_ff": 2048,
11
+ "max_seq_len": 64,
12
+ "dropout": 0.1,
13
+ "use_adaln": true,
14
+ "bidirectional": false,
15
+ "dtype": "bfloat16",
16
+ "epoch": 7,
17
+ "n_params_total": 31099825,
18
+ "training": {
19
+ "dataset": "30M STAMP molecules (train split, all_pass=True)",
20
+ "train_rows": 19148578,
21
+ "valid_rows_sampled": 20000,
22
+ "optimizer": "AdamW (fused, bf16)",
23
+ "lr": 5e-4,
24
+ "weight_decay": 0.01,
25
+ "micro_batch_size": 6144,
26
+ "global_batch_size": 6144,
27
+ "grad_accum_steps": 1,
28
+ "random_ratio": 0.9,
29
+ "torch_compile": true,
30
+ "fallback_p_low": 0.02,
31
+ "fallback_p_high": 0.15
32
+ },
33
+ "default_sampling": {
34
+ "temperature": 0.95,
35
+ "top_p": 0.85,
36
+ "top_k": 0,
37
+ "max_new_tokens": 64
38
+ },
39
+ "eval": {
40
+ "N": 1024,
41
+ "validity_pct": 100.0,
42
+ "uniqueness_pct": 100.0,
43
+ "quality_over_valid_pct": 79.16,
44
+ "genmol_pct": 79.00,
45
+ "qed_mean": 0.727,
46
+ "sa_mean": 2.92,
47
+ "diversity": 0.860,
48
+ "reference_ar_baseline_genmol_pct": 79.64
49
+ }
50
+ }
hybrid_vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
hybrid_vocab.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid motif+char STAMP vocabulary.
2
+
3
+ Strategy:
4
+ - High-frequency motifs (freq >= cutoff) stay as atomic tokens — one vocab id
5
+ per motif. Rare motifs are always expanded to SMILES char tokens, letting
6
+ the model learn them via atom-level shared structure.
7
+ - During training, even atomic motifs are expanded to chars with a small
8
+ probability `p(freq)` (log-interpolated: higher near the cutoff, lower for
9
+ the very frequent motifs). This closes the train/inference gap for the
10
+ char form, which is the only form in which OOV motifs can appear.
11
+ - No special motif-boundary markers are added. The STAMP structural tokens
12
+ `[J_*]`, `[B_*]`, `[S_*]`, `[END]` already delimit motifs.
13
+
14
+ Vocab layout (stoi / itos):
15
+ 0..4: <pad> <bos> <eos> <unk> <mask>
16
+ 5..K-1: STAMP structural tokens ([J_*], [B_*], [S_*], [END], [BACK], [Ring])
17
+ K..K+C: SMILES char tokens (regex-level SMILES tokens, 49 distinct on our
18
+ v3 motif corpus)
19
+ K+C..: atomic motif SMILES (freq >= cutoff). Single-token-SMILES motifs
20
+ like ``C`` or ``[CH2]`` are NOT duplicated here; they share the char
21
+ id that was allocated above.
22
+
23
+ Tokens stream out of the STAMP canonical form (space-separated motif tokens
24
+ + structural tokens) via ``encode_stream``, which expands rare motifs and
25
+ probabilistically expands atomic motifs.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import math
32
+ import random
33
+ import re
34
+ from collections import Counter
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
38
+
39
+
40
+ # Covers all 12395 motif SMILES in v3_cm_union.txt (kekulized + isomeric).
41
+ # Multi-char atoms `Br`, `Cl` and bracketed groups `[...]` come first so the
42
+ # regex prefers them over their single-letter prefixes.
43
+ SMILES_RE = re.compile(r"(\[[^\]]+\]|Br|Cl|[BCFINOPS]|[0-9]|\(|\)|=|#|-|\+|/|\\|@)")
44
+
45
+
46
+ def smiles_char_tokens(smiles: str) -> List[str]:
47
+ """Tokenize a SMILES string into regex-level atomic tokens.
48
+
49
+ Raises ValueError if the regex does not cover the whole string.
50
+ """
51
+ toks = SMILES_RE.findall(smiles)
52
+ if "".join(toks) != smiles:
53
+ raise ValueError(f"SMILES char tokenizer did not cover input: {smiles!r} -> {toks!r}")
54
+ return toks
55
+
56
+
57
+ STAMP_STRUCT_RE = re.compile(r"^\[(?:J_\d+|B_\d+|S_(?:R|S|E|Z)|Ring)\]$")
58
+
59
+
60
+ def is_stamp_structural(tok: str) -> bool:
61
+ return bool(STAMP_STRUCT_RE.match(tok)) or tok in {"[END]", "[BACK]"}
62
+
63
+
64
+ PAD = "<pad>"
65
+ BOS = "<bos>"
66
+ EOS = "<eos>"
67
+ UNK = "<unk>"
68
+ MASK = "<mask>"
69
+ _SPECIAL = (PAD, BOS, EOS, UNK, MASK)
70
+
71
+
72
+ @dataclass
73
+ class HybridVocab:
74
+ """Vocabulary + atomic-motif expansion table.
75
+
76
+ ``itos`` is the master token list (indices = token ids). ``atomic_motifs``
77
+ maps each atomic-motif string to its id (may overlap char-token ids for
78
+ length-1 SMILES motifs). ``motif_freq`` stores the raw motif frequency
79
+ (for fallback-probability scheduling). ``motif_expansion`` caches the
80
+ SMILES char-token id list for every atomic motif — needed at train time
81
+ when we roll a fallback expansion.
82
+ """
83
+
84
+ itos: List[str]
85
+ atomic_motifs: Dict[str, int] = field(default_factory=dict)
86
+ motif_freq: Dict[str, int] = field(default_factory=dict)
87
+ motif_expansion: Dict[str, List[int]] = field(default_factory=dict)
88
+ freq_cutoff: int = 5000
89
+
90
+ def __post_init__(self) -> None:
91
+ self.stoi: Dict[str, int] = {tok: i for i, tok in enumerate(self.itos)}
92
+ self.pad_id = self.stoi[PAD]
93
+ self.bos_id = self.stoi[BOS]
94
+ self.eos_id = self.stoi[EOS]
95
+ self.unk_id = self.stoi[UNK]
96
+ self.mask_id = self.stoi[MASK]
97
+ # Freq bounds for log-interpolated fallback prob
98
+ freqs = list(self.motif_freq.values()) or [self.freq_cutoff]
99
+ self._log_freq_max = math.log(max(freqs))
100
+ self._log_freq_min = math.log(self.freq_cutoff)
101
+
102
+ # ---- Building ----
103
+
104
+ @classmethod
105
+ def build(
106
+ cls,
107
+ motif_vocab_path: str | Path,
108
+ freq_cutoff: int = 5000,
109
+ extra_stamp_tokens: Optional[Sequence[str]] = None,
110
+ ) -> "HybridVocab":
111
+ """Read a v3_cm_union-style motif vocab file and build a HybridVocab.
112
+
113
+ The file is tab-separated with three columns (smiles, num_heavy_atoms,
114
+ freq) and a leading JSON-config line.
115
+ """
116
+ path = Path(motif_vocab_path)
117
+ motifs: List[Tuple[str, int]] = []
118
+ with path.open("r", encoding="utf-8") as f:
119
+ first = f.readline()
120
+ try:
121
+ json.loads(first)
122
+ except json.JSONDecodeError:
123
+ # Not a JSON header; rewind by re-reading as a data line.
124
+ parts = first.rstrip("\n").split("\t")
125
+ if len(parts) == 3:
126
+ motifs.append((parts[0], int(parts[2])))
127
+ for line in f:
128
+ parts = line.rstrip("\n").split("\t")
129
+ if len(parts) != 3:
130
+ continue
131
+ motifs.append((parts[0], int(parts[2])))
132
+
133
+ atomic = [(smi, freq) for smi, freq in motifs if freq >= freq_cutoff]
134
+
135
+ # Collect SMILES char tokens across ALL motifs (so rare motifs can be
136
+ # expanded) plus the chars appearing inside atomic motifs (for the
137
+ # training-time fallback expansion).
138
+ char_set: set[str] = set()
139
+ for smi, _ in motifs:
140
+ for t in smiles_char_tokens(smi):
141
+ char_set.add(t)
142
+
143
+ itos: List[str] = list(_SPECIAL)
144
+
145
+ # STAMP structural tokens. Default includes the commonly used joint
146
+ # ([J_0]..[J_29]), branch ([B_1]..[B_12]), stereo and control tokens;
147
+ # callers may pass `extra_stamp_tokens` for anything custom.
148
+ stamp_struct = [f"[J_{i}]" for i in range(30)]
149
+ stamp_struct += [f"[B_{i}]" for i in range(1, 13)]
150
+ stamp_struct += ["[S_R]", "[S_S]", "[S_E]", "[S_Z]", "[Ring]", "[END]", "[BACK]"]
151
+ for t in stamp_struct:
152
+ if t not in itos:
153
+ itos.append(t)
154
+ if extra_stamp_tokens:
155
+ for t in extra_stamp_tokens:
156
+ if t not in itos:
157
+ itos.append(t)
158
+
159
+ # SMILES char tokens (sorted for determinism).
160
+ for t in sorted(char_set):
161
+ if t not in itos:
162
+ itos.append(t)
163
+
164
+ # Atomic motifs (freq >= cutoff). Single-token SMILES motifs (e.g., 'C',
165
+ # '[CH2]') share ids with the SMILES-char entries; multi-char motifs
166
+ # get fresh ids.
167
+ # Sorted by descending freq for determinism + for convenient head/tail.
168
+ for smi, _ in sorted(atomic, key=lambda x: (-x[1], x[0])):
169
+ if smi not in itos:
170
+ itos.append(smi)
171
+
172
+ # Finalize.
173
+ vocab = cls(itos=itos, freq_cutoff=freq_cutoff)
174
+
175
+ atomic_ids: Dict[str, int] = {}
176
+ freq_map: Dict[str, int] = {}
177
+ expansion: Dict[str, List[int]] = {}
178
+ for smi, freq in atomic:
179
+ atomic_ids[smi] = vocab.stoi[smi]
180
+ freq_map[smi] = int(freq)
181
+ expansion[smi] = [vocab.stoi[t] for t in smiles_char_tokens(smi)]
182
+
183
+ vocab.atomic_motifs = atomic_ids
184
+ vocab.motif_freq = freq_map
185
+ vocab.motif_expansion = expansion
186
+ vocab.__post_init__() # refresh freq bounds
187
+ return vocab
188
+
189
+ # ---- Fallback probability schedule ----
190
+
191
+ def fallback_prob(self, freq: int, p_low: float = 0.02, p_high: float = 0.15) -> float:
192
+ """Log-interpolate fallback prob. Most frequent motif → p_low; at the
193
+ freq cutoff → p_high. Monotone decreasing in freq."""
194
+ if freq <= self.freq_cutoff:
195
+ return p_high
196
+ f = math.log(max(1, freq))
197
+ denom = self._log_freq_max - self._log_freq_min
198
+ t = 0.0 if denom <= 1e-9 else (f - self._log_freq_min) / denom
199
+ t = max(0.0, min(1.0, t))
200
+ return p_high * (1.0 - t) + p_low * t
201
+
202
+ # ---- Encoding ----
203
+
204
+ def _expand_motif_to_chars(self, smi: str) -> List[int]:
205
+ if smi in self.motif_expansion:
206
+ return list(self.motif_expansion[smi])
207
+ # Encode on the fly for rare motifs (always expanded).
208
+ return [self.stoi.get(t, self.unk_id) for t in smiles_char_tokens(smi)]
209
+
210
+ def encode_stream(
211
+ self,
212
+ tokens: Sequence[str],
213
+ training: bool = False,
214
+ rng: Optional[random.Random] = None,
215
+ p_low: float = 0.02,
216
+ p_high: float = 0.15,
217
+ ) -> List[int]:
218
+ """Encode a STAMP token stream (list of structural tokens + motif
219
+ SMILES, as stored in `stamp_smiles`) into token ids.
220
+
221
+ During training, atomic motifs may be expanded to SMILES chars with
222
+ probability `fallback_prob(freq)`. Rare motifs (below cutoff) always
223
+ expand.
224
+ """
225
+ rnd = rng if rng is not None else random
226
+ out: List[int] = []
227
+ for tok in tokens:
228
+ if is_stamp_structural(tok):
229
+ out.append(self.stoi.get(tok, self.unk_id))
230
+ continue
231
+ if tok in self.atomic_motifs:
232
+ if training:
233
+ p = self.fallback_prob(self.motif_freq[tok], p_low=p_low, p_high=p_high)
234
+ if rnd.random() < p:
235
+ out.extend(self._expand_motif_to_chars(tok))
236
+ continue
237
+ out.append(self.atomic_motifs[tok])
238
+ else:
239
+ out.extend(self._expand_motif_to_chars(tok))
240
+ return out
241
+
242
+ # ---- Persistence ----
243
+
244
+ def to_dict(self) -> Dict:
245
+ return {
246
+ "itos": self.itos,
247
+ "atomic_motifs": self.atomic_motifs,
248
+ "motif_freq": self.motif_freq,
249
+ "motif_expansion": self.motif_expansion,
250
+ "freq_cutoff": self.freq_cutoff,
251
+ }
252
+
253
+ @classmethod
254
+ def from_dict(cls, d: Dict) -> "HybridVocab":
255
+ return cls(
256
+ itos=list(d["itos"]),
257
+ atomic_motifs=dict(d.get("atomic_motifs", {})),
258
+ motif_freq=dict(d.get("motif_freq", {})),
259
+ motif_expansion={k: list(v) for k, v in d.get("motif_expansion", {}).items()},
260
+ freq_cutoff=int(d.get("freq_cutoff", 5000)),
261
+ )
262
+
263
+ def save(self, path: str | Path) -> None:
264
+ Path(path).write_text(json.dumps(self.to_dict(), ensure_ascii=False))
265
+
266
+ @classmethod
267
+ def load(cls, path: str | Path) -> "HybridVocab":
268
+ return cls.from_dict(json.loads(Path(path).read_text()))
model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:611298f400ec12cfa68e1042e9d5c84c542add640b53db950e3e2115e5c7443e
3
+ size 124436358
motif_vocab.txt ADDED
The diff for this file is too large to render. See raw diff