phlam / utils /phlam_embedder.py
pswap's picture
arc
66a69dd
Raw History Blame Contribute Delete
4.27 kB
"""PHLAM genome/protein embeddings from a genome's ESM2 protein embeddings.
phlam_genome: h_global -- one vector per genome.
phlam_protein: h_protein - mean(h_protein over the genome) -- one residual
vector per protein, i.e. each protein's deviation from its
own genome's average.
"""
import os
import shutil
import torch
import torch.nn.functional as F
from phlam.model import PHLAM
from utils.retry import retry
# matches training (phagevault/pipelines/embed_phlam.py: raw +1 -> 2, raw -1 -> 1)
STRAND_TO_ID = {"+": 2, "-": 1}
MODELS_DIR = os.environ.get("MODELS_DATA_DIR", "/data/models")
CKPT_FILENAME = os.environ.get("PHLAM_CKPT_FILE", "phlam_model.pt")
MODEL_CKPT_PATH = os.environ.get("PHLAM_CKPT_PATH", os.path.join(MODELS_DIR, CKPT_FILENAME))
LOCAL_CACHE_DIR = os.environ.get("LOCAL_CACHE_DIR", "/tmp/phlam_local_cache")
NUM_LAYERS = 10
NUM_HEADS = 12
def _ensure_local(mount_path):
"""Copies a small file off the (possibly flaky) network mount once, so
every load after the first reads a plain local file instead of racing
the mount again. Checkpoint-sized files only -- the multi-GB FAISS
indexes stay mmap'd straight off the mount, that's a different tradeoff."""
os.makedirs(LOCAL_CACHE_DIR, exist_ok=True)
local_path = os.path.join(LOCAL_CACHE_DIR, os.path.basename(mount_path))
if not os.path.exists(local_path):
tmp_path = local_path + ".tmp"
def _copy():
shutil.copyfile(mount_path, tmp_path)
os.replace(tmp_path, local_path)
retry(_copy, label=f"copy {mount_path} to local cache")
return local_path
class PHLAMEmbedder:
def __init__(self, ckpt_path=MODEL_CKPT_PATH, device="cpu"):
local_path = _ensure_local(ckpt_path)
ckpt = torch.load(local_path, map_location="cpu", weights_only=False)
state_dict = {k.replace("_orig_mod.", ""): v for k, v in ckpt["model_state_dict"].items()}
model = PHLAM(input_dim=480, d_model=480, output_dim=480,
num_layers=NUM_LAYERS, num_heads=NUM_HEADS, num_global=1)
model.load_state_dict(state_dict, strict=True)
model.eval()
self.model = model.to(device)
self.device = device
def embed(self, esm_embeds, strands, is_circular=True):
"""esm_embeds: [L, 480] tensor/array of raw (pre-normalization) ESM
embeddings for one genome, in gene order. strands: list[str] of "+"/"-"
of length L. Returns {"phlam_genome": [480], "phlam_protein": [L, 480]}
as numpy arrays."""
strand_ids = [STRAND_TO_ID[s] for s in strands]
esm_t = torch.as_tensor(esm_embeds, dtype=torch.float32)
esm_in = F.normalize(esm_t, p=2, dim=-1).unsqueeze(0).to(self.device)
strand_t = torch.tensor([strand_ids], dtype=torch.long, device=self.device)
with torch.no_grad():
out = self.model(
strand_ids=strand_t, esm_embeds=esm_in,
is_circular=1.0 if is_circular else 0.0,
output_attentions=False,
)
h_protein = out["h_protein"][0] # [L, 480]
h_global = out["h_global"][0] # [480]
phlam_protein = h_protein - h_protein.mean(dim=0, keepdim=True)
# Reconstruction quality per protein: cosine distance between
# PHLAM's denoised reconstruction and the (normalized) ESM input
# it was asked to recover -- low = familiar/well-modeled, high =
# novel. strand_flip_prob: no corruption is applied at
# inference, so a high value flags a protein whose orientation
# looks inconsistent with the local synteny PHLAM has learned.
cos_sim = F.cosine_similarity(out["recon_esm"][0], esm_in[0], dim=-1)
cosine_loss = (1.0 - cos_sim).float().cpu().numpy()
strand_probs = F.softmax(out["strand_logits"][0], dim=-1)
strand_flip_prob = strand_probs[:, 1].float().cpu().numpy()
return {
"phlam_genome": h_global.float().cpu().numpy(),
"phlam_protein": phlam_protein.float().cpu().numpy(),
"cosine_loss": cosine_loss,
"strand_flip_prob": strand_flip_prob,
}