Skip to main content

ferritin_plms/esmfold2/layers/
pair_init.rs

1//! Pair representation initialization for the ESMFold2 FoldingTrunk.
2//!
3//! Produces the initial pair tensor `[B, N, N, d_pair]` from three sources:
4//!
5//! 1. **Relative position encoding** — for each (i,j), the clipped relative
6//!    residue-index difference `clip(j-i, -n_bins, n_bins)` is one-hot encoded
7//!    into `2*n_bins+1 = 65` bins.
8//! 2. **Chain indicator** — two extra bins: same-chain / cross-chain.
9//! 3. **Outer product** — the single representation `[B,N,d_single]` is
10//!    projected to `d_outer`, outer-producted with itself, then projected
11//!    to `d_pair`.
12//!
13//! The three contributions are projected to `d_pair` and summed.
14//!
15//! This module contains standalone math functions (`relpos_encoding`,
16//! `chain_pair_features`, `outer_product`) that are testable without weights,
17//! plus the [`PairInit`] struct that holds the learned projections.
18
19use candle_core::{DType, Result, Tensor};
20use candle_nn::{Linear, VarBuilder, encoding::one_hot, linear};
21
22// ── Pure math ─────────────────────────────────────────────────────────────
23
24/// Relative-residue-index one-hot encoding for every (i,j) pair.
25///
26/// For each pair: `d = clip(j - i, -n_bins, n_bins)`, shifted to `[0, 2*n_bins]`,
27/// then one-hot encoded.
28///
29/// # Arguments
30/// * `residue_indices` — `[B, N]` integer residue positions (any integer dtype)
31/// * `n_bins`          — half-width of the window (config: 32)
32///
33/// # Returns
34/// `[B, N, N, 2*n_bins+1]` float32
35pub fn relpos_encoding(residue_indices: &Tensor, n_bins: usize) -> Result<Tensor> {
36    let ri = residue_indices.to_dtype(DType::F32)?.unsqueeze(2)?; // [B, N, 1]
37    let rj = residue_indices.to_dtype(DType::F32)?.unsqueeze(1)?; // [B, 1, N]
38    let diff = rj.broadcast_sub(&ri)?; // [B, N, N]
39
40    let nb = n_bins as f64;
41    let clamped = diff.clamp(-nb, nb)?;
42    let shifted = (clamped + nb)?; // values in [0, 2*n_bins]
43
44    let shifted_u32 = shifted.to_dtype(DType::U32)?;
45    one_hot(shifted_u32, 2 * n_bins + 1, 1.0f32, 0.0f32) // [B, N, N, 2*n_bins+1]
46}
47
48/// Per-pair same-chain / different-chain indicator.
49///
50/// # Arguments
51/// * `chain_ids` — `[B, N]` integer chain identifiers (0, 1, 2, …)
52///
53/// # Returns
54/// `[B, N, N, 2]` float32, where dim-3 is `[same_chain, diff_chain]`.
55pub fn chain_pair_features(chain_ids: &Tensor) -> Result<Tensor> {
56    let ci = chain_ids.to_dtype(DType::F32)?.unsqueeze(2)?; // [B, N, 1]
57    let cj = chain_ids.to_dtype(DType::F32)?.unsqueeze(1)?; // [B, 1, N]
58    let diff = ci.broadcast_sub(&cj)?; // [B, N, N]
59
60    let same = diff.abs()?.lt(0.5_f64)?.to_dtype(DType::F32)?; // [B, N, N]
61    let different = (1.0_f64 - &same)?;
62    Tensor::stack(&[&same, &different], 3) // [B, N, N, 2]
63}
64
65/// Flat outer product of two `[B, N, d]` tensors.
66///
67/// Each (i,j) entry is the flattened outer product of row i from `a` and
68/// row j from `b`.
69///
70/// # Returns
71/// `[B, N, N, da * db]`
72pub fn outer_product(a: &Tensor, b: &Tensor) -> Result<Tensor> {
73    let (batch, n, da) = a.dims3()?;
74    let db = b.dim(2)?;
75    let ai = a.reshape((batch, n, 1, da, 1))?; // [B, N, 1, da, 1]
76    let bj = b.reshape((batch, 1, n, 1, db))?; // [B, 1, N, 1, db]
77    let prod = ai.broadcast_mul(&bj)?; // [B, N, N, da, db]
78    prod.reshape((batch, n, n, da * db)) // [B, N, N, da*db]
79}
80
81// ── PairInit ──────────────────────────────────────────────────────────────
82
83/// Learnable pair-representation initializer.
84///
85/// Combines relative-position + chain features and an outer-product term to
86/// produce the initial `[B, N, N, d_pair]` tensor fed into the Pairformer.
87pub struct PairInit {
88    relpos_proj: Linear,
89    single_proj: Linear,
90    outer_proj: Linear,
91    n_relpos_bins: usize,
92}
93
94impl PairInit {
95    /// Load from a `VarBuilder` rooted at `pair_init.*`.
96    ///
97    /// # Arguments
98    /// * `d_single`       — single-repr dim (384)
99    /// * `d_pair`         — output pair dim (256)
100    /// * `n_relpos_bins`  — half-window for relpos (32 → 65 bins)
101    /// * `d_outer`        — inner dim for outer-product projection (32)
102    pub fn load(
103        vb: VarBuilder,
104        d_single: usize,
105        d_pair: usize,
106        n_relpos_bins: usize,
107        d_outer: usize,
108    ) -> Result<Self> {
109        let relpos_in = 2 * n_relpos_bins + 1 + 2; // 65 relpos bins + 2 chain bins
110        Ok(Self {
111            relpos_proj: linear::linear_no_bias(relpos_in, d_pair, vb.pp("relpos_proj"))?,
112            single_proj: linear::linear_no_bias(d_single, d_outer, vb.pp("single_proj"))?,
113            outer_proj: linear::linear_no_bias(d_outer * d_outer, d_pair, vb.pp("outer_proj"))?,
114            n_relpos_bins,
115        })
116    }
117
118    /// Build the initial pair representation.
119    ///
120    /// # Arguments
121    /// * `single`          — `[B, N, d_single]`
122    /// * `residue_indices` — `[B, N]` integer residue positions
123    /// * `chain_ids`       — `[B, N]` integer chain identifiers
124    ///
125    /// # Returns
126    /// `[B, N, N, d_pair]`
127    pub fn forward(
128        &self,
129        single: &Tensor,
130        residue_indices: &Tensor,
131        chain_ids: &Tensor,
132    ) -> Result<Tensor> {
133        // RelPos + chain → [B, N, N, d_pair]
134        let relpos = relpos_encoding(residue_indices, self.n_relpos_bins)?;
135        let chain = chain_pair_features(chain_ids)?;
136        let pos_feat = Tensor::cat(&[&relpos, &chain], 3)?;
137        let pair_from_pos = pos_feat.apply(&self.relpos_proj)?;
138
139        // Outer product → [B, N, N, d_pair]
140        let s_proj = single.apply(&self.single_proj)?;
141        let outer = outer_product(&s_proj, &s_proj)?;
142        let pair_from_outer = outer.apply(&self.outer_proj)?;
143
144        pair_from_pos + pair_from_outer
145    }
146}
147
148// ── Tests ──────────────────────────────────────────────────────────────────
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use candle_core::{Device, Tensor};
154    use candle_nn::VarBuilder;
155
156    const B: usize = 1;
157    const N: usize = 8;
158    const N_BINS: usize = 32;
159
160    fn arange_indices(n: usize) -> Tensor {
161        let vals: Vec<f32> = (0..n).map(|i| i as f32).collect();
162        Tensor::from_vec(vals, &[B, N], &Device::Cpu).unwrap()
163    }
164
165    #[test]
166    fn test_relpos_encoding_shape() {
167        let idx = arange_indices(N);
168        let enc = relpos_encoding(&idx, N_BINS).unwrap();
169        assert_eq!(enc.dims(), &[B, N, N, 2 * N_BINS + 1]);
170    }
171
172    // Helper: get a 1D slice from a 4D tensor at [b, i, j, :]
173    fn get_bin_vec(t: &Tensor, b: usize, i: usize, j: usize) -> Vec<f32> {
174        t.get(b)
175            .unwrap()
176            .get(i)
177            .unwrap()
178            .get(j)
179            .unwrap()
180            .to_vec1()
181            .unwrap()
182    }
183
184    // Helper: get a 1D slice from a 3D tensor at [b, i, :]
185    fn get_pair_vec(t: &Tensor, b: usize, i: usize, j: usize) -> Vec<f32> {
186        t.get(b)
187            .unwrap()
188            .get(i)
189            .unwrap()
190            .get(j)
191            .unwrap()
192            .to_vec1()
193            .unwrap()
194    }
195
196    #[test]
197    fn test_relpos_encoding_diagonal_is_centre_bin() {
198        let idx = arange_indices(N);
199        let enc = relpos_encoding(&idx, N_BINS).unwrap();
200        // On the diagonal i==j, relative pos = 0 → shifted = n_bins → hot at index n_bins
201        for i in 0..N {
202            let vals = get_bin_vec(&enc, 0, i, i);
203            assert!(
204                (vals[N_BINS] - 1.0).abs() < 1e-5,
205                "diagonal [{i},{i}] should be hot at bin {N_BINS}, got {vals:?}"
206            );
207            let off: f32 = vals
208                .iter()
209                .enumerate()
210                .filter(|&(k, _)| k != N_BINS)
211                .map(|(_, &v)| v)
212                .sum();
213            assert!(off.abs() < 1e-5, "off bins should be zero");
214        }
215    }
216
217    #[test]
218    fn test_relpos_encoding_off_diagonal() {
219        let idx = arange_indices(N);
220        let enc = relpos_encoding(&idx, N_BINS).unwrap();
221
222        // pair (0,1): relative pos = +1 → shifted = n_bins+1
223        let row = get_bin_vec(&enc, 0, 0, 1);
224        assert!((row[N_BINS + 1] - 1.0).abs() < 1e-5);
225
226        // pair (1,0): relative pos = -1 → shifted = n_bins-1
227        let row = get_bin_vec(&enc, 0, 1, 0);
228        assert!((row[N_BINS - 1] - 1.0).abs() < 1e-5);
229    }
230
231    #[test]
232    fn test_relpos_clamped_at_boundary() {
233        // diff = 100, clamped to n_bins=32, shifted = 2*32 = 64 (last bin)
234        let far = Tensor::from_vec(vec![0.0f32, 100.0], &[1, 2], &Device::Cpu).unwrap();
235        let enc = relpos_encoding(&far, N_BINS).unwrap();
236        let row = get_bin_vec(&enc, 0, 0, 1);
237        assert!(
238            (row[2 * N_BINS] - 1.0).abs() < 1e-5,
239            "should clamp to max bin"
240        );
241    }
242
243    #[test]
244    fn test_chain_pair_features_shape() {
245        let chain_ids = Tensor::zeros(&[B, N], DType::F32, &Device::Cpu).unwrap();
246        let feats = chain_pair_features(&chain_ids).unwrap();
247        assert_eq!(feats.dims(), &[B, N, N, 2]);
248    }
249
250    #[test]
251    fn test_chain_pair_features_same_chain() {
252        // All residues on chain 0 → every pair is same-chain
253        let chain_ids = Tensor::zeros(&[B, N], DType::F32, &Device::Cpu).unwrap();
254        let feats = chain_pair_features(&chain_ids).unwrap();
255        let val = get_pair_vec(&feats, 0, 0, 1);
256        assert!(
257            (val[0] - 1.0).abs() < 1e-5,
258            "same chain index 0 should be 1"
259        );
260        assert!(val[1].abs() < 1e-5, "same chain index 1 should be 0");
261    }
262
263    #[test]
264    fn test_chain_pair_features_cross_chain() {
265        // Residues 0..4 on chain 0, residues 4..8 on chain 1
266        let ids: Vec<f32> = (0..N).map(|i| if i < 4 { 0.0 } else { 1.0 }).collect();
267        let chain_ids = Tensor::from_vec(ids, &[B, N], &Device::Cpu).unwrap();
268        let feats = chain_pair_features(&chain_ids).unwrap();
269
270        // Same-chain pair (0,1)
271        let same = get_pair_vec(&feats, 0, 0, 1);
272        assert!((same[0] - 1.0).abs() < 1e-5);
273        assert!(same[1].abs() < 1e-5);
274
275        // Cross-chain pair (0,4)
276        let cross = get_pair_vec(&feats, 0, 0, 4);
277        assert!(cross[0].abs() < 1e-5);
278        assert!((cross[1] - 1.0).abs() < 1e-5);
279    }
280
281    #[test]
282    fn test_outer_product_shape() {
283        let a = Tensor::zeros(&[B, N, 16], DType::F32, &Device::Cpu).unwrap();
284        let b = Tensor::zeros(&[B, N, 16], DType::F32, &Device::Cpu).unwrap();
285        let out = outer_product(&a, &b).unwrap();
286        assert_eq!(out.dims(), &[B, N, N, 256]);
287    }
288
289    #[test]
290    fn test_outer_product_values() {
291        // [1, 2] outer [3, 4] = [3, 4, 6, 8]
292        let a = Tensor::from_vec(vec![1.0f32, 2.0], &[1, 1, 2], &Device::Cpu).unwrap();
293        let b = Tensor::from_vec(vec![3.0f32, 4.0], &[1, 1, 2], &Device::Cpu).unwrap();
294        let out = outer_product(&a, &b).unwrap();
295        let vals = out.flatten_all().unwrap().to_vec1::<f32>().unwrap();
296        assert!((vals[0] - 3.0).abs() < 1e-5, "1*3 = 3");
297        assert!((vals[1] - 4.0).abs() < 1e-5, "1*4 = 4");
298        assert!((vals[2] - 6.0).abs() < 1e-5, "2*3 = 6");
299        assert!((vals[3] - 8.0).abs() < 1e-5, "2*4 = 8");
300    }
301
302    #[test]
303    fn test_pair_init_forward_shape() {
304        let device = Device::Cpu;
305        let d_single = 32_usize; // smaller dims for fast test
306        let d_pair = 16_usize;
307        let n_bins = 4_usize;
308        let d_outer = 4_usize;
309
310        let vb = VarBuilder::zeros(DType::F32, &device);
311        let init = PairInit::load(vb, d_single, d_pair, n_bins, d_outer).unwrap();
312
313        let single = Tensor::zeros(&[B, N, d_single], DType::F32, &device).unwrap();
314        let residue_idx = arange_indices(N);
315        let chain_ids = Tensor::zeros(&[B, N], DType::F32, &device).unwrap();
316
317        let pair = init.forward(&single, &residue_idx, &chain_ids).unwrap();
318        assert_eq!(pair.dims(), &[B, N, N, d_pair]);
319    }
320}