Skip to main content

ferritin_plms/esm3/
pretrained.rs

1//! ESM3 pretrained weight loading.
2//!
3//! Downloads weights from `EvolutionaryScale/esm3-sm-open-v1` on HuggingFace (gated access —
4//! the user must accept the Cambrian Non-Commercial license and run `huggingface-cli login`).
5//!
6//! ## Weight layout
7//!
8//! The `.pth` file is a plain PyTorch state dict saved via `torch.save(model.state_dict(), ...)`.
9//! No wrapping key is used; `PthTensors::new(path, None)` loads it directly.
10//!
11//! | Model attribute (Python) | VarBuilder prefix |
12//! |--------------------------|-------------------|
13//! | `encoder.*`              | `encoder.*`       |
14//! | `transformer.*`          | `transformer.*`   |
15//! | `output_heads.*`         | `output_heads.*`  |
16//!
17//! The structure encoder uses a separate checkpoint (`esm3_structure_encoder_v0.pth`):
18//!
19//! | Python attribute        | VarBuilder prefix  |
20//! |-------------------------|--------------------|
21//! | `encoder.*` (blocks)    | `encoder.*`        |
22//! | `pre_vq_proj.weight`    | `pre_vq_proj.*`    |
23//! | `codebook.embeddings`   | `codebook.*`       |
24
25use crate::esm3::models::esm3::{ESM3, ESM3Config};
26use crate::esm3::models::vqvae::{StructureTokenEncoder, VqVaeConfig};
27use crate::esm3::tokenization::sequence::tokenize_sequence;
28use crate::plm_runner::PlmRunner;
29use anyhow::{Context, Result};
30use candle_core::pickle::PthTensors;
31use candle_core::{DType, Device, Tensor};
32use candle_nn::VarBuilder;
33use hf_hub::HFClientSync;
34
35const ESM3_DTYPE: DType = DType::F32;
36
37// ── ESM3Models enum ───────────────────────────────────────────────────────────
38
39/// Available ESM3 model variants.
40pub enum ESM3Models {
41    /// esm3-sm-open-v1 — 1.4B parameter open-access model (Cambrian Non-Commercial license).
42    SmOpen,
43}
44
45impl ESM3Models {
46    /// HuggingFace repo and `.pth` filename for this variant.
47    pub fn model_info(&self) -> (&'static str, &'static str, ESM3Config) {
48        match self {
49            Self::SmOpen => (
50                "EvolutionaryScale/esm3-sm-open-v1",
51                "esm3_sm_open_v1.pth",
52                ESM3Config::sm_open(),
53            ),
54        }
55    }
56}
57
58// ── ESM3Runner ────────────────────────────────────────────────────────────────
59
60/// Wraps a loaded ESM3 model for sequence embedding and multi-track inference.
61///
62/// ## HuggingFace access
63/// The ESM3 model is gated. Before calling `from_pretrained` the user must:
64/// 1. Accept the Cambrian Non-Commercial license at
65///    <https://huggingface.co/EvolutionaryScale/esm3-sm-open-v1>
66/// 2. Run `huggingface-cli login` (or set `HF_TOKEN` env var)
67pub struct ESM3Runner {
68    model: ESM3,
69    device: Device,
70}
71
72impl ESM3Runner {
73    /// Download the ESM3 weights from HuggingFace and load the model.
74    ///
75    /// The `.pth` checkpoint is loaded directly via `candle_core::pickle::PthTensors`.
76    pub fn from_pretrained(variant: ESM3Models, device: Device) -> Result<Self> {
77        let (repo_id, filename, config) = variant.model_info();
78        let (owner, name) = repo_id.split_once('/').unwrap_or(("", repo_id));
79
80        let client = HFClientSync::new().context("failed to initialise HF client")?;
81        let weights_path = client
82            .model(owner, name)
83            .download_file()
84            .filename(filename)
85            .send()
86            .with_context(|| format!("failed to download {} from {}", filename, repo_id))?;
87
88        let pth = PthTensors::new(&weights_path, None)
89            .with_context(|| format!("failed to parse {}", weights_path.display()))?;
90        let vb = VarBuilder::from_backend(Box::new(pth), ESM3_DTYPE, device.clone());
91
92        let model = ESM3::load(vb, config)?;
93        Ok(Self { model, device })
94    }
95
96    /// Tokenize `sequence` and return per-residue embeddings `(1, L, d_model)`.
97    ///
98    /// Only the sequence track is provided; all other tracks are `None`.
99    /// Embeddings are the pre-norm transformer hidden states (raw activations).
100    pub fn embed_sequence(&self, sequence: &str) -> Result<Tensor> {
101        let token_ids = tokenize_sequence(sequence, true);
102        let tokens = Tensor::new(token_ids.as_slice(), &self.device)?.unsqueeze(0)?; // (1, L)
103
104        let output = self.model.forward(
105            Some(&tokens), // sequence_tokens
106            None,          // structure_tokens
107            None,          // ss8_tokens
108            None,          // sasa_tokens
109            None,          // function_tokens
110            None,          // residue_annotation_tokens
111            None,          // average_plddt
112            None,          // per_res_plddt
113            None,          // sequence_id
114            None,          // structure_coords
115            None,          // chain_id
116        )?;
117
118        output
119            .embeddings
120            .ok_or_else(|| anyhow::anyhow!("ESM3 forward() returned no embeddings"))
121    }
122}
123
124impl PlmRunner for ESM3Runner {
125    fn embed(&self, sequence: &str) -> Result<Tensor> {
126        self.embed_sequence(sequence)
127    }
128
129    fn model_name(&self) -> &str {
130        "esm3"
131    }
132}
133
134// ── StructureEncoderRunner ────────────────────────────────────────────────────
135
136/// Wraps the ESM3 structure token encoder for converting backbone coordinates to tokens.
137///
138/// Uses a separate checkpoint (`esm3_structure_encoder_v0.pth`) from the main model.
139pub struct StructureEncoderRunner {
140    encoder: StructureTokenEncoder,
141    device: Device,
142}
143
144impl StructureEncoderRunner {
145    /// Download and load the structure encoder from HuggingFace.
146    pub fn from_pretrained(device: Device) -> Result<Self> {
147        let repo_id = "EvolutionaryScale/esm3-sm-open-v1";
148        let filename = "esm3_structure_encoder_v0.pth";
149        let (owner, name) = repo_id.split_once('/').unwrap_or(("", repo_id));
150
151        let client = HFClientSync::new().context("failed to initialise HF client")?;
152        let weights_path = client
153            .model(owner, name)
154            .download_file()
155            .filename(filename)
156            .send()
157            .with_context(|| format!("failed to download {} from {}", filename, repo_id))?;
158
159        let pth = PthTensors::new(&weights_path, None)
160            .with_context(|| format!("failed to parse {}", weights_path.display()))?;
161        let vb = VarBuilder::from_backend(Box::new(pth), ESM3_DTYPE, device.clone());
162
163        let encoder = StructureTokenEncoder::load(vb, VqVaeConfig::default())?;
164        Ok(Self { encoder, device })
165    }
166
167    /// Encode backbone coordinates to structure tokens.
168    ///
169    /// - `coords`: `(B, L, 3, 3)` backbone `(N, CA, C)` atom positions.
170    ///
171    /// Returns `(B, L)` u32 structure token indices.
172    pub fn encode(&self, coords: &Tensor) -> Result<Tensor> {
173        self.encoder.encode(coords, None, None).map_err(Into::into)
174    }
175}