Skip to main content

ferritin_plms/esmc/layers/
geom_attention.rs

1use crate::esm3::utils::affine3d::Affine3D;
2use crate::esmc::models::esmc::ESMCConfig;
3use candle_core::{D, Module, Result, Tensor};
4use candle_nn::{self as nn, LayerNorm, LayerNormConfig, Linear, VarBuilder};
5
6const SQRT_3: f64 = 1.7320508075688772;
7
8#[allow(dead_code)]
9pub struct GeometricReasoningOriginalImpl {
10    c_s: usize,
11    v_heads: usize,
12    num_vector_messages: usize,
13    mask_and_zero_frameless: bool,
14    s_norm: LayerNorm,
15    proj: Linear,
16    out_proj: Linear,
17    distance_scale_per_head: Tensor,
18    rotation_scale_per_head: Tensor,
19}
20
21impl GeometricReasoningOriginalImpl {
22    // pub fn new(
23    //     c_s: i64,
24    //     v_heads: i64,
25    //     num_vector_messages: i64,
26    //     mask_and_zero_frameless: bool,
27    //     _divide_residual_by_depth: bool,
28    //     bias: bool,
29    //     device: &Device,
30    // ) -> Result<Self> {
31    //     let dim_proj = 4 * v_heads * 3 + v_heads * 3 * num_vector_messages;
32    //     let channels_out = v_heads * 3 * num_vector_messages;
33
34    //     Ok(Self {
35    //         c_s,
36    //         v_heads,
37    //         num_vector_messages,
38    //         mask_and_zero_frameless,
39    //         s_norm: LayerNorm::new(c_s, bias)?,
40    //         proj: Linear::new(c_s, dim_proj, bias)?,
41    //         out_proj: Linear::new(channels_out, c_s, bias)?,
42    //         distance_scale_per_head: Tensor::zeros((v_heads,), device)?,
43    //         rotation_scale_per_head: Tensor::zeros((v_heads,), device)?,
44    //     })
45    // }
46    /// Load geometric attention from a `VarBuilder` rooted at `geom_attn`.
47    ///
48    /// Weight layout, verified against both `esm3_sm_open_v1.pth` and
49    /// `esm3_structure_encoder_v0.pth` (ferritin-100.21):
50    ///
51    /// ```text
52    /// s_norm.weight[/.bias]      — (d_model,)
53    /// proj.weight[/.bias]        — (15 * v_heads, d_model)
54    /// out_proj.weight[/.bias]    — (d_model, 3 * v_heads)
55    /// distance_scale_per_head    — (v_heads,)
56    /// rotation_scale_per_head    — (v_heads,)
57    /// ```
58    ///
59    /// The main ESM3 model stores these **without** bias terms while the VQ-VAE
60    /// structure encoder stores them **with** bias, so `config.bias` selects.
61    pub fn load(vb: VarBuilder, config: &ESMCConfig) -> Result<Self> {
62        let ESMCConfig {
63            d_model,
64            v_head_transformer,
65            mask_and_zero_frameless,
66            bias,
67            ..
68        } = config;
69
70        let num_vector_messages = 1usize;
71
72        // Previously defaulted to 128, which silently mis-sized every geometric
73        // projection for the main ESM3 model (v_heads = 256). The checkpoint's
74        // per-head scale tensors give the true value, so a missing config is an
75        // error rather than a guess.
76        let v_heads = v_head_transformer.ok_or_else(|| {
77            candle_core::Error::Msg(
78                "geometric attention needs v_head_transformer to be set; it determines the \
79                 projection sizes and cannot be guessed"
80                    .to_string(),
81            )
82        })?;
83
84        let dim_proj = 4 * v_heads * 3 + v_heads * 3 * num_vector_messages;
85        let channels_out = v_heads * 3 * num_vector_messages;
86
87        let s_norm = if *bias {
88            nn::layer_norm(*d_model, LayerNormConfig::from(1e-5), vb.pp("s_norm"))?
89        } else {
90            LayerNorm::new_no_bias(vb.pp("s_norm").get((*d_model,), "weight")?, 1e-5)
91        };
92
93        let (proj, out_proj) = if *bias {
94            (
95                nn::linear(*d_model, dim_proj, vb.pp("proj"))?,
96                nn::linear(channels_out, *d_model, vb.pp("out_proj"))?,
97            )
98        } else {
99            (
100                nn::linear_no_bias(*d_model, dim_proj, vb.pp("proj"))?,
101                nn::linear_no_bias(channels_out, *d_model, vb.pp("out_proj"))?,
102            )
103        };
104
105        // These are learned per-head scales. They used to be zero-initialised
106        // rather than loaded, which made geometric attention silently wrong
107        // while the model still loaded cleanly (ferritin-100.21).
108        let distance_scale_per_head = vb.get((v_heads,), "distance_scale_per_head")?;
109        let rotation_scale_per_head = vb.get((v_heads,), "rotation_scale_per_head")?;
110
111        Ok(Self {
112            c_s: *d_model,
113            v_heads,
114            num_vector_messages,
115            mask_and_zero_frameless: *mask_and_zero_frameless,
116            s_norm,
117            proj,
118            out_proj,
119            distance_scale_per_head,
120            rotation_scale_per_head,
121        })
122    }
123
124    /// Geometric attention forward pass.
125    ///
126    /// - `s`:           `(B, L, d_model)` hidden states.
127    /// - `affine`:      per-residue local frames `(B, L, 3, 3)` rot + `(B, L, 3)` trans.
128    /// - `affine_mask`: `(B, L)` u8 — 1 where the frame is valid, 0 for frameless positions.
129    /// - `sequence_id`: optional `(B, L)` int — positions with the same ID form one protein.
130    /// - `chain_id`:    optional `(B, L)` int — positions in the same chain.
131    ///
132    /// Returns `(B, L, d_model)`.
133    pub fn forward(
134        &self,
135        s: &Tensor,
136        affine: &Affine3D,
137        affine_mask: &Tensor,
138        sequence_id: Option<&Tensor>,
139        chain_id: Option<&Tensor>,
140    ) -> Result<Tensor> {
141        let (b, l, _) = s.dims3()?;
142        let dtype = s.dtype();
143        let device = s.device();
144
145        // ── Attention bias (sequence_id and chain_id masking) ──────────────
146        // Same-sequence pairs get 1.0; cross-sequence and frameless get -inf.
147        let attn_bias = if let Some(seq_id) = sequence_id {
148            let seq_q = seq_id.unsqueeze(D::Minus1)?; // (B, L, 1)
149            let seq_k = seq_id.unsqueeze(D::Minus2)?; // (B, 1, L)
150            // (B, L, L): 1 where same sequence, 0 where different
151            let same_seq = seq_q
152                .broadcast_as((b, l, l))?
153                .eq(&seq_k.broadcast_as((b, l, l))?)?
154                .to_dtype(dtype)?;
155            same_seq.unsqueeze(1)? // (B, 1, L, L)
156        } else {
157            Tensor::ones((b, 1, l, l), dtype, device)?
158        };
159
160        // Mask frameless key positions with -inf
161        let neg_inf =
162            Tensor::full(f32::NEG_INFINITY as f64, attn_bias.shape(), device)?.to_dtype(dtype)?;
163        // affine_mask: (B, L) → (B, 1, 1, L)
164        let frame_mask_k = affine_mask
165            .unsqueeze(1)?
166            .unsqueeze(1)?
167            .broadcast_as(attn_bias.shape())?;
168        let mut attn_bias = frame_mask_k.where_cond(&attn_bias, &neg_inf)?;
169
170        // Mask cross-chain pairs with -inf
171        if let Some(cid) = chain_id {
172            let chain_q = cid.unsqueeze(D::Minus1)?.broadcast_as((b, l, l))?;
173            let chain_k = cid.unsqueeze(D::Minus2)?.broadcast_as((b, l, l))?;
174            let diff_chain = chain_q.ne(&chain_k)?.unsqueeze(1)?; // (B, 1, L, L)
175            let diff_bias = diff_chain.broadcast_as(attn_bias.shape())?;
176            attn_bias = diff_bias.where_cond(&neg_inf, &attn_bias)?;
177        }
178
179        // ── Project hidden states ──────────────────────────────────────────
180        let ns = self.s_norm.forward(s)?;
181        let proj_out = self.proj.forward(&ns)?;
182
183        let vec_rot_size = self.v_heads * 2 * 3 + self.v_heads * 3 * self.num_vector_messages;
184        let vec_dist_size = self.v_heads * 2 * 3;
185        let vec_rot = proj_out.narrow(D::Minus1, 0, vec_rot_size)?;
186        let vec_dist = proj_out.narrow(D::Minus1, vec_rot_size, vec_dist_size)?;
187
188        // ── Rotation-only vectors: Q_rot, K_rot, V ────────────────────────
189        // Reshape: (B, L, (h*c)) → (B, L, h, 3)
190        let h_rot = 2 * self.v_heads + self.v_heads * self.num_vector_messages;
191        let vec_rot = vec_rot.reshape((b, l, h_rot, 3))?;
192        // Rotate local-frame vectors to global frame: (B, L, h_rot, 3)
193        let vec_rot = Affine3D::apply_rot(&affine.rot, &vec_rot)?;
194
195        let query_rot = vec_rot.narrow(D::Minus2, 0, self.v_heads)?; // (B, L, H, 3)
196        let key_rot = vec_rot.narrow(D::Minus2, self.v_heads, self.v_heads)?;
197        let value = vec_rot.narrow(
198            D::Minus2,
199            2 * self.v_heads,
200            self.v_heads * self.num_vector_messages,
201        )?;
202
203        // ── Full-affine (rot+trans) vectors: Q_dist, K_dist ───────────────
204        let vec_dist = vec_dist.reshape((b, l, self.v_heads * 2, 3))?;
205        let vec_dist = affine.apply(&vec_dist)?; // (B, L, H*2, 3) in global frame
206        let query_dist = vec_dist.narrow(D::Minus2, 0, self.v_heads)?; // (B, L, H, 3)
207        let key_dist = vec_dist.narrow(D::Minus2, self.v_heads, self.v_heads)?;
208
209        // ── Rearrange for attention computation ───────────────────────────
210        // (B, L, H, 3) → (B, H, L, 3)
211        let query_rot = query_rot.permute((0, 2, 1, 3))?;
212        // (B, L, H, 3) → (B, H, 3, L)  [for matmul with query]
213        let key_rot = key_rot.permute((0, 2, 3, 1))?;
214        // (B, L, H, 3) → (B, H, L, 1, 3)
215        let query_dist = query_dist.permute((0, 2, 1, 3))?.unsqueeze(D::Minus2)?;
216        // (B, L, H, 3) → (B, H, 1, L, 3)  [unsqueeze at position 2 = -3 of 5-dim result]
217        let key_dist = key_dist.permute((0, 2, 1, 3))?.unsqueeze(2)?;
218        // (B, L, H*num_vm, 3) → (B, H, L, num_vm*3)
219        let value = value
220            .reshape((b, l, self.v_heads, self.num_vector_messages * 3))?
221            .permute((0, 2, 1, 3))?;
222
223        // ── Attention weight: rotation + distance terms ────────────────────
224        // Rotation term: (B, H, L, 3) @ (B, H, 3, L) = (B, H, L, L)
225        // affine(scale, 0.0) = tensor * scale (scalar multiplication)
226        let rotation_term = query_rot
227            .contiguous()?
228            .matmul(&key_rot.contiguous()?)?
229            .affine(SQRT_3.recip(), 0.0)?;
230
231        // Distance term: ||q - k||_2 / sqrt(3) → (B, H, L, L)
232        let diff = query_dist
233            .broadcast_as((b, self.v_heads, l, l, 3))?
234            .sub(&key_dist.broadcast_as((b, self.v_heads, l, l, 3))?)?;
235        let distance_term = diff
236            .sqr()?
237            .sum(D::Minus1)?
238            .sqrt()?
239            .affine(SQRT_3.recip(), 0.0)?;
240
241        // Learnable per-head weights: (H,) → (1, H, 1, 1); softplus = log(1 + exp(x))
242        let dist_w = softplus(&self.distance_scale_per_head)?
243            .reshape((1, self.v_heads, 1, 1))?
244            .broadcast_as((b, self.v_heads, l, l))?;
245        let rot_w = softplus(&self.rotation_scale_per_head)?
246            .reshape((1, self.v_heads, 1, 1))?
247            .broadcast_as((b, self.v_heads, l, l))?;
248
249        let mut attn_weight = rotation_term
250            .mul(&rot_w)?
251            .sub(&distance_term.mul(&dist_w)?)?;
252
253        // Add attention bias (already (B, 1, L, L); broadcast to (B, H, L, L))
254        let attn_bias = attn_bias.broadcast_as(attn_weight.shape())?;
255        attn_weight = attn_weight.add(&attn_bias)?;
256
257        let attn_weight = candle_nn::ops::softmax(&attn_weight, D::Minus1)?;
258
259        // ── Weighted sum of values ────────────────────────────────────────
260        // (B, H, L, L) @ (B, H, L, num_vm*3) = (B, H, L, num_vm*3)
261        let attn_out = attn_weight.matmul(&value.contiguous()?)?;
262
263        // Rearrange: (B, H, L, num_vm*3) → (B, L, H*num_vm, 3)
264        let attn_out = attn_out
265            .permute((0, 2, 1, 3))? // (B, L, H, num_vm*3)
266            .contiguous()?
267            .reshape((b, l, self.v_heads * self.num_vector_messages, 3))?;
268
269        // Rotate back from global frame to local frame
270        let attn_out = Affine3D::apply_rot_inv(&affine.rot, &attn_out)?;
271
272        // Flatten head and vector-message dims: (B, L, H*num_vm, 3) → (B, L, H*num_vm*3)
273        let mut attn_out =
274            attn_out
275                .contiguous()?
276                .reshape((b, l, self.v_heads * self.num_vector_messages * 3))?;
277
278        // Zero out frameless positions if requested
279        if self.mask_and_zero_frameless {
280            let zeros = Tensor::zeros_like(&attn_out)?;
281            let mask_exp = affine_mask
282                .unsqueeze(D::Minus1)?
283                .broadcast_as(attn_out.shape())?;
284            attn_out = mask_exp.where_cond(&attn_out, &zeros)?;
285        }
286
287        self.out_proj.forward(&attn_out)
288    }
289}
290
291/// Numerically stable softplus: `log(1 + exp(x))`.
292fn softplus(t: &Tensor) -> Result<Tensor> {
293    // affine(1.0, 1.0) computes tensor * 1 + 1 = tensor + 1
294    t.exp()?.affine(1.0, 1.0)?.log()
295}