Molecule-Generator / inference.py
abdulRaHeeM452's picture
Upload inference.py
122f4db verified
Raw
History Blame Contribute Delete
18 kB
# inference.py — standalone inference module, no training code
# Drop this file into your HF Space or any deployment.
import re, json, math, os
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from rdkit import Chem, RDLogger
from rdkit.Chem import AllChem, Draw, Descriptors, QED
from rdkit import DataStructs
from huggingface_hub import hf_hub_download
from PIL import Image
RDLogger.DisableLog('rdApp.*')
# ── Constants (must match training) ───────────────────────────────────────────
MAX_LEN = 60
FP_DIM = 2048
FP_RADIUS = 2
SMILES_REGEX = re.compile(
r"(\[[^\[\]]+\]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p"
r"|\(|\)|\.|=|#|-|\+|\\|\/|:|~|@|\?|>|\*|\$|\%[0-9]{2}|[0-9])"
)
# ── Tokenizer ──────────────────────────────────────────────────────────────────
class SMILESTokenizer:
def __init__(self):
self.special_tokens = ['<PAD>', '<SOS>', '<EOS>', '<UNK>']
self.vocab = {t: i for i, t in enumerate(self.special_tokens)}
self.inverse_vocab = {i: t for t, i in self.vocab.items()}
self.is_fit = False
def _tokenize(self, smiles: str):
return SMILES_REGEX.findall(smiles)
def load(self, path: str):
with open(path, 'r') as f:
self.vocab = json.load(f)
self.inverse_vocab = {int(v): k for k, v in self.vocab.items()}
self.is_fit = True
def encode(self, smiles: str, max_length: int = MAX_LEN) -> list:
if not self.is_fit:
raise ValueError("Tokenizer not fit.")
enc = [self.vocab['<SOS>']]
for tok in self._tokenize(smiles):
enc.append(self.vocab.get(tok, self.vocab['<UNK>']))
enc.append(self.vocab['<EOS>'])
if len(enc) > max_length:
enc = enc[:max_length - 1] + [self.vocab['<EOS>']]
enc += [self.vocab['<PAD>']] * (max_length - len(enc))
return enc
def decode(self, token_ids) -> str:
out = ""
for tid in token_ids:
tok = self.inverse_vocab.get(int(tid), '<UNK>')
if tok == '<EOS>':
break
if tok not in self.special_tokens:
out += tok
return out
# ── Base Grammar Transformer ───────────────────────────────────────────────────
class BaseGrammarTransformer(nn.Module):
def __init__(self, vocab_size, d_model=256, nhead=8,
num_layers=4, max_seq_length=MAX_LEN):
super().__init__()
self.d_model = d_model
self.num_layers = num_layers
self.token_embedding = nn.Embedding(vocab_size, d_model, padding_idx=0)
self.position_embedding = nn.Embedding(max_seq_length, d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4,
dropout=0.1, batch_first=True, norm_first=True
)
self.transformer = nn.TransformerEncoder(
encoder_layer, num_layers=num_layers, enable_nested_tensor=False
)
self.fc_out = nn.Linear(d_model, vocab_size)
self.fc_out.weight = self.token_embedding.weight
nn.init.normal_(self.token_embedding.weight, std=0.02)
nn.init.normal_(self.position_embedding.weight, std=0.02)
nn.init.zeros_(self.fc_out.bias)
def generate_causal_mask(self, sz, device):
return torch.triu(torch.full((sz, sz), float('-inf'), device=device), diagonal=1)
def forward(self, x, src_key_padding_mask=None):
B, T = x.shape
pos = torch.arange(T, device=x.device).unsqueeze(0)
emb = self.token_embedding(x) * math.sqrt(self.d_model)
emb = emb + self.position_embedding(pos)
mask = self.generate_causal_mask(T, x.device)
out = self.transformer(emb, mask=mask,
src_key_padding_mask=src_key_padding_mask,
is_causal=True)
return self.fc_out(out)
# ── Enhanced Context Encoder ───────────────────────────────────────────────────
class EnhancedContextEncoder(nn.Module):
def __init__(self, vocab_size, d_model=256, fp_dim=FP_DIM):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model, padding_idx=0)
self.gru = nn.GRU(d_model, d_model, batch_first=True,
num_layers=2, dropout=0.1)
self.fp_proj = nn.Sequential(
nn.Linear(fp_dim, d_model),
nn.LayerNorm(d_model),
nn.GELU()
)
self.fusion = nn.Linear(d_model * 2, d_model)
def forward(self, support_set, support_fps=None):
B, K, L = support_set.shape
x = support_set.reshape(B * K, L)
x = self.embedding(x)
_, h = self.gru(x)
h = h[-1].reshape(B, K, -1)
seq_z = h.mean(dim=1)
fp_z = self.fp_proj(support_fps.mean(dim=1)) if support_fps is not None else seq_z
return self.fusion(torch.cat([seq_z, fp_z], dim=-1))
# ── Context-Conditioned LoRA ───────────────────────────────────────────────────
class ContextConditionedLoRA(nn.Module):
def __init__(self, d_model=256, num_layers=4, rank=16, nhead=8):
super().__init__()
self.d_model = d_model
self.num_layers = num_layers
self.rank = rank
self.nhead = nhead
self.head_dim = d_model // nhead
self.A_q = nn.ParameterList([nn.Parameter(torch.empty(rank, d_model)) for _ in range(num_layers)])
self.A_v = nn.ParameterList([nn.Parameter(torch.empty(rank, d_model)) for _ in range(num_layers)])
self.B_q_gen = nn.ModuleList([nn.Linear(d_model, d_model * rank) for _ in range(num_layers)])
self.B_v_gen = nn.ModuleList([nn.Linear(d_model, d_model * rank) for _ in range(num_layers)])
self.scaling = 0.5
for i in range(num_layers):
nn.init.kaiming_uniform_(self.A_q[i], a=math.sqrt(5))
nn.init.kaiming_uniform_(self.A_v[i], a=math.sqrt(5))
nn.init.zeros_(self.B_q_gen[i].weight)
nn.init.zeros_(self.B_v_gen[i].weight)
def get_delta_weights(self, z, layer_idx):
B = z.size(0)
B_q = self.B_q_gen[layer_idx](z).view(B, self.d_model, self.rank)
B_v = self.B_v_gen[layer_idx](z).view(B, self.d_model, self.rank)
A_q = self.A_q[layer_idx].unsqueeze(0).expand(B, -1, -1)
A_v = self.A_v[layer_idx].unsqueeze(0).expand(B, -1, -1)
dW_q = torch.bmm(B_q, A_q) * self.scaling
dW_v = torch.bmm(B_v, A_v) * self.scaling
return dW_q, dW_v
# ── Rapid Adaptation Engine ────────────────────────────────────────────────────
class RapidAdaptationEngine(nn.Module):
def __init__(self, base_model, context_encoder, d_model=256, pad_idx=0):
super().__init__()
self.base_model = base_model
self.context_encoder = context_encoder
self.pad_idx = pad_idx # stored so forward() doesn't need global tokenizer
self.lora = ContextConditionedLoRA(
d_model = d_model,
num_layers = base_model.num_layers,
rank = 16,
nhead = base_model.transformer.layers[0].self_attn.num_heads
)
for p in self.base_model.parameters():
p.requires_grad = False
def _lora_attention_forward(self, layer, x, dW_q, dW_v, attn_mask, key_padding_mask):
B, T, d = x.shape
sa = layer.self_attn
d_model = sa.embed_dim
nhead = sa.num_heads
x_norm = layer.norm1(x)
W_qkv = sa.in_proj_weight
b_qkv = sa.in_proj_bias
W_q_frz = W_qkv[:d_model]
W_k_frz = W_qkv[d_model:2*d_model]
W_v_frz = W_qkv[2*d_model:]
Q = torch.einsum('btd,bde->bte', x_norm, (W_q_frz + dW_q).transpose(1, 2))
K = x_norm @ W_k_frz.T
V = torch.einsum('btd,bde->bte', x_norm, (W_v_frz + dW_v).transpose(1, 2))
if b_qkv is not None:
Q = Q + b_qkv[:d_model]
K = K + b_qkv[d_model:2*d_model]
V = V + b_qkv[2*d_model:]
head_dim = d_model // nhead
scale = head_dim ** -0.5
Q = Q.view(B, T, nhead, head_dim).transpose(1, 2)
K = K.view(B, T, nhead, head_dim).transpose(1, 2)
V = V.view(B, T, nhead, head_dim).transpose(1, 2)
scores = torch.matmul(Q, K.transpose(-2, -1)) * scale
if attn_mask is not None:
scores = scores + attn_mask.unsqueeze(0).unsqueeze(0)
if key_padding_mask is not None:
scores = scores.masked_fill(key_padding_mask.unsqueeze(1).unsqueeze(2), float('-inf'))
attn_weights = F.softmax(scores, dim=-1)
drop_p = layer.self_attn.dropout if layer.training else 0.0
attn_weights = F.dropout(attn_weights, p=drop_p)
attn_out = torch.matmul(attn_weights, V)
attn_out = attn_out.transpose(1, 2).contiguous().view(B, T, d_model)
attn_out = F.linear(attn_out, sa.out_proj.weight, sa.out_proj.bias)
x = x + attn_out
drop_ffn = layer.dropout.p if hasattr(layer.dropout, 'p') else 0.0
x = x + layer.linear2(
F.dropout(layer.activation(layer.linear1(layer.norm2(x))), p=drop_ffn)
)
return x
def forward(self, target_x, support_set, support_fps=None):
z = self.context_encoder(support_set, support_fps)
B, T = target_x.shape
pos = torch.arange(T, device=target_x.device).unsqueeze(0)
x = (self.base_model.token_embedding(target_x) *
math.sqrt(self.base_model.d_model)
+ self.base_model.position_embedding(pos))
pad_mask = (target_x == self.pad_idx)
causal_mask = self.base_model.generate_causal_mask(T, target_x.device)
for i, frozen_layer in enumerate(self.base_model.transformer.layers):
dW_q, dW_v = self.lora.get_delta_weights(z, i)
x = self._lora_attention_forward(frozen_layer, x, dW_q, dW_v,
attn_mask=causal_mask,
key_padding_mask=pad_mask)
return self.base_model.fc_out(x)
# ── Utilities ──────────────────────────────────────────────────────────────────
def smiles_to_fp(smiles: str, radius: int = FP_RADIUS, nbits: int = FP_DIM) -> np.ndarray:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return np.zeros(nbits, dtype=np.float32)
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius, nBits=nbits)
arr = np.zeros(nbits, dtype=np.float32)
DataStructs.ConvertToNumpyArray(fp, arr)
return arr
def mol_to_pil(smiles: str, size=(300, 300)) -> Image.Image | None:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None
return Draw.MolToImage(mol, size=size)
def get_drug_props(smiles: str) -> dict | None:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None
try:
sa_score = _compute_sa_score(mol)
except Exception:
sa_score = None
return {
"qed": round(QED.qed(mol), 4),
"logp": round(Descriptors.MolLogP(mol), 4),
"mw": round(Descriptors.MolWt(mol), 2),
"hbd": Descriptors.NumHDonors(mol),
"hba": Descriptors.NumHAcceptors(mol),
"sa": round(sa_score, 4) if sa_score else "N/A",
}
def _compute_sa_score(mol):
"""SA score via RDKit contrib (if available), else None."""
try:
from rdkit.Contrib.SA_Score import sascorer
return sascorer.calculateScore(mol)
except ImportError:
return None
# ── Generation ─────────────────────────────────────────────────────────────────
def generate_raw(model, tokenizer, support_smiles,
num_generate=50, max_length=MAX_LEN, temperature=0.8):
model.eval()
dev = next(model.parameters()).device
sup_fps = torch.tensor(
np.array([smiles_to_fp(s) for s in support_smiles], dtype=np.float32)
).unsqueeze(0).expand(num_generate, -1, -1).to(dev)
sup_enc = [tokenizer.encode(s, max_length=max_length) for s in support_smiles]
sup_tensor = torch.tensor(sup_enc, dtype=torch.long).unsqueeze(0) \
.expand(num_generate, -1, -1).to(dev)
sos_id = tokenizer.vocab['<SOS>']
eos_id = tokenizer.vocab['<EOS>']
pad_id = tokenizer.vocab['<PAD>']
unk_id = tokenizer.vocab['<UNK>']
seqs = torch.full((num_generate, 1), sos_id, dtype=torch.long, device=dev)
eos_hit = torch.zeros(num_generate, dtype=torch.bool, device=dev)
with torch.no_grad():
for _ in range(max_length - 1):
logits = model(seqs, sup_tensor, sup_fps)
nxt = logits[:, -1, :] / temperature
for bad_id in [pad_id, sos_id, unk_id]:
nxt[:, bad_id] = float('-inf')
probs = F.softmax(nxt, dim=-1)
tok = torch.multinomial(probs, num_samples=1)
tok[eos_hit] = eos_id
eos_hit |= tok.squeeze(1) == eos_id
seqs = torch.cat([seqs, tok], dim=1)
if eos_hit.all():
break
return [tokenizer.decode(s.cpu().numpy()) for s in seqs]
def evaluate_generation(generated_smiles, support_smiles, verbose=False):
valid_mols, canonical_gen = [], []
for smi in generated_smiles:
mol = Chem.MolFromSmiles(smi)
if mol is not None:
valid_mols.append(mol)
canonical_gen.append(Chem.MolToSmiles(mol))
validity = len(valid_mols) / max(1, len(generated_smiles)) * 100
unique_can = list(set(canonical_gen))
uniqueness = len(unique_can) / max(1, len(valid_mols)) * 100
can_support = set()
for smi in support_smiles:
mol = Chem.MolFromSmiles(smi)
if mol:
can_support.add(Chem.MolToSmiles(mol))
novel = [s for s in unique_can if s not in can_support]
novelty = len(novel) / max(1, len(unique_can)) * 100
sup_fps = []
for smi in can_support:
mol = Chem.MolFromSmiles(smi)
if mol:
sup_fps.append(AllChem.GetMorganFingerprintAsBitVect(mol, FP_RADIUS, nBits=FP_DIM))
max_sims = []
for smi in novel:
mol_g = Chem.MolFromSmiles(smi)
fp_g = AllChem.GetMorganFingerprintAsBitVect(mol_g, FP_RADIUS, nBits=FP_DIM)
sims = [DataStructs.TanimotoSimilarity(fp_g, fp_s) for fp_s in sup_fps]
max_sims.append(max(sims) if sims else 0.0)
avg_tan = float(np.mean(max_sims)) if max_sims else 0.0
return dict(
validity=validity, uniqueness=uniqueness, novelty=novelty,
avg_tanimoto=avg_tan, novel_smiles=novel,
valid_mols=valid_mols, max_sims=max_sims,
n_generated=len(generated_smiles), n_valid=len(valid_mols),
n_unique=len(unique_can), n_novel=len(novel),
)
# ── Global model state (loaded once at startup) ────────────────────────────────
_tokenizer = None
_meta_model = None
_device = None
HF_REPO_ID = "abdulRaHeeM452/Molecule-generator" # your HF model repo
def load_models(repo_id: str = HF_REPO_ID):
global _tokenizer, _meta_model, _device
if _meta_model is not None:
return # already loaded
print("Loading models from HF Hub...")
_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {_device}")
tok_path = hf_hub_download(repo_id=repo_id, filename="smiles_tokenizer_v3.json")
base_path = hf_hub_download(repo_id=repo_id, filename="grammar_engine_v3.pt")
meta_path = hf_hub_download(repo_id=repo_id, filename="meta_engine_v3.pt")
_tokenizer = SMILESTokenizer()
_tokenizer.load(tok_path)
vocab_size = len(_tokenizer.vocab)
pad_idx = _tokenizer.vocab['<PAD>']
base_model = BaseGrammarTransformer(vocab_size=vocab_size).to(_device)
base_model.load_state_dict(torch.load(base_path, map_location=_device))
base_model.eval()
encoder = EnhancedContextEncoder(vocab_size=vocab_size).to(_device)
meta = RapidAdaptationEngine(base_model, encoder, pad_idx=pad_idx).to(_device)
ckpt = torch.load(meta_path, map_location=_device)
meta.context_encoder.load_state_dict(ckpt['encoder'])
meta.lora.load_state_dict(ckpt['lora'])
meta.eval()
_meta_model = meta
print(f"Models loaded. Vocab size: {vocab_size}")
def run_generation(support_smiles: list[str], n: int = 50, temperature: float = 0.8):
"""Public API — call this from app.py or FastAPI."""
load_models()
raw = generate_raw(_meta_model, _tokenizer, support_smiles,
num_generate=n, temperature=temperature)
metrics = evaluate_generation(raw, support_smiles)
images = []
for smi in metrics['novel_smiles'][:20]:
img = mol_to_pil(smi, size=(280, 280))
props = get_drug_props(smi)
if img:
images.append({"smiles": smi, "image": img, "props": props})
metrics['images'] = images
return metrics