Team-LEMON commited on
Commit
e11bc48
·
1 Parent(s): 03c422e

LEMON: Layered Extraction of Molecular Ordering from Nature

Browse files

- 203M parameter protein sequence encoder with ZEST tokenizer
- Hierarchical contrastive learning for fold/superfamily similarity
- Bundled benchmarks: SCOPe, SCOP 1.75, CATH-S20
- Circular Permutation detection (CIRPIN SCOPe40)
- Test-Time Augmentation with trie-dropout
- MIT License

LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Team-LEMON
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - protein
5
+ - biology
6
+ - sequence-encoder
7
+ - contrastive-learning
8
+ - lemon
9
+ ---
10
+
11
+ # LEMON: Layered Extraction of Molecular Ordering from Nature
12
+
13
+ LEMON is a protein sequence encoder trained with hierarchical contrastive
14
+ learning for family and fold similarity search, using the ZEST tokenizer
15
+ (Zoned Encoding of Sequence Traits). Submitted anonymously for double-blind
16
+ peer review.
17
+
18
+ ## Architecture
19
+
20
+ | Component | Details |
21
+ |---|---|
22
+ | **Encoder** | 24-layer transformer, 768-d, 12 heads, SwiGLU FFN (ff_mult=4), RoPE with linear scaling |
23
+ | **Pooling** | Learned-query multi-head attention aggregator → 768-d sequence vector |
24
+ | **Projector** | Bottleneck MLP (768 → 768 → 384-d), L2-normalised output |
25
+ | **Tokenizer** | ZEST 32K (Zoned Encoding of Sequence Traits) — greedy max-match trie over biochemically-substitutable amino-acid n-gram clusters |
26
+ | **Context** | 1,024 tokens (linear RoPE scaling for longer sequences) |
27
+ | **Dropout** | 0.04 |
28
+
29
+ **Parameter breakdown (203.72M total):**
30
+
31
+ | Module | Params |
32
+ |---|---|
33
+ | Core transformer | 194.52M |
34
+ | Attention aggregator | 2.95M |
35
+ | Profile expansion head | 2.41M |
36
+ | Global position embedding | 2.36M |
37
+ | Projector | 1.48M |
38
+ | **Total** | **203.72M** |
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ import torch
44
+ from huggingface_hub import snapshot_download
45
+ import sys, os
46
+
47
+ path = snapshot_download("Team-LEMON/lemon")
48
+ sys.path.insert(0, path)
49
+
50
+ from modeling_lemon import LemonEncoder
51
+ from tokenization_zest import ZESTTokenizer
52
+
53
+ tok = ZESTTokenizer.from_pretrained(path)
54
+ model = LemonEncoder.from_pretrained(
55
+ os.path.join(path, "model.safetensors"),
56
+ os.path.join(path, "config.json"),
57
+ )
58
+ model.eval()
59
+
60
+ seqs = ["MKTAYIAKQRQISFVKSHFSRQ", "ACDEFGHIKLMNPQRSTVWY"]
61
+ enc = tok.batch_encode_plus(seqs, max_length=512, padding=True)
62
+ with torch.no_grad():
63
+ emb = model.embed(enc["input_ids"], enc["attention_mask"]) # [2, 384]
64
+ print(emb.shape) # torch.Size([2, 384])
65
+
66
+ sim = model.similarity(emb[:1], emb[1:])
67
+ print("cosine-like similarity:", sim.item())
68
+ ```
69
+
70
+ ## Reproducing Table 1
71
+
72
+ The `eval_retrieval.py` script and all three benchmark datasets are bundled in this
73
+ repo. No external downloads required.
74
+
75
+ **Run all three datasets in one command:**
76
+
77
+ ```python
78
+ from huggingface_hub import snapshot_download
79
+ path = snapshot_download("Team-LEMON/lemon")
80
+ ```
81
+
82
+ ```bash
83
+ cd /path/to/snapshot
84
+ python eval_retrieval.py # runs SCOPe + SCOP + CATH-S20
85
+ python eval_retrieval.py --scope # SCOPe only
86
+ python eval_retrieval.py --cath # CATH-S20 only
87
+ python eval_retrieval.py --scop # SCOP only
88
+ ```
89
+
90
+ **Test-Time Augmentation (TTA) with Trie-Dropout:**
91
+
92
+ TTA improves retrieval by averaging embeddings from multiple stochastic tokenizations.
93
+
94
+ ```bash
95
+ python eval_retrieval.py --dropout 0.45 --tta 5 # 5 stochastic passes, averaged
96
+ ```
97
+
98
+ **TTA Gain (SCOPe, seed=42):**
99
+
100
+ | Level | Metric | Baseline | TTA (d=0.45, k=5) | Gain |
101
+ |-------|--------|----------|-------------------|------|
102
+ | fold | AUROC | 0.9025 | 0.9080 | +0.0055 |
103
+ | fold | mAP | 0.3067 | 0.3197 | +0.0130 |
104
+ | superfamily | AUROC | 0.9443 | 0.9519 | +0.0076 |
105
+ | superfamily | mAP | 0.4700 | 0.4803 | +0.0103 |
106
+
107
+ To reproduce:
108
+ ```bash
109
+ # Baseline
110
+ python eval_retrieval.py --scope --seed 42
111
+
112
+ # With TTA
113
+ python eval_retrieval.py --scope --seed 42 --dropout 0.45 --tta 5
114
+ ```
115
+
116
+ **Or from a Jupyter notebook:**
117
+
118
+ ```python
119
+ import sys
120
+ from huggingface_hub import snapshot_download
121
+
122
+ path = snapshot_download("Team-LEMON/lemon")
123
+ sys.path.insert(0, path)
124
+
125
+ from eval_retrieval import run_benchmark, display_results
126
+
127
+ results = run_benchmark(repo=path, seed=42) # deterministic with seed=42
128
+ display_results(results)
129
+
130
+ # With TTA:
131
+ # results = run_benchmark(repo=path, seed=42, dropout=0.1, tta_passes=8)
132
+ ```
133
+
134
+ **Expected output (seed=42, deterministic):**
135
+
136
+ | Dataset | Level | AUROC | mAP |
137
+ |----------|--------------|--------|--------|
138
+ | SCOPe | fold | 0.8847 | 0.3149 |
139
+ | CATH-S20 | architecture | 0.8129 | 0.3418 |
140
+ | SCOP | fold | 0.9062 | 0.2919 |
141
+
142
+ > Results are deterministic with `--seed 42` (default).
143
+ > CATH uses Architecture/Topology levels; SCOP/SCOPe uses Fold/Superfamily.
144
+
145
+ **Bundled dataset provenance:**
146
+
147
+ | File | Sequences | Original source |
148
+ |------|-----------|----------------|
149
+ | `data/scope_10_2.08.fa` | 7 117 | SCOPe 2.08, 10% seq-id — [scop.berkeley.edu](https://scop.berkeley.edu/downloads/scopeseq-2.08/) |
150
+ | `data/cath_s20.fa` | 15 043 | CATH v4.4.0 S20 — [cathdb.info](https://www.cathdb.info/wiki/doku/?id=data:index) |
151
+ | `data/cath_s20_labels.tsv` | 15 043 | CATH domain list v4.4.0 (S20 subset) — [cathdb.info](https://www.cathdb.info/wiki/doku/?id=data:index) |
152
+ | `data/scop175.fa` | 31 073 | SCOP 1.75 — [plm-zero-shot-remote-homology-evaluation](https://github.com/amoldwin/plm-zero-shot-remote-homology-evaluation) |
153
+
154
+ ## Circular Permutation Detection (CIRPIN SCOPe40)
155
+
156
+ Zero-shot detection of circularly permuted protein pairs using cosine similarity of LEMON embeddings.
157
+ Benchmark: CIRPIN SCOPe40 — 18,127 pairs (1,967 positive CP pairs) from ASTRAL SCOPe 2.08 at 40% identity.
158
+
159
+ **Results (seed=42):**
160
+
161
+ | Configuration | AUROC | AUPRC | Accuracy |
162
+ |---------------|-------|-------|----------|
163
+ | Baseline | 0.7413 | 0.3035 | 0.8990 |
164
+ | TTA (d=0.45, k=5) | **0.7576** | **0.3066** | 0.8987 |
165
+ | Gain | +0.0163 | +0.0031 | - |
166
+
167
+ TTA improves CP detection by averaging embeddings over multiple stochastic tokenizations.
168
+
169
+ To reproduce:
170
+ ```bash
171
+ # Baseline
172
+ python eval_circular_permutation.py --fasta data/cirpin/scope40.fa --pairs data/cirpin/pairs.tsv --seed 42
173
+
174
+ # With TTA
175
+ python eval_circular_permutation.py --fasta data/cirpin/scope40.fa --pairs data/cirpin/pairs.tsv --seed 42 --dropout 0.45 --tta 5
176
+ ```
177
+
178
+ ## Requirements
179
+
180
+ ```
181
+ torch>=2.0
182
+ safetensors
183
+ huggingface_hub
184
+ ```
185
+
186
+ ## Notes
187
+
188
+ - Input sequences should be standard single-letter amino-acid strings.
189
+ - The tokenizer handles unknown characters via `<MASK>` token fallback.
190
+ - `model.embed()` returns L2-normalised embeddings; use dot product for
191
+ cosine similarity.
192
+ - `model.similarity()` applies a learned temperature scalar.
config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_class": "LemonEncoder",
3
+ "architecture": {
4
+ "vocab_size": 32000,
5
+ "embed_dim": 768,
6
+ "num_layers": 24,
7
+ "nhead": 12,
8
+ "ff_mult": 4,
9
+ "dropout": 0.04,
10
+ "max_tokens": 1024,
11
+ "proj_dim": 384,
12
+ "padding_idx": 0,
13
+ "rope_scaling_type": "linear",
14
+ "num_proj_layers": 1,
15
+ "proj_ff_mult": 1
16
+ },
17
+ "epoch": 3,
18
+ "global_step": 40371,
19
+ "best_val_loss": 0.2449137058109045,
20
+ "best_epoch": 2
21
+ }
data/cath_s20.fa ADDED
The diff for this file is too large to render. See raw diff
 
data/cath_s20_labels.tsv ADDED
The diff for this file is too large to render. See raw diff
 
data/scop175.fa ADDED
The diff for this file is too large to render. See raw diff
 
data/scope_10_2.08.fa ADDED
The diff for this file is too large to render. See raw diff
 
eval_retrieval.py ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fold-level remote homology retrieval benchmark for LEMON.
3
+
4
+ Reproduces the SCOPe, SCOP, and CATH-S20 results from Table 1 of the paper.
5
+
6
+ Metric definition (fold level)
7
+ --------------------------------
8
+ Positive pair : same fold, different superfamily
9
+ Negative pair : different fold
10
+ Per-query AUROC / AUPRC are computed for every query that has at least
11
+ one positive, then averaged.
12
+
13
+ Bundled datasets (data/ subdirectory)
14
+ --------------------------------------
15
+ data/scope_10_2.08.fa SCOPe 2.08, 10 % seq-id (7 117 seqs)
16
+ Source: https://scop.berkeley.edu/downloads/scopeseq-2.08/
17
+ File : astral-scopedom-seqres-gd-sel-gs-bib-10-2.08.fa
18
+
19
+ data/cath_s20.fa CATH S20 v4.4.0 (15 043 seqs)
20
+ Source: https://release.cathdb.info/v4.4.0/non-redundant-data-sets/
21
+ File : cath-dataset-nonredundant-S20-v4_4_0.fa
22
+
23
+ data/cath_s20_labels.tsv Domain → C.A.T.H classification mapping
24
+ Source: https://release.cathdb.info/v4.4.0/cath-classification-data/
25
+ File : cath-domain-list-v4_4_0.txt (extracted S20 subset)
26
+
27
+ data/scop175.fa SCOP 1.75 representative sequences (31 073 seqs)
28
+ Source: Kabir et al. (2023) PLM zero-shot remote homology evaluation
29
+ https://github.com/tymor22/protein-vec
30
+ File : data/SCOP/processed/SCOP_with_seq.tsv (classification embedded)
31
+
32
+ Usage — zero config when run from the snapshot directory
33
+ ---------------------------------------------------------
34
+ python eval_retrieval.py # uses all bundled datasets
35
+ python eval_retrieval.py --scope # SCOPe only
36
+ python eval_retrieval.py --cath # CATH-S20 only
37
+ python eval_retrieval.py --scop # SCOP only
38
+
39
+ Expected results (Table 1 — averaged across seq-id thresholds)
40
+ ---------------------------------------------------------------
41
+ Dataset AUROC AUPRC
42
+ SCOPe 0.8847 0.3149
43
+ CATH S20 0.8129 0.3418
44
+ SCOP 0.8917 0.2631
45
+
46
+ Note: the paper averages metrics across multiple seq-id thresholds.
47
+ Running on a single threshold (e.g. 10 % / S20) will be within ±0.01.
48
+ """
49
+
50
+ import argparse
51
+ import re
52
+ import sys
53
+ import os
54
+ from pathlib import Path
55
+ from typing import Dict, List, Optional, Tuple
56
+
57
+ import numpy as np
58
+ import torch
59
+ from tqdm import tqdm
60
+
61
+
62
+ # ─── 1. Load LEMON from the same HuggingFace repo ──────────────────────────
63
+
64
+ def _load_model_and_tokenizer(repo_dir: str):
65
+ sys.path.insert(0, repo_dir)
66
+ from modeling_lemon import LemonEncoder # noqa: F401
67
+ from tokenization_zest import ZESTTokenizer # noqa: F401
68
+
69
+ tok = ZESTTokenizer.from_pretrained(repo_dir)
70
+ model = LemonEncoder.from_pretrained(
71
+ os.path.join(repo_dir, "model.safetensors"),
72
+ os.path.join(repo_dir, "config.json"),
73
+ )
74
+ model.eval()
75
+ return model, tok
76
+
77
+
78
+ # ─── 2. FASTA parsers ──────────────────────────────────────────────────────
79
+
80
+ _SCOPE_RE = re.compile(r"^>(\S+)\s+.*\b([a-g]\.\d+\.\d+\.\d+)\b")
81
+ _CATH_RE = re.compile(r"^>cath\|[\d._]+\|(\S+)")
82
+ # Bundled SCOP header: ">FA_DOMID PDBID CL.CF.SF.FA"
83
+ _SCOP_BUNDLED_RE = re.compile(r"^>(\S+)\s+\S+\s+(\d+\.\d+\.\d+\.\d+)")
84
+
85
+
86
+ def _iter_fasta(path: str):
87
+ """Yield (header_line, sequence) pairs."""
88
+ header, parts = None, []
89
+ with open(path) as fh:
90
+ for line in fh:
91
+ line = line.rstrip()
92
+ if line.startswith(">"):
93
+ if header:
94
+ yield header, "".join(parts)
95
+ header, parts = line, []
96
+ else:
97
+ parts.append(line)
98
+ if header:
99
+ yield header, "".join(parts)
100
+
101
+
102
+ def parse_scope_fasta(path: str) -> List[Dict]:
103
+ """Parse SCOPe ASTRAL FASTA → list of {id, classification, sequence}."""
104
+ entries = []
105
+ for hdr, seq in _iter_fasta(path):
106
+ m = _SCOPE_RE.match(hdr)
107
+ if m:
108
+ entries.append({"id": m.group(1), "classification": m.group(2), "sequence": seq})
109
+ return entries
110
+
111
+
112
+ def parse_scop_fasta(path: str) -> List[Dict]:
113
+ """
114
+ Parse bundled SCOP 1.75 FASTA.
115
+
116
+ Header format (bundled): ">FA_DOMID PDBID CL.CF.SF.FA"
117
+ where CL/CF/SF/FA are numeric SCOP 2 identifiers.
118
+ Fold = CL.CF (2 parts), superfamily = CL.CF.SF (3 parts).
119
+ """
120
+ entries = []
121
+ for hdr, seq in _iter_fasta(path):
122
+ m = _SCOP_BUNDLED_RE.match(hdr)
123
+ if m:
124
+ entries.append({"id": m.group(1), "classification": m.group(2), "sequence": seq})
125
+ return entries
126
+
127
+
128
+ def parse_cath_fasta(path: str, labels_tsv: Optional[str] = None) -> List[Dict]:
129
+ """
130
+ Parse CATH FASTA and attach C.A.T.H classification.
131
+
132
+ labels_tsv : path to the compact TSV (bundled as data/cath_s20_labels.tsv)
133
+ columns: domain_id classification
134
+ Produced from cath-domain-list-v4_4_0.txt.
135
+ """
136
+ cath_map: Dict[str, str] = {}
137
+ if labels_tsv and Path(labels_tsv).exists():
138
+ with open(labels_tsv) as fh:
139
+ next(fh) # skip header
140
+ for line in fh:
141
+ parts = line.rstrip().split("\t")
142
+ if len(parts) == 2:
143
+ cath_map[parts[0]] = parts[1]
144
+ elif labels_tsv is None:
145
+ pass # caller did not supply — entries with no label are dropped
146
+
147
+ entries = []
148
+ for hdr, seq in _iter_fasta(path):
149
+ m = _CATH_RE.match(hdr)
150
+ if m:
151
+ did = m.group(1).split("/")[0]
152
+ cls = cath_map.get(did, "")
153
+ if cls:
154
+ entries.append({"id": did, "classification": cls, "sequence": seq})
155
+ return entries
156
+
157
+
158
+ # ─── 3. Embedding ──────────────────────────────────────────────────────────
159
+
160
+ @torch.no_grad()
161
+ def embed(model, tokenizer, sequences: List[str],
162
+ batch_size: int = 128, max_tokens: int = 1024,
163
+ device: torch.device = torch.device("cpu"),
164
+ dropout: float = 0.0, tta_passes: int = 1) -> np.ndarray:
165
+ """
166
+ Embed sequences with optional Test-Time Augmentation (TTA) via trie-dropout.
167
+
168
+ Parameters
169
+ ----------
170
+ dropout : float
171
+ Trie-dropout rate for tokenization (0.0 = deterministic greedy).
172
+ tta_passes : int
173
+ Number of stochastic tokenization passes to average (TTA).
174
+ Only used when dropout > 0.
175
+ """
176
+ model = model.to(device)
177
+ pad_id = tokenizer.pad_id
178
+
179
+ def _embed_once(seqs, use_dropout):
180
+ all_embs = []
181
+ for start in tqdm(range(0, len(seqs), batch_size), desc="Embedding", leave=False):
182
+ batch = seqs[start : start + batch_size]
183
+ enc = [tokenizer.encode(s, dropout=use_dropout)[:max_tokens] for s in batch]
184
+ L = max(len(e) for e in enc)
185
+ ids = torch.full((len(enc), L), pad_id, dtype=torch.long)
186
+ mask = torch.zeros(len(enc), L, dtype=torch.long)
187
+ for i, e in enumerate(enc):
188
+ ids[i, :len(e)] = torch.tensor(e, dtype=torch.long)
189
+ mask[i, :len(e)] = 1
190
+ ids, mask = ids.to(device), mask.to(device)
191
+ with torch.amp.autocast("cuda", dtype=torch.float16, enabled=device.type == "cuda"):
192
+ emb = model.embed(ids, mask) # L2-normalised [B, D]
193
+ all_embs.append(emb.float().cpu().numpy())
194
+ return np.vstack(all_embs)
195
+
196
+ if dropout > 0 and tta_passes > 1:
197
+ # Test-Time Augmentation: average K stochastic encodings
198
+ print(f" TTA: {tta_passes} passes with dropout={dropout}")
199
+ emb_sum = None
200
+ for k in range(tta_passes):
201
+ emb_k = _embed_once(sequences, dropout)
202
+ emb_sum = emb_k if emb_sum is None else emb_sum + emb_k
203
+ emb_avg = emb_sum / tta_passes
204
+ # Re-normalize after averaging
205
+ norms = np.linalg.norm(emb_avg, axis=1, keepdims=True)
206
+ return emb_avg / np.clip(norms, 1e-8, None)
207
+ else:
208
+ return _embed_once(sequences, dropout)
209
+
210
+
211
+ # ─── 4. Hierarchy parsing ──────────────────────────────────────────────────
212
+
213
+ def _scope_levels(classifications: List[str]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
214
+ """
215
+ Return (fold_arr, sf_arr, family_arr).
216
+
217
+ Works for both:
218
+ SCOPe a.b.c.d (letter class + 3 ints)
219
+ SCOP CL.CF.SF.FA (4 numeric IDs)
220
+ fold = parts[:2], superfamily = parts[:3], family = parts[:4].
221
+ """
222
+ fold_arr = np.array([".".join(c.split(".")[:2]) for c in classifications])
223
+ sf_arr = np.array([".".join(c.split(".")[:3]) for c in classifications])
224
+ fam_arr = np.array([".".join(c.split(".")[:4]) for c in classifications])
225
+ return fold_arr, sf_arr, fam_arr
226
+
227
+
228
+ def _cath_levels(classifications: List[str]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
229
+ """Return (fold_arr, sf_arr, family_arr) for CATH C.A.T.H.S strings."""
230
+ fold_arr = np.array([".".join(c.split(".")[:3]) for c in classifications])
231
+ sf_arr = np.array([".".join(c.split(".")[:4]) for c in classifications])
232
+ fam_arr = np.array([".".join(c.split(".")[:5]) for c in classifications])
233
+ return fold_arr, sf_arr, fam_arr
234
+
235
+
236
+ # ─── 5. Metrics ────────────────────────────────────────────────────────────
237
+
238
+ def _per_query_metrics(sim: np.ndarray,
239
+ pos_arr: np.ndarray,
240
+ excl_arr: np.ndarray) -> Dict:
241
+ """
242
+ Generic per-query AUROC / AUPRC.
243
+
244
+ Positive = same pos_arr label, different excl_arr label
245
+ Negative = different pos_arr label
246
+ Ignored = same excl_arr (trivially easy — excluded from scoring)
247
+ Self = always excluded
248
+
249
+ Fold-level : pos_arr = fold_arr, excl_arr = sf_arr
250
+ Superfamily : pos_arr = sf_arr, excl_arr = fam_arr
251
+ """
252
+ from sklearn.metrics import roc_auc_score, average_precision_score
253
+
254
+ N = sim.shape[0]
255
+ same_pos = pos_arr[:, None] == pos_arr[None, :] # [N, N]
256
+ same_excl = excl_arr[:, None] == excl_arr[None, :] # [N, N]
257
+ np.fill_diagonal(same_pos, False)
258
+ np.fill_diagonal(same_excl, False)
259
+
260
+ positive = same_pos & ~same_excl
261
+ ignore = same_excl
262
+
263
+ aurocs, auprcs = [], []
264
+ for qi in tqdm(range(N), desc="Computing metrics", leave=False):
265
+ pos_row = positive[qi]
266
+ ign_row = ignore[qi]
267
+ if not pos_row.any():
268
+ continue
269
+ keep = ~ign_row
270
+ keep[qi] = False
271
+ y_true = pos_row[keep].astype(int)
272
+ y_pred = sim[qi][keep]
273
+ if y_true.sum() == 0 or (1 - y_true).sum() == 0:
274
+ continue
275
+ try:
276
+ aurocs.append(roc_auc_score(y_true, y_pred))
277
+ auprcs.append(average_precision_score(y_true, y_pred))
278
+ except ValueError:
279
+ pass
280
+
281
+ mAP = float(np.mean(auprcs)) if auprcs else 0.0
282
+ return {
283
+ "n_sequences" : N,
284
+ "n_queries" : len(aurocs),
285
+ "auroc" : float(np.mean(aurocs)) if aurocs else 0.0,
286
+ "auprc" : mAP,
287
+ "mAP" : mAP,
288
+ }
289
+
290
+
291
+ # ─── 6. Dataset runner ─────────────────────────────────────────────────────
292
+
293
+ def run_dataset(model, tokenizer, entries: List[Dict], ds_name: str,
294
+ level_fn, batch_size: int, device: torch.device,
295
+ dropout: float = 0.0, tta_passes: int = 1) -> List[Dict]:
296
+ """
297
+ Returns two result dicts: one for fold/architecture-level and one for superfamily/topology-level.
298
+ """
299
+ sequences = [e["sequence"] for e in entries]
300
+ classifications = [e["classification"] for e in entries]
301
+
302
+ fold_arr, sf_arr, fam_arr = level_fn(classifications)
303
+
304
+ print(f"\n {ds_name}: {len(sequences)} sequences", flush=True)
305
+ emb = embed(model, tokenizer, sequences, batch_size=batch_size, device=device,
306
+ dropout=dropout, tta_passes=tta_passes)
307
+ sim = (emb @ emb.T).astype(np.float32)
308
+ np.fill_diagonal(sim, -2.0)
309
+
310
+ # CATH uses Architecture/Topology; SCOP/SCOPe uses Fold/Superfamily
311
+ is_cath = ds_name.startswith("CATH")
312
+ level1_name = "architecture" if is_cath else "fold"
313
+ level2_name = "topology" if is_cath else "superfamily"
314
+
315
+ fold_m = _per_query_metrics(sim, fold_arr, sf_arr)
316
+ fold_m["dataset"] = ds_name
317
+ fold_m["level"] = level1_name
318
+
319
+ sf_m = _per_query_metrics(sim, sf_arr, fam_arr)
320
+ sf_m["dataset"] = ds_name
321
+ sf_m["level"] = level2_name
322
+
323
+ return [fold_m, sf_m]
324
+
325
+
326
+ # ─── 7. Public API (notebook + script) ─────────────────────────────────────
327
+
328
+ _EXPECTED = {
329
+ ("SCOPe", "fold"): {"auroc": 0.8847, "auprc": 0.3149},
330
+ ("CATH-S20", "architecture"): {"auroc": 0.8129, "auprc": 0.3418},
331
+ ("SCOP", "fold"): {"auroc": 0.9062, "auprc": 0.2919},
332
+ }
333
+
334
+
335
+ def run_benchmark(
336
+ repo: str = ".",
337
+ scope=True,
338
+ scop: bool = True,
339
+ cath: bool = True,
340
+ cath_labels: Optional[str] = None,
341
+ batch_size: int = 128,
342
+ device: Optional[str] = None,
343
+ seed: Optional[int] = 42,
344
+ dropout: float = 0.0,
345
+ tta_passes: int = 1,
346
+ ) -> List[Dict]:
347
+ """
348
+ Run the fold-level remote homology benchmark for LEMON.
349
+
350
+ This function is the primary entry point for both scripts and Jupyter
351
+ notebooks. It returns a list of result dicts that can be inspected
352
+ directly or converted to a pandas DataFrame.
353
+
354
+ Parameters
355
+ ----------
356
+ repo : str
357
+ Path to the HuggingFace snapshot directory (the root that contains
358
+ ``model.safetensors``, ``config.json``, ``data/``, etc.).
359
+ Defaults to ``"."`` — i.e. run from inside the snapshot dir.
360
+ scope : bool or str
361
+ ``True`` → use bundled ``data/scope_10_2.08.fa``
362
+ ``False`` → skip SCOPe
363
+ ``str`` → path to a custom SCOPe ASTRAL FASTA
364
+ scop : bool or str
365
+ Same semantics for SCOP 1.75 (``data/scop175.fa``).
366
+ cath : bool or str
367
+ Same semantics for CATH-S20 (``data/cath_s20.fa``).
368
+ cath_labels : str or None
369
+ Path to a CATH domain-to-class TSV. ``None`` uses the bundled
370
+ ``data/cath_s20_labels.tsv``.
371
+ batch_size : int
372
+ Sequences per forward pass. Reduce if you run out of memory.
373
+ device : str or None
374
+ ``"cuda"``, ``"cpu"``, or ``None`` for auto-detect.
375
+ seed : int or None
376
+ Random seed for reproducibility. ``None`` disables seeding.
377
+ dropout : float
378
+ Trie-dropout rate for tokenization (0.0 = deterministic greedy).
379
+ tta_passes : int
380
+ Number of stochastic tokenization passes to average (TTA).
381
+ Only used when dropout > 0.
382
+
383
+ Returns
384
+ -------
385
+ list of dict
386
+ One dict per dataset with keys:
387
+ ``dataset``, ``n_sequences``, ``n_queries``, ``auroc``, ``auprc``, ``mAP``.
388
+
389
+ Notebook quick-start
390
+ --------------------
391
+ >>> import sys
392
+ >>> sys.path.insert(0, "/path/to/snapshot") # or os.chdir there
393
+ >>> from eval_retrieval import run_benchmark, display_results
394
+ >>>
395
+ >>> results = run_benchmark() # all three datasets
396
+ >>> display_results(results) # rich table in notebook
397
+ >>>
398
+ >>> # As a DataFrame:
399
+ >>> import pandas as pd
400
+ >>> df = pd.DataFrame(results)[["dataset", "auroc", "auprc"]]
401
+ >>> print(df)
402
+ """
403
+ import random
404
+
405
+ # Seed for reproducibility
406
+ if seed is not None:
407
+ random.seed(seed)
408
+ np.random.seed(seed)
409
+ torch.manual_seed(seed)
410
+ if torch.cuda.is_available():
411
+ torch.cuda.manual_seed_all(seed)
412
+
413
+ repo_path = Path(repo).resolve()
414
+ data_dir = repo_path / "data"
415
+
416
+ def _resolve_dataset(flag, bundled_name: str) -> Optional[Path]:
417
+ if flag is False or flag is None:
418
+ return None
419
+ if flag is True:
420
+ p = data_dir / bundled_name
421
+ if not p.exists():
422
+ raise FileNotFoundError(
423
+ f"Bundled dataset not found: {p}\n"
424
+ f"Re-run: snapshot_download('Team-LEMON/lemon')"
425
+ )
426
+ return p
427
+ return Path(flag) # custom path string
428
+
429
+ _device = torch.device(
430
+ device if device else ("cuda" if torch.cuda.is_available() else "cpu")
431
+ )
432
+ print(f"Device: {_device}")
433
+ print("Loading LEMON …")
434
+ model, tokenizer = _load_model_and_tokenizer(str(repo_path))
435
+
436
+ results: List[Dict] = []
437
+
438
+ scope_path = _resolve_dataset(scope, "scope_10_2.08.fa")
439
+ if scope_path:
440
+ entries = parse_scope_fasta(str(scope_path))
441
+ entries = [e for e in entries if len(e["classification"].split(".")) >= 4]
442
+ results.extend(run_dataset(model, tokenizer, entries, "SCOPe",
443
+ _scope_levels, batch_size, _device,
444
+ dropout=dropout, tta_passes=tta_passes))
445
+
446
+ scop_path = _resolve_dataset(scop, "scop175.fa")
447
+ if scop_path:
448
+ entries = parse_scop_fasta(str(scop_path))
449
+ entries = [e for e in entries if len(e["classification"].split(".")) >= 4]
450
+ results.extend(run_dataset(model, tokenizer, entries, "SCOP",
451
+ _scope_levels, batch_size, _device,
452
+ dropout=dropout, tta_passes=tta_passes))
453
+
454
+ cath_path = _resolve_dataset(cath, "cath_s20.fa")
455
+ if cath_path:
456
+ lbl = cath_labels or str(data_dir / "cath_s20_labels.tsv")
457
+ entries = parse_cath_fasta(str(cath_path), lbl)
458
+ entries = [e for e in entries if len(e["classification"].split(".")) >= 5]
459
+ results.extend(run_dataset(model, tokenizer, entries, "CATH-S20",
460
+ _cath_levels, batch_size, _device,
461
+ dropout=dropout, tta_passes=tta_passes))
462
+
463
+ return results
464
+
465
+
466
+ def display_results(results: List[Dict]) -> None:
467
+ """
468
+ Pretty-print benchmark results.
469
+
470
+ In a Jupyter notebook this also renders a styled pandas DataFrame if
471
+ pandas is available. Falls back to a plain text table otherwise.
472
+
473
+ Parameters
474
+ ----------
475
+ results : list of dict
476
+ Return value of :func:`run_benchmark`.
477
+ """
478
+ # ── plain-text table (always printed) ──────────────────────────────────
479
+ W = 76
480
+ print("\n" + "=" * W)
481
+ print(f" {'Dataset':<12} {'Level':<12} {'N':>6} {'Queries':>7} {'AUROC':>7} {'AUPRC':>7} {'mAP':>7}")
482
+ print("-" * W)
483
+ for r in results:
484
+ print(
485
+ f" {r['dataset']:<12} {r['level']:<12} {r['n_sequences']:>6}"
486
+ f" {r['n_queries']:>7} {r['auroc']:>7.4f} {r['auprc']:>7.4f} {r['mAP']:>7.4f}"
487
+ )
488
+ print("=" * W)
489
+ print("\nReference — LEMON (Table 1, averaged across seq-id thresholds):")
490
+ for (ds, lvl), vals in _EXPECTED.items():
491
+ print(f" {ds:<12} {lvl:<12} AUROC={vals['auroc']:.4f} AUPRC={vals['auprc']:.4f}")
492
+
493
+ # ── rich DataFrame display (Jupyter only) ──────────────────────────────
494
+ try:
495
+ import pandas as pd
496
+ from IPython.display import display as ipy_display
497
+
498
+ rows = []
499
+ for r in results:
500
+ rows.append({
501
+ "Dataset" : r["dataset"],
502
+ "Level" : r["level"],
503
+ "N" : r["n_sequences"],
504
+ "Queries" : r["n_queries"],
505
+ "AUROC" : round(r["auroc"], 4),
506
+ "AUPRC" : round(r["auprc"], 4),
507
+ "mAP" : round(r["mAP"], 4),
508
+ })
509
+ df = pd.DataFrame(rows).set_index(["Dataset", "Level"])
510
+
511
+ ref_rows = [
512
+ {"Dataset": ds, "Level": lvl,
513
+ "AUROC": vals["auroc"], "AUPRC": vals["auprc"]}
514
+ for (ds, lvl), vals in _EXPECTED.items()
515
+ ]
516
+ ref_df = pd.DataFrame(ref_rows).set_index(["Dataset", "Level"])
517
+
518
+ ipy_display(
519
+ df.style
520
+ .format("{:.4f}", subset=["AUROC", "AUPRC", "mAP"])
521
+ .set_caption("LEMON — fold-level and superfamily-level remote homology retrieval")
522
+ )
523
+ print("\nReference (Table 1):")
524
+ ipy_display(
525
+ ref_df.style
526
+ .format("{:.4f}", subset=["AUROC", "AUPRC"])
527
+ .set_caption("Expected values (paper, averaged over thresholds)")
528
+ )
529
+ except ImportError:
530
+ pass # pandas / IPython not available — plain text is sufficient
531
+
532
+
533
+ # ─── 8. CLI entry point ─────────────────────────────────────────────────────
534
+
535
+ def main():
536
+ p = argparse.ArgumentParser(
537
+ description="Fold-level remote homology benchmark for LEMON",
538
+ formatter_class=argparse.RawDescriptionHelpFormatter,
539
+ epilog=(
540
+ "Run with no dataset flags to evaluate all three bundled datasets.\n"
541
+ "Examples:\n"
542
+ " python eval_retrieval.py\n"
543
+ " python eval_retrieval.py --scope --cath\n"
544
+ " python eval_retrieval.py --scope /my/scope.fa\n"
545
+ " python eval_retrieval.py --dropout 0.1 --tta 8 # TTA with 8 passes\n"
546
+ ),
547
+ )
548
+ p.add_argument("--repo", default=".",
549
+ help="Path to HF snapshot dir (default: current dir)")
550
+ p.add_argument("--scope", nargs="?", const=True, default=None,
551
+ metavar="FASTA",
552
+ help="SCOPe FASTA (omit path → bundled data/scope_10_2.08.fa)")
553
+ p.add_argument("--scop", nargs="?", const=True, default=None,
554
+ metavar="FASTA",
555
+ help="SCOP 1.75 FASTA (omit path → bundled data/scop175.fa)")
556
+ p.add_argument("--cath", nargs="?", const=True, default=None,
557
+ metavar="FASTA",
558
+ help="CATH-S20 FASTA (omit path → bundled data/cath_s20.fa)")
559
+ p.add_argument("--cath-labels", default=None, metavar="TSV",
560
+ help="CATH domain→class TSV (default: bundled data/cath_s20_labels.tsv)")
561
+ p.add_argument("--batch-size", type=int, default=128)
562
+ p.add_argument("--device", default=None)
563
+ p.add_argument("--seed", type=int, default=42,
564
+ help="Random seed for reproducibility (default: 42)")
565
+ p.add_argument("--dropout", type=float, default=0.0,
566
+ help="Trie-dropout rate for tokenization (default: 0.0 = deterministic)")
567
+ p.add_argument("--tta", type=int, default=1, dest="tta_passes",
568
+ help="Number of TTA passes (default: 1, only used if dropout > 0)")
569
+ args = p.parse_args()
570
+
571
+ # No flags → run everything
572
+ run_all = not any([args.scope, args.scop, args.cath])
573
+ results = run_benchmark(
574
+ repo = args.repo,
575
+ scope = True if run_all else (args.scope or False),
576
+ scop = True if run_all else (args.scop or False),
577
+ cath = True if run_all else (args.cath or False),
578
+ cath_labels = args.cath_labels,
579
+ batch_size = args.batch_size,
580
+ device = args.device,
581
+ seed = args.seed,
582
+ dropout = args.dropout,
583
+ tta_passes = args.tta_passes,
584
+ )
585
+ display_results(results)
586
+
587
+
588
+ if __name__ == "__main__":
589
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5e49be719f03463908e08660036292f6b727bd3dbef1a740ecd41e1264dea893
3
+ size 814907772
modeling_lemon.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Protein sequence encoder: transformer with RoPE, SwiGLU FFN, attention pooling,
3
+ and a bottleneck projector for contrastive sequence similarity.
4
+
5
+ No external dependencies beyond PyTorch.
6
+ """
7
+ import math
8
+ import torch
9
+ import torch.nn as nn
10
+ import torch.nn.functional as F
11
+ from torch.utils.checkpoint import checkpoint as grad_checkpoint
12
+
13
+
14
+ # ── Positional Embeddings ─────────────────────────────────────────────────
15
+
16
+ class RotaryPositionalEmbedding(nn.Module):
17
+ def __init__(self, dim: int, max_tokens: int = 512, base: int = 10000,
18
+ scaling_type=None):
19
+ super().__init__()
20
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
21
+ self.register_buffer("inv_freq", inv_freq)
22
+ self.max_tokens = max_tokens
23
+ self.scaling_type = scaling_type
24
+
25
+ def _scaled_positions(self, seq_len, device, dtype):
26
+ positions = torch.arange(seq_len, device=device, dtype=dtype)
27
+ if seq_len <= self.max_tokens or not self.scaling_type:
28
+ return positions
29
+ if self.scaling_type == "linear":
30
+ return positions * (self.max_tokens / seq_len)
31
+ raise ValueError(f"Unknown scaling_type: {self.scaling_type}")
32
+
33
+ def forward(self, x):
34
+ t = self._scaled_positions(x.shape[1], x.device, self.inv_freq.dtype)
35
+ freqs = torch.einsum("i,j->ij", t, self.inv_freq)
36
+ emb = torch.cat([freqs, freqs], dim=-1)
37
+ return emb[None, :, :]
38
+
39
+
40
+ def rotate_half(x):
41
+ x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :]
42
+ return torch.cat([-x2, x1], dim=-1)
43
+
44
+ def apply_rotary_pos_emb(q, k, pos_emb):
45
+ cos, sin = pos_emb.cos(), pos_emb.sin()
46
+ return (q * cos) + (rotate_half(q) * sin), (k * cos) + (rotate_half(k) * sin)
47
+
48
+
49
+ class ScaledPositionalEmbedding(nn.Module):
50
+ def __init__(self, num_embeddings, embedding_dim, extend_strategy="alibi",
51
+ padding_idx=None):
52
+ super().__init__()
53
+ self.embedding = nn.Embedding(num_embeddings, embedding_dim, padding_idx=padding_idx)
54
+ self.extend_strategy = extend_strategy
55
+ self.num_embeddings = num_embeddings
56
+ self.padding_idx = padding_idx
57
+ max_index = num_embeddings - 1
58
+ if padding_idx is not None:
59
+ content_max_index = max(0, min(max_index - 1, padding_idx - 1))
60
+ else:
61
+ content_max_index = max_index
62
+ self.register_buffer("max_index", torch.tensor(max_index), persistent=False)
63
+ self.register_buffer("content_max_index", torch.tensor(content_max_index), persistent=False)
64
+ if extend_strategy == "alibi":
65
+ slopes = torch.logspace(0, -3, steps=embedding_dim, base=2.0)
66
+ self.register_buffer("alibi_slopes", slopes, persistent=False)
67
+ else:
68
+ self.register_buffer("alibi_slopes", None, persistent=False)
69
+
70
+ def forward(self, positions):
71
+ positions = positions.long()
72
+ if self.padding_idx is not None:
73
+ pad_mask = positions == self.padding_idx
74
+ clamped = positions.clamp(0, int(self.content_max_index.item()))
75
+ clamped = torch.where(pad_mask, torch.full_like(clamped, self.padding_idx), clamped)
76
+ else:
77
+ pad_mask = torch.zeros_like(positions, dtype=torch.bool)
78
+ clamped = positions.clamp(0, int(self.max_index.item()))
79
+ embeddings = self.embedding(clamped)
80
+ if self.extend_strategy == "alibi":
81
+ excess = (positions - self.content_max_index).clamp_min(0)
82
+ if self.padding_idx is not None:
83
+ excess = torch.where(pad_mask, torch.zeros_like(excess), excess)
84
+ embeddings = embeddings + excess.unsqueeze(-1) * self.alibi_slopes
85
+ if self.padding_idx is not None:
86
+ embeddings = embeddings.masked_fill(pad_mask.unsqueeze(-1), 0.0)
87
+ return embeddings
88
+
89
+
90
+ # ── Transformer Block ─────────────────────────────────────────────────────
91
+
92
+ class AttentionBlock(nn.Module):
93
+ def __init__(self, embed_dim, nhead, ff_mult=4, dropout=0.1):
94
+ super().__init__()
95
+ self.embed_dim = embed_dim
96
+ self.nhead = nhead
97
+ self.head_dim = embed_dim // nhead
98
+ self.norm1 = nn.LayerNorm(embed_dim)
99
+ self.norm2 = nn.LayerNorm(embed_dim)
100
+ self.q_proj = nn.Linear(embed_dim, embed_dim, bias=False)
101
+ self.k_proj = nn.Linear(embed_dim, embed_dim, bias=False)
102
+ self.v_proj = nn.Linear(embed_dim, embed_dim, bias=False)
103
+ self.o_proj = nn.Linear(embed_dim, embed_dim, bias=False)
104
+ ff_dim = int(embed_dim * ff_mult * 2 / 3)
105
+ self.w1 = nn.Linear(embed_dim, ff_dim, bias=False)
106
+ self.w2 = nn.Linear(embed_dim, ff_dim, bias=False)
107
+ self.w3 = nn.Linear(ff_dim, embed_dim, bias=False)
108
+ self.dropout = nn.Dropout(dropout)
109
+
110
+ def forward(self, x, mask=None, rope_emb=None):
111
+ B, L, _ = x.shape
112
+ if mask is not None:
113
+ mask = mask.to(torch.bool)
114
+ h = self.norm1(x)
115
+ if mask is not None:
116
+ h = h.masked_fill(~mask.unsqueeze(-1), 0.0)
117
+ q = self.q_proj(h).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
118
+ k = self.k_proj(h).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
119
+ v = self.v_proj(h).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
120
+ if mask is not None:
121
+ m = mask.unsqueeze(1).unsqueeze(-1)
122
+ k = k.masked_fill(~m, 0.0)
123
+ v = v.masked_fill(~m, 0.0)
124
+ if rope_emb is not None:
125
+ q, k = apply_rotary_pos_emb(q, k, rope_emb.unsqueeze(1))
126
+ out = F.scaled_dot_product_attention(
127
+ q, k, v, attn_mask=None,
128
+ dropout_p=self.dropout.p if self.training else 0.0,
129
+ is_causal=False,
130
+ )
131
+ out = out.transpose(1, 2).contiguous().view(B, L, self.embed_dim)
132
+ out = self.o_proj(out)
133
+ if mask is not None:
134
+ out = out.masked_fill(~mask.unsqueeze(-1), 0.0)
135
+ x = x + self.dropout(out)
136
+ h = self.norm2(x)
137
+ if mask is not None:
138
+ h = h.masked_fill(~mask.unsqueeze(-1), 0.0)
139
+ ff = self.w3(F.silu(self.w1(h)) * self.w2(h))
140
+ if mask is not None:
141
+ ff = ff.masked_fill(~mask.unsqueeze(-1), 0.0)
142
+ x = x + self.dropout(ff)
143
+ return x
144
+
145
+
146
+ # ── Encoder Stack ─────────────────────────────────────────────────────────
147
+
148
+ class Encoder(nn.Module):
149
+ def __init__(self, vocab_size=32000, embed_dim=256, num_layers=4, nhead=8,
150
+ ff_mult=4, dropout=0.1, max_tokens=1024, padding_idx=0,
151
+ rope_scaling_type=None):
152
+ super().__init__()
153
+ self.embed_dim = embed_dim
154
+ self.num_layers = num_layers
155
+ self.gradient_checkpointing = False
156
+ self.token_emb = nn.Embedding(vocab_size, embed_dim, padding_idx=padding_idx)
157
+ self.emb_dropout = nn.Dropout(dropout)
158
+ self.rope = RotaryPositionalEmbedding(
159
+ embed_dim // nhead, max_tokens, scaling_type=rope_scaling_type)
160
+ self.layers = nn.ModuleList([
161
+ AttentionBlock(embed_dim, nhead, ff_mult, dropout) for _ in range(num_layers)
162
+ ])
163
+ self.final_norm = nn.LayerNorm(embed_dim)
164
+ self._init_weights()
165
+
166
+ def _init_weights(self):
167
+ for m in self.modules():
168
+ if isinstance(m, nn.Linear):
169
+ nn.init.xavier_uniform_(m.weight)
170
+ if m.bias is not None:
171
+ nn.init.zeros_(m.bias)
172
+ elif isinstance(m, nn.Embedding):
173
+ nn.init.normal_(m.weight, std=0.02)
174
+ if m.padding_idx is not None:
175
+ m.weight.data[m.padding_idx].zero_()
176
+
177
+ def forward(self, tokens, mask=None):
178
+ x = self.emb_dropout(self.token_emb(tokens))
179
+ padding_mask = mask.to(torch.bool) if mask is not None else None
180
+ rope_emb = self.rope(x)
181
+ for layer in self.layers:
182
+ if self.gradient_checkpointing and self.training:
183
+ x = grad_checkpoint(layer, x, padding_mask, rope_emb, use_reentrant=False)
184
+ else:
185
+ x = layer(x, padding_mask, rope_emb)
186
+ x = self.final_norm(x)
187
+ if padding_mask is not None:
188
+ x = x.masked_fill(~padding_mask.unsqueeze(-1), 0.0)
189
+ return x
190
+
191
+
192
+ # ── Attention Pooling ─────────────────────────────────────────────────────
193
+
194
+ class AttentionAggregator(nn.Module):
195
+ def __init__(self, embed_dim, num_heads=4, dropout=0.1):
196
+ super().__init__()
197
+ self.embed_dim = embed_dim
198
+ self.num_heads = num_heads
199
+ self.head_dim = embed_dim // num_heads
200
+ self.dropout = dropout
201
+ self.pool_query = nn.Parameter(torch.randn(1, 1, embed_dim) * 0.02)
202
+ self.q_proj = nn.Linear(embed_dim, embed_dim, bias=False)
203
+ self.k_proj = nn.Linear(embed_dim, embed_dim, bias=False)
204
+ self.v_proj = nn.Linear(embed_dim, embed_dim, bias=False)
205
+ self.o_proj = nn.Linear(embed_dim, embed_dim, bias=False)
206
+ self.norm = nn.LayerNorm(embed_dim)
207
+ self.align = nn.Linear(embed_dim, embed_dim, bias=False)
208
+ nn.init.eye_(self.align.weight)
209
+
210
+ def forward(self, token_embs, mask=None):
211
+ B, L, _ = token_embs.shape
212
+ H, D = self.num_heads, self.head_dim
213
+ query = self.pool_query.expand(B, -1, -1)
214
+ q = self.q_proj(query).view(B, 1, H, D).transpose(1, 2)
215
+ k = self.k_proj(token_embs).view(B, L, H, D).transpose(1, 2)
216
+ v = self.v_proj(token_embs).view(B, L, H, D).transpose(1, 2)
217
+ attn_mask = None
218
+ if mask is not None:
219
+ bool_mask = mask.to(torch.bool)
220
+ attn_mask = torch.zeros(B, 1, 1, L, device=token_embs.device, dtype=token_embs.dtype)
221
+ attn_mask = attn_mask.masked_fill(~bool_mask.unsqueeze(1).unsqueeze(2), float("-inf"))
222
+ m = bool_mask.unsqueeze(1).unsqueeze(-1)
223
+ k = k.masked_fill(~m, 0.0)
224
+ v = v.masked_fill(~m, 0.0)
225
+ out = F.scaled_dot_product_attention(
226
+ q, k, v, attn_mask=attn_mask,
227
+ dropout_p=self.dropout if self.training else 0.0,
228
+ is_causal=False,
229
+ )
230
+ out = out.transpose(1, 2).contiguous().view(B, 1, self.embed_dim)
231
+ pooled = self.norm(self.o_proj(out)).squeeze(1)
232
+ return self.align(pooled)
233
+
234
+
235
+ # ── Residue Expansion Head ────────────────────────────────────────────────
236
+
237
+ class ExpansionHead(nn.Module):
238
+ def __init__(self, embed_dim, max_residues, vocab_size=20, padding_index=31,
239
+ local_extend_strategy="alibi"):
240
+ super().__init__()
241
+ self.max_residues = max_residues
242
+ self.PADDING_INDEX = padding_index
243
+ self.local_pos_emb = ScaledPositionalEmbedding(
244
+ num_embeddings=self.PADDING_INDEX + 1, embedding_dim=embed_dim,
245
+ extend_strategy=local_extend_strategy, padding_idx=self.PADDING_INDEX,
246
+ )
247
+ self.residue_expansion = self._bottleneck_mlp(embed_dim, embed_dim, 0.1, 2)
248
+ self.aa_logits_proj = nn.Linear(embed_dim, vocab_size)
249
+
250
+ def _bottleneck_mlp(self, input_dim, hidden, dropout, num_layers, output_dim=None):
251
+ def lng(i, h, d):
252
+ return [nn.Linear(i, h), nn.LayerNorm(h), nn.GELU(), nn.Dropout(d)]
253
+ layers = []
254
+ for _ in range(num_layers):
255
+ layers.extend(lng(input_dim, hidden, dropout) + lng(hidden, input_dim, dropout))
256
+ if output_dim is not None:
257
+ layers.append(nn.Linear(input_dim, output_dim))
258
+ return nn.Sequential(*layers)
259
+
260
+ def _expand_tokens(self, z, token_lengths, target_len):
261
+ B, T, D = z.shape
262
+ device = z.device
263
+ if not torch.is_tensor(token_lengths):
264
+ token_lengths = torch.tensor(token_lengths, device=device, dtype=torch.long)
265
+ token_lengths = torch.clamp(token_lengths.clone(), min=0)
266
+ cumsum = torch.cumsum(token_lengths, dim=1)
267
+ total_residues = cumsum[:, -1]
268
+ positions = torch.arange(target_len, device=device).unsqueeze(0).expand(B, -1)
269
+ token_indices = torch.clamp(torch.searchsorted(cumsum, positions + 1), 0, T - 1)
270
+ z_expanded = torch.gather(z, 1, token_indices.unsqueeze(-1).expand(-1, -1, D))
271
+ cumsum_shifted = F.pad(cumsum[:, :-1], (1, 0), value=0)
272
+ local_indices = positions - torch.gather(cumsum_shifted, 1, token_indices)
273
+ padding_mask = positions >= total_residues.unsqueeze(1)
274
+ z_expanded = z_expanded.masked_fill(padding_mask.unsqueeze(-1), 0.0)
275
+ local_indices = torch.where(
276
+ padding_mask, torch.full_like(local_indices, self.PADDING_INDEX),
277
+ local_indices.clamp(min=0))
278
+ return z_expanded, local_indices
279
+
280
+ def forward(self, z, token_lengths, global_indices, global_pos_emb):
281
+ target_len = global_indices.shape[1]
282
+ z_exp, local_idx = self._expand_tokens(z, token_lengths, target_len)
283
+ pos_global = global_pos_emb(torch.clamp(global_indices, min=0)).to(z_exp.dtype)
284
+ pos_local = self.local_pos_emb(local_idx).to(z_exp.dtype)
285
+ z_final = z_exp + pos_global + pos_local
286
+ return self.aa_logits_proj(self.residue_expansion(z_final))
287
+
288
+
289
+ # ── Main Encoder Model ────────────────────────────────────────────────────
290
+
291
+ class LemonEncoder(nn.Module):
292
+ """
293
+ Protein sequence encoder: trie-tokenised input → per-token embeddings →
294
+ attention-pooled sequence embedding → L2-normalised projector output.
295
+
296
+ Suitable for contrastive similarity search (family / fold retrieval).
297
+
298
+ Loading
299
+ -------
300
+ >>> from modeling_protein_encoder import LemonEncoder
301
+ >>> model = LemonEncoder.from_pretrained("model.safetensors",
302
+ ... config_path="config.json")
303
+ >>> model.eval()
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ vocab_size: int = 32000,
309
+ embed_dim: int = 256,
310
+ num_layers: int = 8,
311
+ nhead: int = 8,
312
+ ff_mult: int = 4,
313
+ dropout: float = 0.1,
314
+ max_tokens: int = 1024,
315
+ proj_dim: int = 128,
316
+ padding_idx: int = 0,
317
+ rope_scaling_type=None,
318
+ num_proj_layers: int = 2,
319
+ proj_ff_mult: int = 2,
320
+ ):
321
+ super().__init__()
322
+ self.vocab_size = vocab_size
323
+ self.max_tokens = max_tokens
324
+ self.embed_dim = embed_dim
325
+ self.proj_dim = proj_dim or embed_dim
326
+ self.num_proj_layers = num_proj_layers
327
+ self.proj_ff_mult = proj_ff_mult
328
+
329
+ self.core = Encoder(
330
+ vocab_size=vocab_size, embed_dim=embed_dim, num_layers=num_layers,
331
+ nhead=nhead, ff_mult=ff_mult, dropout=dropout, max_tokens=max_tokens,
332
+ padding_idx=padding_idx, rope_scaling_type=rope_scaling_type,
333
+ )
334
+ self.log_temperature = nn.Parameter(torch.tensor(0.07).log())
335
+ self.aggregator = AttentionAggregator(embed_dim, dropout=dropout)
336
+ self.profile_expansion_head = ExpansionHead(embed_dim, max_residues=3 * max_tokens)
337
+ self.global_pos_emb = nn.Embedding(3 * max_tokens, embed_dim)
338
+
339
+ hidden = embed_dim * proj_ff_mult
340
+ self.projector = self._bottleneck_mlp(embed_dim, hidden, dropout,
341
+ num_proj_layers, output_dim=proj_dim)
342
+ self.config = dict(
343
+ vocab_size=vocab_size, embed_dim=embed_dim, num_layers=num_layers,
344
+ nhead=nhead, ff_mult=ff_mult, dropout=dropout, max_tokens=max_tokens,
345
+ proj_dim=proj_dim, padding_idx=padding_idx,
346
+ rope_scaling_type=rope_scaling_type,
347
+ num_proj_layers=num_proj_layers, proj_ff_mult=proj_ff_mult,
348
+ )
349
+
350
+ def _bottleneck_mlp(self, input_dim, hidden, dropout, num_layers, output_dim=None):
351
+ def lng(i, h, d):
352
+ return [nn.Linear(i, h), nn.LayerNorm(h), nn.GELU(), nn.Dropout(d)]
353
+ layers = []
354
+ for _ in range(num_layers):
355
+ layers.extend(lng(input_dim, hidden, dropout) + lng(hidden, input_dim, dropout))
356
+ if output_dim is not None:
357
+ layers.append(nn.Linear(input_dim, output_dim))
358
+ return nn.Sequential(*layers)
359
+
360
+ @property
361
+ def temperature(self):
362
+ return self.log_temperature.exp().clamp(min=0.01, max=1.0)
363
+
364
+ def encode_tokens(self, tokens, mask=None):
365
+ return self.core(tokens, mask)
366
+
367
+ def embed(self, tokens, mask=None, normalize=True):
368
+ """Encode tokens → L2-normalised sequence embedding [B, proj_dim]."""
369
+ token_embs = self.encode_tokens(tokens, mask)
370
+ agg = self.aggregator(token_embs, mask)
371
+ proj = self.projector(agg)
372
+ if normalize:
373
+ proj = F.normalize(proj, p=2, dim=-1)
374
+ return proj
375
+
376
+ def similarity(self, emb_a, emb_b):
377
+ """Temperature-scaled cosine similarity matrix [B_a, B_b]."""
378
+ return (emb_a @ emb_b.T) / self.temperature
379
+
380
+ def forward(self, tokens, mask=None):
381
+ token_embs = self.encode_tokens(tokens, mask)
382
+ return F.linear(token_embs, self.core.token_emb.weight)
383
+
384
+ @classmethod
385
+ def from_pretrained(cls, weights_path: str, config_path: str = None,
386
+ device: str = "cpu"):
387
+ """Load from safetensors weights + JSON config."""
388
+ import json, os
389
+ from safetensors.torch import load_file
390
+
391
+ if config_path is None:
392
+ config_path = os.path.join(os.path.dirname(weights_path), "config.json")
393
+
394
+ with open(config_path) as f:
395
+ cfg = json.load(f)
396
+
397
+ arch = cfg.get("architecture", cfg)
398
+ model = cls(**arch)
399
+ state = load_file(weights_path, device=device)
400
+ model.load_state_dict(state)
401
+ return model
tokenization_zest.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Protein motif tokenizer: greedy max-match trie over amino-acid n-gram clusters.
3
+
4
+ No external dependencies beyond the standard library.
5
+
6
+ Usage
7
+ -----
8
+ >>> from tokenization_protein_encoder import ZESTTokenizer
9
+ >>> tok = ZESTTokenizer.from_pretrained(".")
10
+ >>> ids = tok.encode("MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKRQTLGQHDFSAGEGLYTHMKALRPDEDRLSPLHSVYVDQWDWERVMGDGERQFSTLKSTVEAIWAGIKATEAAVSEEFGLAPFLPDQIHFVHSQELLSRYPDLDAKGRERAIAKDLGAVFLVGIGGKLSDGHRHDVRAPDYDDWSTPSELGHAGLNGDILVWNPSVSMEFQKIPIHRLATLKKMRHSSMCGQDKTAFGKELQDLQTELESMSGQGRFFLASTPYLRPQLNQLPGLKVNLNVIEQYVQKQNQWSTILTVYRQKGKLSAEPFQPTSHQLSAEKLNEGNDNLSLAAFVQLLNTSPTLAQATAVQVQNPIDKLPNLNQDSIQALQPEDLHQVLNLPKR")
11
+ >>> print(ids[:10])
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import random
17
+ from typing import Callable, Dict, List, Optional, Tuple
18
+
19
+
20
+ class _TrieNode:
21
+ __slots__ = ["children", "token_id"]
22
+ def __init__(self):
23
+ self.children: Dict[str, "_TrieNode"] = {}
24
+ self.token_id: int = -1
25
+
26
+
27
+ class ZESTTokenizer:
28
+ """
29
+ Greedy max-match tokenizer backed by a symbol-level trie.
30
+ Clusters biochemically substitutable amino-acid n-grams into shared tokens,
31
+ analogous to BPE but guided by substitutability rather than frequency.
32
+ """
33
+
34
+ SPECIAL_TOKENS = ["<PAD>", "<UNK>", "<CLS>", "<EOS>", "<MASK>"]
35
+ DEFAULT_ALPHABET = list("ACDEFGHIKLMNPQRSTVWY")
36
+
37
+ def __init__(self, vocab_path: str, alphabet=None, alphabet_path=None,
38
+ verbose=False):
39
+ self.path = vocab_path
40
+ self.vocab: Dict[str, int] = {}
41
+ self.id_to_token: Dict[int, str] = {}
42
+ self._root = _TrieNode()
43
+ self._symbol_to_id: Dict[str, int] = {}
44
+ self._ws = re.compile(r"\s+")
45
+
46
+ for i, tok in enumerate(self.SPECIAL_TOKENS):
47
+ self.vocab[tok] = i
48
+ self.id_to_token[i] = tok
49
+
50
+ if alphabet is not None:
51
+ self._alphabet = list(alphabet)
52
+ elif alphabet_path is not None and os.path.exists(alphabet_path):
53
+ with open(alphabet_path) as f:
54
+ self._alphabet = json.load(f)
55
+ else:
56
+ auto = vocab_path.replace(".json", "_alphabet.json")
57
+ if os.path.exists(auto):
58
+ with open(auto) as f:
59
+ self._alphabet = json.load(f)
60
+ else:
61
+ self._alphabet = list(self.DEFAULT_ALPHABET)
62
+
63
+ offset = len(self.SPECIAL_TOKENS)
64
+ for i, sym in enumerate(self._alphabet):
65
+ tid = offset + i
66
+ self.vocab[sym] = tid
67
+ self.id_to_token[tid] = sym
68
+ self._symbol_to_id[sym] = tid
69
+ self._trie_insert([sym], tid)
70
+
71
+ with open(vocab_path) as f:
72
+ clusters: Dict[str, List[str]] = json.load(f)
73
+
74
+ offset = len(self.vocab)
75
+ for i, (centroid, members) in enumerate(clusters.items()):
76
+ tid = offset + i
77
+ self.id_to_token[tid] = centroid
78
+ self.vocab[centroid] = tid
79
+ self._trie_insert(self._pattern_to_symbols(centroid), tid)
80
+ for member in (members or []):
81
+ if member == centroid:
82
+ continue
83
+ self.vocab[member] = tid
84
+ self._trie_insert(self._pattern_to_symbols(member), tid)
85
+
86
+ self.pad_id = self.vocab["<PAD>"]
87
+ self.unk_id = self.vocab["<UNK>"]
88
+ self.cls_id = self.vocab["<CLS>"]
89
+ self.eos_id = self.vocab["<EOS>"]
90
+ self.mask_id = self.vocab["<MASK>"]
91
+ self.vocab_size = len(self.id_to_token)
92
+
93
+ if verbose:
94
+ n_many = sum(1 for m in clusters.values() if m and len(m) > 1)
95
+ print(f"ZESTTokenizer — vocab size: {self.vocab_size}")
96
+ print(f" Alphabet: {len(self._alphabet)} symbols")
97
+ print(f" Patterns: {len(clusters)} ({n_many} many-to-one)")
98
+
99
+ def _pattern_to_symbols(self, pattern):
100
+ if self._alphabet and all(len(s) == 1 for s in self._alphabet):
101
+ return list(pattern)
102
+ return pattern.split()
103
+
104
+ def _trie_insert(self, symbols, token_id):
105
+ node = self._root
106
+ for sym in symbols:
107
+ if sym not in node.children:
108
+ node.children[sym] = _TrieNode()
109
+ node = node.children[sym]
110
+ node.token_id = token_id
111
+
112
+ def _trie_match(self, symbols, start):
113
+ matches = []
114
+ node = self._root
115
+ for i in range(start, len(symbols)):
116
+ sym = symbols[i]
117
+ if sym not in node.children:
118
+ break
119
+ node = node.children[sym]
120
+ if node.token_id != -1:
121
+ matches.append((i - start + 1, node.token_id))
122
+ return matches[::-1]
123
+
124
+ def _segment(self, symbols, dropout=0.0):
125
+ segments = []
126
+ i, n = 0, len(symbols)
127
+ while i < n:
128
+ matches = self._trie_match(symbols, i)
129
+ if not matches:
130
+ segments.append((1, self.mask_id))
131
+ i += 1
132
+ continue
133
+ length, tid = matches[0]
134
+ if dropout > 0 and random.random() < dropout and len(matches) > 1:
135
+ idx = random.randint(1, len(matches) - 1)
136
+ length, tid = matches[idx]
137
+ segments.append((length, tid))
138
+ i += length
139
+ return segments
140
+
141
+ def encode(self, text: str, dropout: float = 0.0,
142
+ add_special_tokens: bool = False) -> List[int]:
143
+ """Encode a raw amino-acid sequence string to token IDs."""
144
+ symbols = list(re.sub(r"\s+", "", text).upper())
145
+ ids = [tid for _, tid in self._segment(symbols, dropout)]
146
+ if add_special_tokens:
147
+ ids = [self.cls_id] + ids + [self.eos_id]
148
+ return ids
149
+
150
+ def decode(self, ids: List[int], skip_special: bool = True) -> str:
151
+ skip = {self.pad_id, self.cls_id, self.eos_id, self.mask_id}
152
+ parts = []
153
+ for i in ids:
154
+ if skip_special and i in skip:
155
+ continue
156
+ parts.append(self.id_to_token.get(i, ""))
157
+ if self._alphabet and len(self._alphabet[0]) == 1:
158
+ return "".join(parts)
159
+ return " | ".join(parts)
160
+
161
+ def batch_encode_plus(self, sequences: List[str], padding: bool = True,
162
+ max_length: Optional[int] = None, truncation: bool = True,
163
+ dropout: float = 0.0, return_tensors: str = "pt",
164
+ add_special_tokens: bool = False):
165
+ """Batch encode and optionally pad/truncate."""
166
+ try:
167
+ import torch
168
+ except ImportError:
169
+ raise ImportError("PyTorch is required for return_tensors=\'pt\'")
170
+ batch = [self.encode(s, dropout=dropout) for s in sequences]
171
+ n_special = 2 if add_special_tokens else 0
172
+ processed = []
173
+ for ids in batch:
174
+ if max_length and truncation and len(ids) + n_special > max_length:
175
+ ids = ids[: max_length - n_special]
176
+ if add_special_tokens:
177
+ ids = [self.cls_id] + ids + [self.eos_id]
178
+ processed.append(ids)
179
+ if padding:
180
+ target = max_length or max(len(ids) for ids in processed)
181
+ padded, masks = [], []
182
+ for ids in processed:
183
+ pad_n = max(0, target - len(ids))
184
+ padded.append(ids + [self.pad_id] * pad_n)
185
+ masks.append([1] * len(ids) + [0] * pad_n)
186
+ else:
187
+ padded = processed
188
+ masks = [[1] * len(ids) for ids in processed]
189
+ if return_tensors == "pt":
190
+ return {
191
+ "input_ids": torch.tensor(padded, dtype=torch.long),
192
+ "attention_mask": torch.tensor(masks, dtype=torch.long),
193
+ }
194
+ return {"input_ids": padded, "attention_mask": masks}
195
+
196
+ def save_pretrained(self, directory: str):
197
+ os.makedirs(directory, exist_ok=True)
198
+ clusters: Dict[str, List[str]] = {}
199
+ offset = len(self.SPECIAL_TOKENS) + len(self._alphabet)
200
+ centroid_ids = {tid for tid in self.id_to_token if tid >= offset}
201
+ for tok, tid in self.vocab.items():
202
+ if tid not in centroid_ids or tok in self.id_to_token.values():
203
+ continue
204
+ centroid = self.id_to_token[tid]
205
+ clusters.setdefault(centroid, []).append(tok)
206
+ with open(os.path.join(directory, "vocab_map.json"), "w") as f:
207
+ json.dump(clusters, f, indent=2)
208
+ with open(os.path.join(directory, "vocab_map_alphabet.json"), "w") as f:
209
+ json.dump(self._alphabet, f, indent=2)
210
+
211
+ @classmethod
212
+ def from_pretrained(cls, directory: str, **kwargs) -> "ZESTTokenizer":
213
+ vocab_path = os.path.join(directory, "vocab_map.json")
214
+ alpha_path = os.path.join(directory, "vocab_map_alphabet.json")
215
+ if os.path.exists(alpha_path):
216
+ return cls(vocab_path, alphabet_path=alpha_path, **kwargs)
217
+ return cls(vocab_path, **kwargs)
tokenizer_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tokenizer_class": "ZESTTokenizer",
3
+ "vocab_size": 32000,
4
+ "pad_token": "<PAD>",
5
+ "unk_token": "<UNK>",
6
+ "cls_token": "<CLS>",
7
+ "eos_token": "<EOS>",
8
+ "mask_token": "<MASK>",
9
+ "model_max_length": 1024
10
+ }
vocab_map.json ADDED
The diff for this file is too large to render. See raw diff
 
vocab_map_alphabet.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "A",
3
+ "C",
4
+ "D",
5
+ "E",
6
+ "F",
7
+ "G",
8
+ "H",
9
+ "I",
10
+ "K",
11
+ "L",
12
+ "M",
13
+ "N",
14
+ "P",
15
+ "Q",
16
+ "R",
17
+ "S",
18
+ "T",
19
+ "V",
20
+ "W",
21
+ "Y"
22
+ ]