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 +21 -0
- README.md +192 -0
- config.json +21 -0
- data/cath_s20.fa +0 -0
- data/cath_s20_labels.tsv +0 -0
- data/scop175.fa +0 -0
- data/scope_10_2.08.fa +0 -0
- eval_retrieval.py +589 -0
- model.safetensors +3 -0
- modeling_lemon.py +401 -0
- tokenization_zest.py +217 -0
- tokenizer_config.json +10 -0
- vocab_map.json +0 -0
- vocab_map_alphabet.json +22 -0
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 |
+
]
|