""" Inference helpers — dispatches between three model families based on the checkpoint: - ProtStabCNN (model_type absent / 'cnn') — one-hot 1D CNN - ESM2LoRARegressor (model_type == 'esm2_lora') — ESM2-35M + LoRA r16 - ESM2GatedStabilityModel(no model_type tag; detected via — ESM2-150M + LoRA r32 "env_gate." keys in state_dict) + temperature/pH gating The gated model's checkpoint carries no model_type/model_name metadata (it wasn't tagged when saved), so it's detected structurally instead of by tag — see _detect_family(). If it's ever retrained and re-saved with a proper model_type, that tag should take priority the same way esm2_lora's does. Works on CPU or GPU — auto-detects available device. """ import torch from pathlib import Path from protstab_model import ProtStabCNN, encode_sequence, MAX_LEN def _is_esm2(model) -> bool: return model.__class__.__name__ == "ESM2LoRARegressor" def _is_gated(model) -> bool: return model.__class__.__name__ == "ESM2GatedStabilityModel" def _detect_family(ckpt) -> str: if isinstance(ckpt, dict) and ckpt.get("model_type") == "esm2_lora": return "esm2_lora" state = ckpt.get("state_dict", ckpt) if isinstance(ckpt, dict) else ckpt if isinstance(state, dict) and any(k.startswith("env_gate.") for k in state): return "esm2_gated" return "cnn" def load_model(checkpoint_path: str, device: str = "cpu"): path = Path(checkpoint_path) if not path.exists(): raise FileNotFoundError(f"No checkpoint at {path}. Train first.") # Peek at metadata to decide which architecture to build (full load, not weights_only, # because esm2_lora/esm2_gated checkpoints carry extra keys beyond a bare state_dict). ckpt = torch.load(path, map_location=device, weights_only=False) family = _detect_family(ckpt) if family == "esm2_lora": from esm2_lora_model import load_model as load_esm2 return load_esm2(checkpoint_path, device) if family == "esm2_gated": from esm2_gated_model import load_model as load_gated return load_gated(checkpoint_path, device) # Default: one-hot CNN model = ProtStabCNN() state = ckpt.get("state_dict", ckpt) if isinstance(ckpt, dict) else ckpt model.load_state_dict(state) model.to(device) model.eval() return model def predict_one(seq: str, model, device: str = "cpu", conditions: dict | None = None) -> float: """Return predicted ΔG (kcal/mol) for a single amino acid sequence. `conditions` (e.g. {"temperature": 55, "ph": 6.5}) is only used by the gated model — ignored by the CNN and ESM2-r16 models.""" if _is_gated(model): from esm2_gated_model import predict_one as pg return pg(seq, model, device, conditions=conditions) if _is_esm2(model): from esm2_lora_model import predict_one as p1 return p1(seq, model, device) x = encode_sequence(seq).unsqueeze(0).to(device) with torch.no_grad(): return round(model(x).item(), 4) def predict_batch(seqs: list[str], model, device: str = "cpu", conditions: list[dict] | None = None) -> list[float]: """Return predicted ΔG values for a list of sequences (batched). `conditions` (gated model only) must be the same length as `seqs` if given.""" if _is_gated(model): from esm2_gated_model import predict_batch as pgb return pgb(seqs, model, device, conditions=conditions) if _is_esm2(model): from esm2_lora_model import predict_batch as pb return pb(seqs, model, device) tensors = torch.stack([encode_sequence(s) for s in seqs]).to(device) with torch.no_grad(): return [round(v, 4) for v in model(tensors).tolist()] def stability_label(dg: float) -> str: # Client convention: NEGATIVE ΔG = more stable (dg here is already negated at the API boundary). if dg < -3.0: return "highly stable" if dg < -0.5: return "stable" if dg < 0.5: return "marginally stable" if dg < 3.0: return "unstable" return "highly unstable"