Download assets/runtime/src/models.py from liupf/SMART: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/spaces/liupf/SMART/resolve/main/assets/runtime/src/models.py
- Command line
-
hf download hf://spaces/liupf/SMART/assets/runtime/src/models.py
-
curl -L -o models.py https://huggingface.co/spaces/liupf/SMART/resolve/main/assets/runtime/src/models.py
12.5 kB
| """Forward EI predictors. All public prediction outputs are RAW relative intensity.""" | |
| from __future__ import annotations | |
| import warnings | |
| from pathlib import Path | |
| from typing import Optional | |
| import numpy as np | |
| import torch | |
| from torch import nn | |
| from torch.nn import functional as F | |
| from rdkit import Chem, DataStructs | |
| from rdkit.Chem import Descriptors, rdFingerprintGenerator | |
| def choose_device(value="auto"): | |
| if value == "auto": | |
| value = "cuda" if torch.cuda.is_available() else "cpu" | |
| device = torch.device(value) | |
| if device.type == "cuda" and not torch.cuda.is_available(): | |
| raise RuntimeError("CUDA requested but unavailable. Check the AutoDL PyTorch image / GPU.") | |
| return device | |
| def molecular_features(smiles, n_bits=2048, radius=2): | |
| generator = rdFingerprintGenerator.GetMorganGenerator(radius=radius, fpSize=n_bits) | |
| count_rows, bit_rows, mass_rows = [], [], [] | |
| for value in smiles: | |
| molecule = Chem.MolFromSmiles(str(value)) | |
| if molecule is None: | |
| raise ValueError(f"Invalid SMILES: {value!r}") | |
| counts = np.zeros(n_bits, dtype=np.float32) | |
| bits = np.zeros(n_bits, dtype=np.float32) | |
| DataStructs.ConvertToNumpyArray(generator.GetCountFingerprint(molecule), counts) | |
| DataStructs.ConvertToNumpyArray(generator.GetFingerprint(molecule), bits) | |
| count_rows.append(counts) | |
| bit_rows.append(bits) | |
| mass_rows.append(Descriptors.ExactMolWt(molecule)) | |
| if not count_rows: | |
| return np.empty((0, n_bits), np.float32), np.empty((0, n_bits), np.float32), np.empty(0, np.float32) | |
| return np.stack(count_rows), np.stack(bit_rows), np.asarray(mass_rows, dtype=np.float32) | |
| class ResidualBlock(nn.Module): | |
| def __init__(self, width, dropout): | |
| super().__init__() | |
| self.block = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, width), nn.GELU(), | |
| nn.Dropout(dropout), nn.Linear(width, width)) | |
| def forward(self, inputs): | |
| return inputs + self.block(inputs) | |
| class BidirectionalSpectrumHead(nn.Module): | |
| """NEIMS-inspired mass/neutral-loss head; output represents sqrt(intensity).""" | |
| def __init__(self, input_dim, output_dim, hidden_dim=512, blocks=2, dropout=0.1, | |
| isotope_margin=4): | |
| super().__init__() | |
| self.output_dim = int(output_dim) | |
| self.isotope_margin = int(isotope_margin) | |
| self.body = nn.Sequential(nn.Linear(input_dim, hidden_dim), nn.GELU(), | |
| *[ResidualBlock(hidden_dim, dropout) for _ in range(blocks)], | |
| nn.LayerNorm(hidden_dim)) | |
| self.forward_head = nn.Linear(hidden_dim, output_dim) | |
| self.reverse_head = nn.Linear(hidden_dim, output_dim) | |
| self.gate = nn.Linear(hidden_dim, output_dim) | |
| self.register_buffer("bins", torch.arange(output_dim), persistent=False) | |
| def forward(self, embeddings, exact_mass): | |
| hidden = self.body(embeddings) | |
| forward = F.softplus(self.forward_head(hidden)) | |
| reverse = F.softplus(self.reverse_head(hidden)) | |
| center = torch.floor(exact_mass.float() + 0.5).long() | |
| reverse_index = center[:, None] + self.isotope_margin - self.bins[None, :] | |
| reverse_valid = (reverse_index >= 0) & (reverse_index < self.output_dim) | |
| aligned = reverse.gather(1, reverse_index.clamp(0, self.output_dim - 1)) * reverse_valid | |
| gate = torch.sigmoid(self.gate(hidden)) | |
| prediction = gate * forward + (1.0 - gate) * aligned | |
| valid = (self.bins[None, :] > 0) & (self.bins[None, :] <= center[:, None] + self.isotope_margin) | |
| return prediction * valid | |
| class SafeBatchNorm(nn.BatchNorm1d): | |
| def forward(self, values): | |
| if self.training and values.shape[0] == 1: | |
| return F.batch_norm(values, self.running_mean, self.running_var, self.weight, self.bias, | |
| training=False, momentum=self.momentum, eps=self.eps) | |
| return super().forward(values) | |
| class NEIMSResidualBlock(nn.Module): | |
| def __init__(self, width, bottleneck_factor, dropout): | |
| super().__init__() | |
| inner = max(1, int(width * bottleneck_factor)) | |
| self.block = nn.Sequential(SafeBatchNorm(width), nn.ReLU(), nn.Dropout(dropout), | |
| nn.Linear(width, inner), SafeBatchNorm(inner), nn.ReLU(), | |
| nn.Linear(inner, width)) | |
| def forward(self, values): | |
| return values + self.block(values) | |
| class NEIMSReimplementation(nn.Module): | |
| """Independent implementation aligned to the published NEIMS-style MLP defaults. | |
| 4096 raw Morgan count features, width 2000, seven BN/ReLU residual bottlenecks, | |
| dropout .25, gated forward/reverse outputs. Shared split, sqrt-cosine loss and | |
| 0..750 bins differ from the original TensorFlow/NIST training protocol. | |
| """ | |
| def __init__(self, config): | |
| super().__init__() | |
| self.config = config | |
| width = config.get("hidden_dim", 2000) | |
| dimension = config["spectrum_dim"] | |
| self.isotope_margin = config.get("isotope_margin", 5) | |
| self.input = nn.Sequential(SafeBatchNorm(config.get("fingerprint_bits", 4096)), | |
| nn.Linear(config.get("fingerprint_bits", 4096), width), nn.ReLU()) | |
| self.blocks = nn.Sequential(*[NEIMSResidualBlock(width, config.get("bottleneck_factor", .5), | |
| config.get("dropout", .25)) | |
| for _ in range(config.get("residual_blocks", 7))], | |
| SafeBatchNorm(width), nn.ReLU()) | |
| self.forward_head = nn.Linear(width, dimension) | |
| self.reverse_head = nn.Linear(width, dimension) | |
| self.gate = nn.Linear(width, dimension) | |
| self.register_buffer("bins", torch.arange(dimension), persistent=False) | |
| def forward(self, inputs, exact_mass): | |
| features = self.blocks(self.input(inputs["fingerprints"])) | |
| direct = self.forward_head(features) | |
| reverse = self.reverse_head(features) | |
| ceiling = torch.floor(exact_mass.float() + .5).long() + self.isotope_margin | |
| source = ceiling[:, None] - self.bins[None, :] | |
| valid = (source >= 0) & (source < len(self.bins)) & (self.bins[None, :] > 0) | |
| aligned = reverse.gather(1, source.clamp(0, len(self.bins) - 1)) * valid | |
| gate = torch.sigmoid(self.gate(features)) | |
| direct_valid = (self.bins[None, :] > 0) & (self.bins[None, :] <= ceiling[:, None]) | |
| return F.relu(gate * direct + (1 - gate) * aligned) * direct_valid | |
| class MolformerSpectrumModel(nn.Module): | |
| def __init__(self, config, model_dir_override=None): | |
| super().__init__() | |
| from transformers import AutoModel, AutoTokenizer | |
| from peft import LoraConfig, get_peft_model | |
| self.config = config | |
| model_dir = Path(model_dir_override or config.get("model_dir", "models/molformer")) | |
| if not model_dir.is_dir() or not (model_dir / "config.json").is_file(): | |
| raise FileNotFoundError(f"MoLFormer files missing in {model_dir}. Run download_resources.py --molformer first.") | |
| self.tokenizer = AutoTokenizer.from_pretrained(str(model_dir), trust_remote_code=True, | |
| local_files_only=True) | |
| backbone = AutoModel.from_pretrained(str(model_dir), trust_remote_code=True, | |
| local_files_only=True, deterministic_eval=True) | |
| expected = config.get("lora_targets", ["query", "value"]) | |
| observed = {name.rsplit(".", 1)[-1] for name, module in backbone.named_modules() | |
| if isinstance(module, nn.Linear)} | |
| if not set(expected).issubset(observed): | |
| raise RuntimeError(f"LoRA modules {expected} not found. Available linear leaf names: {sorted(observed)}") | |
| lora = LoraConfig(r=config.get("lora_r", 8), lora_alpha=config.get("lora_alpha", 16), | |
| lora_dropout=config.get("lora_dropout", 0.05), target_modules=expected, | |
| bias="none") | |
| self.encoder = get_peft_model(backbone, lora) | |
| self.head = BidirectionalSpectrumHead( | |
| backbone.config.hidden_size, config["spectrum_dim"], config.get("hidden_dim", 512), | |
| config.get("residual_blocks", 2), config.get("dropout", 0.1), | |
| config.get("isotope_margin", 4)) | |
| def tokenize(self, smiles): | |
| # Check rather than silently truncate molecules. | |
| encoded = self.tokenizer(list(smiles), padding=True, truncation=False, return_tensors="pt") | |
| limit = self.config.get("max_tokens", 256) | |
| if encoded["input_ids"].shape[1] > limit: | |
| raise ValueError(f"SMILES exceeds max_tokens={limit}; review long structures or raise max_tokens explicitly.") | |
| return {key: value for key, value in encoded.items() if key in {"input_ids", "attention_mask"}} | |
| def forward(self, inputs, exact_mass): | |
| encoded = self.encoder(**inputs, return_dict=True) | |
| mask = inputs["attention_mask"].unsqueeze(-1).to(encoded.last_hidden_state.dtype) | |
| pooled = (encoded.last_hidden_state * mask).sum(1) / mask.sum(1).clamp_min(1) | |
| return self.head(pooled, exact_mass) | |
| def build_model(config, model_dir_override=None): | |
| if config["model_type"] == "neims_reimpl": | |
| return NEIMSReimplementation(config) | |
| if config["model_type"] == "molformer": | |
| return MolformerSpectrumModel(config, model_dir_override) | |
| raise ValueError(f"Unknown model_type: {config['model_type']}") | |
| def load_checkpoint(path, map_location="cpu"): | |
| # Only load checkpoints produced by this package or another trusted source. | |
| return torch.load(path, map_location=map_location, weights_only=False) | |
| def raw_intensity(sqrt_prediction): | |
| raw = sqrt_prediction.float().clamp_min(0).square() | |
| return raw / raw.amax(dim=-1, keepdim=True).clamp_min(1e-12) | |
| class SpectrumPredictor: | |
| def __init__(self, checkpoint, device="auto", model_dir_override=None): | |
| self.device = choose_device(device) | |
| saved = load_checkpoint(checkpoint) | |
| self.config = saved["config"] | |
| self.model = build_model(self.config, model_dir_override) | |
| self.model.load_state_dict(saved["model_state"], strict=True) | |
| self.model.to(self.device).eval() | |
| if "train_keys" not in saved: | |
| raise ValueError("Checkpoint lacks training identity audit (train_keys)") | |
| self.train_keys = set(saved["train_keys"]) | |
| self.metadata = {key: value for key, value in saved.items() | |
| if key not in {"model_state", "optimizer_state", "rng_state", "train_keys"}} | |
| def __call__(self, smiles, exact_mass=None, batch_size=32): | |
| smiles = list(smiles) | |
| if batch_size < 1: | |
| raise ValueError("batch_size must be positive") | |
| if not smiles: | |
| return np.empty((0, self.config["spectrum_dim"]), dtype=np.float32) | |
| supplied_mass = None if exact_mass is None else np.asarray(exact_mass, dtype=np.float32) | |
| if supplied_mass is not None and supplied_mass.shape != (len(smiles),): | |
| raise ValueError("exact_mass must have one value per SMILES") | |
| results = [] | |
| for start in range(0, len(smiles), batch_size): | |
| batch_smiles = smiles[start:start + batch_size] | |
| counts, _, calculated = molecular_features(batch_smiles, self.config.get("fingerprint_bits", 2048), | |
| self.config.get("fingerprint_radius", 2)) | |
| mass = calculated if supplied_mass is None else supplied_mass[start:start + batch_size] | |
| if not np.isfinite(mass).all() or (mass <= 0).any(): | |
| raise ValueError("Non-finite or nonpositive exact_mass") | |
| if self.config["model_type"] == "neims_reimpl": | |
| inputs = {"fingerprints": torch.from_numpy(counts)} | |
| else: | |
| inputs = self.model.tokenize(batch_smiles) | |
| inputs = {key: value.to(self.device) for key, value in inputs.items()} | |
| values = self.model(inputs, torch.as_tensor(mass, device=self.device)) | |
| results.append(raw_intensity(values).cpu().numpy()) | |
| return np.concatenate(results) | |
| def load_predictor(checkpoint, device="auto", model_dir_override=None): | |
| return SpectrumPredictor(checkpoint, device, model_dir_override) | |