Download modeling_allcaps.py from BacPop/ALLCAPS: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/BacPop/ALLCAPS/resolve/main/modeling_allcaps.py
- Command line
-
hf download hf://BacPop/ALLCAPS/modeling_allcaps.py
-
curl -L -o modeling_allcaps.py https://huggingface.co/BacPop/ALLCAPS/resolve/main/modeling_allcaps.py
15.1 kB
| """Self-contained ALLCAPS inference. | |
| Everything needed to go from a *cps* locus FASTA to serotype calls and novelty | |
| flags, with no dependency on the ALLCAPS repository β only torch, transformers, | |
| numpy and (for `predict_fasta`) biopython. | |
| from modeling_allcaps import ALLCAPS | |
| model = ALLCAPS.from_pretrained(".") # or a local snapshot directory | |
| for call in model.predict_fasta("my_loci.fasta"): | |
| print(call["record_id"], call["serotype"], call["is_novel"]) | |
| This mirrors `allcaps/inference.py` + `allcaps/trihead/process_trihead_query.py` | |
| + `allcaps/knn_ood.py` in https://github.com/bacpop/pneumococcal-serotyping. | |
| The repository version is the reference implementation; this file exists so the | |
| checkpoint is usable on its own. If the two ever disagree, the repository is | |
| right. For whole assemblies rather than pre-cut loci, use the repository's | |
| `ALLCAPS predict --input samples.txt --extract align` instead β it cuts the | |
| locus out for you and fetches these artifacts from the Hub. | |
| Licence: the code is MIT, the weights are CC-BY-NC-4.0. See README.md. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Iterator, Optional | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| __all__ = ["ALLCAPS", "TransformerTriHeadLR", "SerotypeCall"] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Architecture β must stay byte-compatible with the published state dict | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _make_padding_mask(x: torch.Tensor) -> torch.Tensor: | |
| """(B, L) mask, True at all-zero (padded) chunk positions.""" | |
| return x.abs().sum(dim=-1) == 0 | |
| def _masked_mean_pool(x: torch.Tensor, padding_mask: torch.Tensor) -> torch.Tensor: | |
| """Mean over real positions only. | |
| Averaging over padding too would make a locus's embedding depend on what | |
| else happened to be in its batch β the pooled vector feeds the novelty | |
| detector, so that would move the distances. | |
| """ | |
| real = (~padding_mask).float().unsqueeze(-1) | |
| lengths = real.sum(dim=1).clamp(min=1) | |
| return (x * real).sum(dim=1) / lengths | |
| class TransformerTriHeadLR(nn.Module): | |
| """Chunk embeddings β transformer encoder β pooled 128-d locus embedding β 3 heads. | |
| Head 3 (`genogroup_classifier`) is present but was trained with weight 0 in | |
| the released checkpoint; its logits are meaningless. See README.md. | |
| """ | |
| def __init__(self, input_dim, num_classes, output_dim=128, max_len=64, | |
| nhead=4, num_layers=2, **kwargs): | |
| super().__init__() | |
| self.pos_embed = nn.Embedding(max_len, input_dim) | |
| encoder_layer = nn.TransformerEncoderLayer( | |
| d_model=input_dim, nhead=nhead, | |
| dim_feedforward=4 * input_dim, batch_first=True, | |
| ) | |
| self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) | |
| self.cbl_classifier = nn.Linear(output_dim, 2) | |
| self.serotype_classifier = nn.Linear(output_dim, num_classes[0]) | |
| self.genogroup_classifier = nn.Linear(output_dim, num_classes[1]) | |
| self.project = nn.Sequential( | |
| nn.Linear(input_dim, input_dim), nn.ReLU(), nn.Linear(input_dim, output_dim) | |
| ) | |
| def forward(self, x): | |
| B, L, D = x.size() | |
| padding_mask = _make_padding_mask(x) | |
| pos = torch.arange(L, device=x.device).unsqueeze(0) | |
| x = x + self.pos_embed(pos) | |
| x = self.encoder(x, src_key_padding_mask=padding_mask) | |
| x = _masked_mean_pool(x, padding_mask) | |
| z = F.normalize(self.project(x), dim=1) | |
| return self.cbl_classifier(z), (self.serotype_classifier(z), | |
| self.genogroup_classifier(z)), z | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Novelty detector | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class KnnNoveltyIndex: | |
| """Cosine distance to the k-th nearest training locus, thresholded. | |
| Brute force over ~54k x 128 β a few hundred ms per query batch on CPU, so | |
| there is no reason to carry an approximate index. | |
| """ | |
| def __init__(self, embeddings, serotypes, keys, threshold, k=1, metric="cosine"): | |
| if metric != "cosine": | |
| raise ValueError(f"only cosine is supported, got {metric!r}") | |
| # float64 throughout. In float32 the `1 - x.y` of cosine distance cancels | |
| # below machine epsilon for the many near-identical loci in this dataset, | |
| # quantising ~73% of in-distribution distances toward zero and costing | |
| # ~0.015 AUROC. This is not a micro-optimisation to undo. | |
| X = np.ascontiguousarray(embeddings, dtype=np.float64) | |
| self.X = X / np.linalg.norm(X, axis=1, keepdims=True) | |
| self.serotypes = np.asarray(serotypes) | |
| self.keys = np.asarray(keys) | |
| self.threshold = float(threshold) | |
| self.k = int(k) | |
| def from_pretrained(cls, path: str): | |
| with np.load(os.path.join(path, "knn_index.npz"), allow_pickle=False) as npz: | |
| embeddings = npz["train_data"] | |
| serotypes = npz["train_serotypes"] | |
| keys = npz["train_keys"] | |
| with open(os.path.join(path, "knn_config.json")) as f: | |
| cfg = json.load(f) | |
| return cls(embeddings, serotypes, keys, | |
| threshold=cfg["threshold"], k=cfg["k"], metric=cfg["metric"]) | |
| def query(self, z: np.ndarray, top_k: int = 1): | |
| """Returns (distances, serotypes, keys), each (n_queries, top_k), nearest first.""" | |
| Q = np.ascontiguousarray(z, dtype=np.float64) | |
| Q = Q / np.linalg.norm(Q, axis=1, keepdims=True) | |
| distances = 1.0 - Q @ self.X.T | |
| top_k = min(top_k, distances.shape[1]) | |
| idx = np.argpartition(distances, top_k - 1, axis=1)[:, :top_k] | |
| order = np.take_along_axis(distances, idx, axis=1).argsort(axis=1) | |
| idx = np.take_along_axis(idx, order, axis=1) | |
| return (np.take_along_axis(distances, idx, axis=1), | |
| self.serotypes[idx], self.keys[idx]) | |
| def is_novel(self, distance_to_kth: np.ndarray) -> np.ndarray: | |
| return distance_to_kth > self.threshold | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # End-to-end model | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SerotypeCall: | |
| record_id: str | |
| is_cps: bool | |
| cps_probability: float | |
| serotype: Optional[str] | |
| confidence: float | |
| is_novel: Optional[bool] | |
| nn_distance: Optional[float] | |
| nn_serotype: Optional[str] | |
| embedding: np.ndarray | |
| def as_dict(self, with_embedding: bool = False) -> dict: | |
| d = {k: v for k, v in self.__dict__.items() if k != "embedding"} | |
| if with_embedding: | |
| d["embedding"] = self.embedding | |
| return d | |
| # Mapping-style access so callers can treat these as plain records. | |
| def __getitem__(self, key): | |
| return getattr(self, key) | |
| class ALLCAPS: | |
| """ProkBERT β TriHead encoder β serotype call + novelty flag.""" | |
| def __init__(self, head, config, serotype_to_idx, genogroup_to_idx, | |
| knn: Optional[KnnNoveltyIndex] = None, device="cpu", | |
| base_model_name="neuralbioinfo/prokbert-mini-long"): | |
| self.head = head | |
| self.config = config | |
| self.device = torch.device(device) | |
| self.idx_to_serotype = {v: k for k, v in serotype_to_idx.items()} | |
| self.idx_to_genogroup = {v: k for k, v in genogroup_to_idx.items()} | |
| self.knn = knn | |
| self.base_model_name = base_model_name | |
| self._tokenizer = None | |
| self._base = None | |
| def from_pretrained(cls, path: str = ".", device: str = "cpu", | |
| load_knn: bool = True) -> "ALLCAPS": | |
| """Load from a directory holding config.json, labels.json and the weights. | |
| Pass a `huggingface_hub.snapshot_download(...)` path to load from the Hub. | |
| """ | |
| with open(os.path.join(path, "config.json")) as f: | |
| config = json.load(f) | |
| with open(os.path.join(path, "labels.json")) as f: | |
| labels = json.load(f) | |
| head = TransformerTriHeadLR( | |
| input_dim=config["input_dim"], | |
| num_classes=tuple(config["num_classes"]), | |
| output_dim=config["output_dim"], | |
| max_len=config.get("max_len", 64), | |
| nhead=config["nhead"], | |
| num_layers=config["num_layers"], | |
| ) | |
| safetensors_path = os.path.join(path, "model.safetensors") | |
| if os.path.exists(safetensors_path): | |
| from safetensors.torch import load_file | |
| state = load_file(safetensors_path) | |
| else: # the original torch checkpoint carries the same weights | |
| state = torch.load(os.path.join(path, "transformer_model.pth"), | |
| map_location="cpu")["model_state_dict"] | |
| head.load_state_dict(state) | |
| head.eval().to(device) | |
| knn = None | |
| if load_knn and os.path.exists(os.path.join(path, "knn_index.npz")): | |
| knn = KnnNoveltyIndex.from_pretrained(path) | |
| return cls(head, config, labels["serotype_to_idx"], labels["genogroup_to_idx"], | |
| knn=knn, device=device, | |
| base_model_name=config.get("base_model", | |
| "neuralbioinfo/prokbert-mini-long")) | |
| # ββ ProkBERT, loaded lazily so `from_pretrained` stays cheap ββββββββββββββ | |
| def _ensure_base_model(self): | |
| if self._base is not None: | |
| return | |
| from transformers import AutoModel, AutoTokenizer | |
| self._tokenizer = AutoTokenizer.from_pretrained( | |
| self.base_model_name, trust_remote_code=True) | |
| self._base = AutoModel.from_pretrained( | |
| self.base_model_name, trust_remote_code=True).to(self.device) | |
| self._base.eval() | |
| def embed_chunks(self, sequence: str) -> torch.Tensor: | |
| """DNA string β (1, n_chunks, 384) ProkBERT chunk embeddings.""" | |
| self._ensure_base_model() | |
| chunk_size = min(self.config["chunk_size"], | |
| getattr(self._tokenizer, "model_max_length", | |
| self.config["chunk_size"])) | |
| stride = int(chunk_size * self.config["stride_ratio"]) | |
| sequence = sequence[: self.config["max_length"]].upper() | |
| chunks = [sequence[i:i + chunk_size] | |
| for i in range(0, max(len(sequence) - chunk_size + 1, 1), stride)] | |
| chunks = [c for c in chunks if len(c) > 0] or [sequence] | |
| vectors = [] | |
| for chunk in chunks: | |
| enc = self._tokenizer(chunk, return_tensors="pt", truncation=True, | |
| max_length=chunk_size).to(self.device) | |
| out = self._base(**enc).last_hidden_state # (1, tokens, 384) | |
| vectors.append(out.mean(dim=1).squeeze(0)) | |
| return torch.stack(vectors).unsqueeze(0) # (1, n_chunks, 384) | |
| def predict_sequence(self, sequence: str, record_id: str = "query", | |
| top_k: int = 1) -> SerotypeCall: | |
| chunk_embeddings = self.embed_chunks(sequence).to(self.device) | |
| cps_logits, (serotype_logits, _), z = self.head(chunk_embeddings) | |
| cps_probs = torch.softmax(cps_logits, dim=-1).squeeze(0).cpu().numpy() | |
| is_cps = bool(cps_probs[1] > self.config.get("capsule_threshold", 0.5)) | |
| serotype_probs = torch.softmax(serotype_logits, dim=-1).squeeze(0).cpu().numpy() | |
| best = int(serotype_probs.argmax()) | |
| z_np = z.squeeze(0).cpu().numpy() | |
| is_novel = nn_distance = nn_serotype = None | |
| if self.knn is not None: | |
| distances, serotypes, _ = self.knn.query(z_np[None, :], top_k=max(top_k, self.knn.k)) | |
| nn_distance = float(distances[0, 0]) | |
| nn_serotype = str(serotypes[0, 0]) | |
| # The score is the distance to the k-th neighbour; k=1 as deployed. | |
| kth = float(distances[0, min(self.knn.k, distances.shape[1]) - 1]) | |
| is_novel = bool(kth > self.knn.threshold) | |
| return SerotypeCall( | |
| record_id=record_id, | |
| is_cps=is_cps, | |
| cps_probability=float(cps_probs[1]), | |
| # A non-cps sequence has no serotype; reporting the argmax anyway is | |
| # how people end up quoting a serotype for a housekeeping contig. | |
| serotype=self.idx_to_serotype[best] if is_cps else None, | |
| confidence=float(serotype_probs[best]), | |
| is_novel=is_novel if is_cps else None, | |
| nn_distance=nn_distance, | |
| nn_serotype=nn_serotype, | |
| embedding=z_np, | |
| ) | |
| def predict_fasta(self, path: str, top_k: int = 1) -> Iterator[SerotypeCall]: | |
| """Yield one call per FASTA record. Records should be cut *cps* loci.""" | |
| from Bio import SeqIO | |
| for record in SeqIO.parse(path, "fasta"): | |
| yield self.predict_sequence(str(record.seq), record_id=record.id, top_k=top_k) | |
| if __name__ == "__main__": | |
| import argparse | |
| import csv | |
| import sys | |
| p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| p.add_argument("fasta", help="FASTA of cps loci") | |
| p.add_argument("--model_dir", default=".", help="Directory holding config.json etc.") | |
| p.add_argument("--device", default="cpu") | |
| p.add_argument("--output", default="-", help="CSV output path ('-' for stdout)") | |
| args = p.parse_args() | |
| model = ALLCAPS.from_pretrained(args.model_dir, device=args.device) | |
| handle = sys.stdout if args.output == "-" else open(args.output, "w", newline="") | |
| writer = csv.DictWriter(handle, fieldnames=[ | |
| "record_id", "is_cps", "cps_probability", "serotype", "confidence", | |
| "is_novel", "nn_distance", "nn_serotype"]) | |
| writer.writeheader() | |
| for call in model.predict_fasta(args.fasta): | |
| writer.writerow(call.as_dict()) | |
| if handle is not sys.stdout: | |
| handle.close() | |