# 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 = ['', '', '', ''] 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['']] for tok in self._tokenize(smiles): enc.append(self.vocab.get(tok, self.vocab[''])) enc.append(self.vocab['']) if len(enc) > max_length: enc = enc[:max_length - 1] + [self.vocab['']] enc += [self.vocab['']] * (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), '') if tok == '': 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[''] eos_id = tokenizer.vocab[''] pad_id = tokenizer.vocab[''] unk_id = tokenizer.vocab[''] 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[''] 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