lemon / tokenization_zest.py
Team-LEMON
LEMON: Layered Extraction of Molecular Ordering from Nature
e11bc48
Raw
History Blame Contribute Delete
8.9 kB
"""
Protein motif tokenizer: greedy max-match trie over amino-acid n-gram clusters.
No external dependencies beyond the standard library.
Usage
-----
>>> from tokenization_protein_encoder import ZESTTokenizer
>>> tok = ZESTTokenizer.from_pretrained(".")
>>> ids = tok.encode("MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKRQTLGQHDFSAGEGLYTHMKALRPDEDRLSPLHSVYVDQWDWERVMGDGERQFSTLKSTVEAIWAGIKATEAAVSEEFGLAPFLPDQIHFVHSQELLSRYPDLDAKGRERAIAKDLGAVFLVGIGGKLSDGHRHDVRAPDYDDWSTPSELGHAGLNGDILVWNPSVSMEFQKIPIHRLATLKKMRHSSMCGQDKTAFGKELQDLQTELESMSGQGRFFLASTPYLRPQLNQLPGLKVNLNVIEQYVQKQNQWSTILTVYRQKGKLSAEPFQPTSHQLSAEKLNEGNDNLSLAAFVQLLNTSPTLAQATAVQVQNPIDKLPNLNQDSIQALQPEDLHQVLNLPKR")
>>> print(ids[:10])
"""
import json
import os
import re
import random
from typing import Callable, Dict, List, Optional, Tuple
class _TrieNode:
__slots__ = ["children", "token_id"]
def __init__(self):
self.children: Dict[str, "_TrieNode"] = {}
self.token_id: int = -1
class ZESTTokenizer:
"""
Greedy max-match tokenizer backed by a symbol-level trie.
Clusters biochemically substitutable amino-acid n-grams into shared tokens,
analogous to BPE but guided by substitutability rather than frequency.
"""
SPECIAL_TOKENS = ["<PAD>", "<UNK>", "<CLS>", "<EOS>", "<MASK>"]
DEFAULT_ALPHABET = list("ACDEFGHIKLMNPQRSTVWY")
def __init__(self, vocab_path: str, alphabet=None, alphabet_path=None,
verbose=False):
self.path = vocab_path
self.vocab: Dict[str, int] = {}
self.id_to_token: Dict[int, str] = {}
self._root = _TrieNode()
self._symbol_to_id: Dict[str, int] = {}
self._ws = re.compile(r"\s+")
for i, tok in enumerate(self.SPECIAL_TOKENS):
self.vocab[tok] = i
self.id_to_token[i] = tok
if alphabet is not None:
self._alphabet = list(alphabet)
elif alphabet_path is not None and os.path.exists(alphabet_path):
with open(alphabet_path) as f:
self._alphabet = json.load(f)
else:
auto = vocab_path.replace(".json", "_alphabet.json")
if os.path.exists(auto):
with open(auto) as f:
self._alphabet = json.load(f)
else:
self._alphabet = list(self.DEFAULT_ALPHABET)
offset = len(self.SPECIAL_TOKENS)
for i, sym in enumerate(self._alphabet):
tid = offset + i
self.vocab[sym] = tid
self.id_to_token[tid] = sym
self._symbol_to_id[sym] = tid
self._trie_insert([sym], tid)
with open(vocab_path) as f:
clusters: Dict[str, List[str]] = json.load(f)
offset = len(self.vocab)
for i, (centroid, members) in enumerate(clusters.items()):
tid = offset + i
self.id_to_token[tid] = centroid
self.vocab[centroid] = tid
self._trie_insert(self._pattern_to_symbols(centroid), tid)
for member in (members or []):
if member == centroid:
continue
self.vocab[member] = tid
self._trie_insert(self._pattern_to_symbols(member), tid)
self.pad_id = self.vocab["<PAD>"]
self.unk_id = self.vocab["<UNK>"]
self.cls_id = self.vocab["<CLS>"]
self.eos_id = self.vocab["<EOS>"]
self.mask_id = self.vocab["<MASK>"]
self.vocab_size = len(self.id_to_token)
if verbose:
n_many = sum(1 for m in clusters.values() if m and len(m) > 1)
print(f"ZESTTokenizer — vocab size: {self.vocab_size}")
print(f" Alphabet: {len(self._alphabet)} symbols")
print(f" Patterns: {len(clusters)} ({n_many} many-to-one)")
def _pattern_to_symbols(self, pattern):
if self._alphabet and all(len(s) == 1 for s in self._alphabet):
return list(pattern)
return pattern.split()
def _trie_insert(self, symbols, token_id):
node = self._root
for sym in symbols:
if sym not in node.children:
node.children[sym] = _TrieNode()
node = node.children[sym]
node.token_id = token_id
def _trie_match(self, symbols, start):
matches = []
node = self._root
for i in range(start, len(symbols)):
sym = symbols[i]
if sym not in node.children:
break
node = node.children[sym]
if node.token_id != -1:
matches.append((i - start + 1, node.token_id))
return matches[::-1]
def _segment(self, symbols, dropout=0.0):
segments = []
i, n = 0, len(symbols)
while i < n:
matches = self._trie_match(symbols, i)
if not matches:
segments.append((1, self.mask_id))
i += 1
continue
length, tid = matches[0]
if dropout > 0 and random.random() < dropout and len(matches) > 1:
idx = random.randint(1, len(matches) - 1)
length, tid = matches[idx]
segments.append((length, tid))
i += length
return segments
def encode(self, text: str, dropout: float = 0.0,
add_special_tokens: bool = False) -> List[int]:
"""Encode a raw amino-acid sequence string to token IDs."""
symbols = list(re.sub(r"\s+", "", text).upper())
ids = [tid for _, tid in self._segment(symbols, dropout)]
if add_special_tokens:
ids = [self.cls_id] + ids + [self.eos_id]
return ids
def decode(self, ids: List[int], skip_special: bool = True) -> str:
skip = {self.pad_id, self.cls_id, self.eos_id, self.mask_id}
parts = []
for i in ids:
if skip_special and i in skip:
continue
parts.append(self.id_to_token.get(i, ""))
if self._alphabet and len(self._alphabet[0]) == 1:
return "".join(parts)
return " | ".join(parts)
def batch_encode_plus(self, sequences: List[str], padding: bool = True,
max_length: Optional[int] = None, truncation: bool = True,
dropout: float = 0.0, return_tensors: str = "pt",
add_special_tokens: bool = False):
"""Batch encode and optionally pad/truncate."""
try:
import torch
except ImportError:
raise ImportError("PyTorch is required for return_tensors=\'pt\'")
batch = [self.encode(s, dropout=dropout) for s in sequences]
n_special = 2 if add_special_tokens else 0
processed = []
for ids in batch:
if max_length and truncation and len(ids) + n_special > max_length:
ids = ids[: max_length - n_special]
if add_special_tokens:
ids = [self.cls_id] + ids + [self.eos_id]
processed.append(ids)
if padding:
target = max_length or max(len(ids) for ids in processed)
padded, masks = [], []
for ids in processed:
pad_n = max(0, target - len(ids))
padded.append(ids + [self.pad_id] * pad_n)
masks.append([1] * len(ids) + [0] * pad_n)
else:
padded = processed
masks = [[1] * len(ids) for ids in processed]
if return_tensors == "pt":
return {
"input_ids": torch.tensor(padded, dtype=torch.long),
"attention_mask": torch.tensor(masks, dtype=torch.long),
}
return {"input_ids": padded, "attention_mask": masks}
def save_pretrained(self, directory: str):
os.makedirs(directory, exist_ok=True)
clusters: Dict[str, List[str]] = {}
offset = len(self.SPECIAL_TOKENS) + len(self._alphabet)
centroid_ids = {tid for tid in self.id_to_token if tid >= offset}
for tok, tid in self.vocab.items():
if tid not in centroid_ids or tok in self.id_to_token.values():
continue
centroid = self.id_to_token[tid]
clusters.setdefault(centroid, []).append(tok)
with open(os.path.join(directory, "vocab_map.json"), "w") as f:
json.dump(clusters, f, indent=2)
with open(os.path.join(directory, "vocab_map_alphabet.json"), "w") as f:
json.dump(self._alphabet, f, indent=2)
@classmethod
def from_pretrained(cls, directory: str, **kwargs) -> "ZESTTokenizer":
vocab_path = os.path.join(directory, "vocab_map.json")
alpha_path = os.path.join(directory, "vocab_map_alphabet.json")
if os.path.exists(alpha_path):
return cls(vocab_path, alphabet_path=alpha_path, **kwargs)
return cls(vocab_path, **kwargs)