Skip to main content

uno_core/
rt.rs

1//! Retention-time prediction: shared featurization + a gbdt model distilled
2//! from DeepLC.
3//!
4//! Both `uno-rt-train` (offline distillation) and the search scorer use
5//! [`rt_features`] so the model sees identical inputs at train and inference
6//! time. [`rt_features`] emits the 99-d order-aware set described on
7//! [`RT_N_FEATURES`] below. It REPLACED an earlier composition-only set in the
8//! style of Klammer et al. (2007) (20 AA counts, 20 N-terminal-window counts, 20
9//! C-terminal-window counts, length, ln(1+mass), intercept = 63 features), which
10//! is no longer built here.
11
12use crate::mass;
13use anyhow::{Context, Result};
14use gbdt::decision_tree::Data;
15use gbdt::gradient_boost::GBDT;
16use std::path::Path;
17
18const AA: &[u8; 20] = b"ACDEFGHIKLMNPQRSTVWY";
19const N_POS_BINS: usize = 3;
20
21/// Number of features produced by [`rt_features`]. Order-aware "rich" set:
22/// 20 AA composition + 6 terminal-residue identities (first 3, last 3) +
23/// `N_POS_BINS`*20 position-binned composition + 13 scalar descriptors.
24/// Validated to match DeepLC's MS1 protein recovery (vs the old 63-feature
25/// composition-only set above, which trailed it), via order-aware features the
26/// composition set lacked.
27pub const RT_N_FEATURES: usize = 20 + 6 + N_POS_BINS * 20 + 13;
28
29/// Per-residue physicochemical scales, indexed by position in `AA`
30/// ("ACDEFGHIKLMNPQRSTVWY"). KD = Kyte-Doolittle hydropathy; RC =
31/// Krokhin/SSRCalc-style retention coefficient; EI = Eisenberg consensus.
32const KD: [f64; 20] = [
33    1.8, 2.5, -3.5, -3.5, 2.8, -0.4, -3.2, 4.5, -3.9, 3.8, 1.9, -3.5, -1.6, -3.5, -4.5, -0.8, -0.7,
34    4.2, -0.9, -1.3,
35];
36const RC: [f64; 20] = [
37    1.1, 0.5, 0.2, 1.1, 10.9, 0.2, -3.1, 8.0, -3.1, 9.6, 5.0, -1.2, 2.1, -2.0, -4.1, -2.1, 0.6,
38    5.5, 11.1, 4.4,
39];
40const EI: [f64; 20] = [
41    0.62, 0.29, -0.90, -0.74, 1.19, 0.48, -0.40, 1.38, -1.50, 1.06, 0.64, -0.78, 0.12, -0.85,
42    -2.53, -0.18, -0.05, 1.08, 0.81, 0.26,
43];
44
45fn aa_index(c: u8) -> Option<usize> {
46    AA.iter().position(|&a| a == c)
47}
48
49fn peptide_mass(seq: &[u8]) -> f64 {
50    let mut m = mass::H2O;
51    for &c in seq {
52        m += mass::residue(c).unwrap_or(0.0);
53    }
54    m
55}
56
57/// `numpy.array_split(0..n, k)` boundary: cumulative size of the first `i` of
58/// `k` contiguous chunks of `n` items (first `n % k` chunks get one extra).
59fn split_bound(n: usize, k: usize, i: usize) -> usize {
60    let (base, rem) = (n / k, n % k);
61    i * base + i.min(rem)
62}
63
64/// Order-aware "rich" RT feature vector for a stripped peptide sequence.
65/// Layout: AA composition (20), terminal residue identities (6), position-binned
66/// composition (`N_POS_BINS`*20), then 13 scalar descriptors.
67pub fn rt_features(seq: &str) -> Vec<f64> {
68    let bytes = seq.as_bytes();
69    let idx: Vec<usize> = bytes.iter().filter_map(|&c| aa_index(c)).collect();
70    let n = idx.len();
71    let mut v = vec![0.0f64; RT_N_FEATURES];
72
73    // [0,20) AA composition.
74    for &j in &idx {
75        v[j] += 1.0;
76    }
77    // [20,26) terminal residue identities: first 3, last 3 (index value, -1 pad).
78    for k in 0..3 {
79        v[20 + k] = if k < n { idx[k] as f64 } else { -1.0 };
80        v[23 + k] = if k < n { idx[n - 1 - k] as f64 } else { -1.0 };
81    }
82    // [26, 26+N_POS_BINS*20) position-binned composition (numpy.array_split order).
83    let pb = 26;
84    for b in 0..N_POS_BINS {
85        let (lo, hi) = (
86            split_bound(n, N_POS_BINS, b),
87            split_bound(n, N_POS_BINS, b + 1),
88        );
89        for &j in &idx[lo..hi] {
90            v[pb + b * 20 + j] += 1.0;
91        }
92    }
93    // [pb+N_POS_BINS*20, +13) scalar descriptors.
94    let s = pb + N_POS_BINS * 20;
95    let nf = n.max(1) as f64;
96    let (mut sum_kd, mut sum_rc, mut sum_ei) = (0.0, 0.0, 0.0);
97    let (mut max_kd, mut min_kd) = (f64::NEG_INFINITY, f64::INFINITY);
98    let (mut ssr, mut moment) = (0.0, 0.0);
99    let (mut p_ct, mut arom, mut kr) = (0.0, 0.0, 0.0);
100    for (k, &j) in idx.iter().enumerate() {
101        sum_kd += KD[j];
102        sum_rc += RC[j];
103        sum_ei += EI[j];
104        max_kd = max_kd.max(KD[j]);
105        min_kd = min_kd.min(KD[j]);
106        ssr += RC[j] * (1.0 + if k < 3 { 0.3 } else { 0.0 });
107        moment += KD[j]
108            * if n > 1 {
109                k as f64 / (n - 1) as f64
110            } else {
111                0.0
112            };
113        match AA[j] {
114            b'P' => p_ct += 1.0,
115            b'F' | b'W' | b'Y' => arom += 1.0,
116            b'K' | b'R' => kr += 1.0,
117            _ => {}
118        }
119    }
120    if n == 0 {
121        max_kd = 0.0;
122        min_kd = 0.0;
123    }
124    v[s] = n as f64;
125    v[s + 1] = peptide_mass(bytes).ln_1p();
126    v[s + 2] = sum_kd;
127    v[s + 3] = sum_kd / nf;
128    v[s + 4] = max_kd;
129    v[s + 5] = min_kd;
130    v[s + 6] = sum_rc;
131    v[s + 7] = sum_ei;
132    v[s + 8] = ssr;
133    v[s + 9] = p_ct;
134    v[s + 10] = arom;
135    v[s + 11] = kr;
136    v[s + 12] = moment;
137    v
138}
139
140fn to_f32(v: &[f64]) -> Vec<f32> {
141    v.iter().map(|&x| x as f32).collect()
142}
143
144/// Distilled RT backend. Predicts DeepLC abstract RT from sequence; the values
145/// are in DeepLC's uncalibrated space (per-run calibration is applied downstream).
146enum RtBackend {
147    /// Distilled gbdt student (legacy; gbdt-rs serialized JSON).
148    Gbdt(GBDT),
149    /// Distilled MLP student (`UMLP` binary, see [`crate::rt_mlp`]).
150    Mlp(crate::rt_mlp::RtMlp),
151    /// Sequence-aware CNN trained on experimental iRT (`URTC` binary, see
152    /// [`crate::rt_cnn`]).
153    Cnn(crate::rt_cnn::RtCnn),
154}
155
156/// The supported pretrained CNN, embedded so installed binaries work anywhere.
157pub const DEFAULT_RT_CNN: &[u8] = include_bytes!("../models/rt_cnn_chrono_global_v3_ch32.urtc");
158
159/// A loaded RT model plus a content `fingerprint` (a stable hash of the model
160/// file bytes). The fingerprint keys the on-disk prediction cache
161/// ([`crate::rt_cache`]) so cached predictions are reused only for the exact
162/// model that produced them.
163pub struct RtModel {
164    backend: RtBackend,
165    fingerprint: u64,
166}
167
168impl RtModel {
169    /// The bundled CNN, with no runtime file or Python dependency.
170    pub fn bundled() -> Result<Self> {
171        Ok(Self {
172            backend: RtBackend::Cnn(crate::rt_cnn::RtCnn::from_bytes(DEFAULT_RT_CNN)?),
173            fingerprint: fingerprint_bytes(DEFAULT_RT_CNN),
174        })
175    }
176
177    /// CNN provenance when available; legacy backends have no CNN metadata.
178    pub fn cnn_info(&self) -> Option<&serde_json::Value> {
179        match &self.backend {
180            RtBackend::Cnn(model) => Some(model.info()),
181            _ => None,
182        }
183    }
184    /// Load a distilled RT model, auto-detecting the backend by file magic:
185    /// a leading `UMLP` => MLP, `URTC` => CNN, otherwise the gbdt-rs JSON format.
186    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
187        let path = path.as_ref();
188        let bytes = std::fs::read(path)
189            .map_err(|e| anyhow::anyhow!("opening rt model {}: {e}", path.display()))?;
190        let fingerprint = fingerprint_bytes(&bytes);
191        let magic = bytes.get(0..4).unwrap_or(&[]);
192        let backend = if magic == b"UMLP" {
193            RtBackend::Mlp(crate::rt_mlp::RtMlp::load(path)?)
194        } else if magic == b"URTC" {
195            RtBackend::Cnn(crate::rt_cnn::RtCnn::from_bytes(&bytes)?)
196        } else {
197            let model = GBDT::load_model(&path.to_string_lossy())
198                .map_err(|e| anyhow::anyhow!("loading rt model {}: {e}", path.display()))?;
199            RtBackend::Gbdt(model)
200        };
201        if !matches!(&backend, RtBackend::Cnn(_)) {
202            log::warn!(
203                "legacy RT backend: MLP/GBDT loading is deprecated; use a URTC CNN or --rt-library"
204            );
205        }
206        Ok(RtModel {
207            backend,
208            fingerprint,
209        })
210    }
211
212    /// Stable content hash of the model file, used as the prediction-cache key.
213    pub fn fingerprint(&self) -> u64 {
214        self.fingerprint
215    }
216
217    /// Predict abstract RT for one sequence.
218    pub fn predict_seq(&self, seq: &str) -> f64 {
219        match &self.backend {
220            RtBackend::Gbdt(model) => {
221                let data = vec![Data::new_test_data(to_f32(&rt_features(seq)), None)];
222                model.predict(&data)[0] as f64
223            }
224            RtBackend::Mlp(m) => m.predict_seq(seq),
225            RtBackend::Cnn(m) => m.predict_seq(seq),
226        }
227    }
228
229    /// Penultimate-layer embeddings for fine-tuning the readout on-data. Only the
230    /// MLP/CNN backends expose a usable embedding; the gbdt student returns `None`.
231    pub fn embed_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Option<Vec<Vec<f64>>> {
232        match &self.backend {
233            RtBackend::Mlp(m) => Some(m.embed_many(seqs)),
234            RtBackend::Cnn(m) => Some(m.embed_many(seqs)),
235            RtBackend::Gbdt(_) => None,
236        }
237    }
238
239    /// Predict abstract RT AND return the penultimate embedding per sequence in a
240    /// single pass, when the on-data RT fine-tune will consume both. Returns
241    /// `(preds, Some(embeds))` for backends with a usable embedding (MLP/CNN) and
242    /// `(preds, None)` for the gbdt student. Byte-identical to calling
243    /// `predict_many` and `embed_many` separately, but the CNN backend runs its
244    /// forward pass once instead of twice over the same ~1.1M unique peptides.
245    pub fn predict_embed_many<S: AsRef<str> + Sync>(
246        &self,
247        seqs: &[S],
248    ) -> (Vec<f64>, Option<Vec<Vec<f64>>>) {
249        match &self.backend {
250            RtBackend::Cnn(m) => {
251                let (p, e) = m.predict_embed_many(seqs);
252                (p, Some(e))
253            }
254            // MLP: predict and embed remain separate passes (no fused kernel), but
255            // still correct + byte-identical to the old two-call path.
256            RtBackend::Mlp(m) => (m.predict_many(seqs), Some(m.embed_many(seqs))),
257            RtBackend::Gbdt(_) => (self.predict_many(seqs), None),
258        }
259    }
260
261    /// Predict abstract RT for many sequences in one batch.
262    pub fn predict_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Vec<f64> {
263        match &self.backend {
264            RtBackend::Gbdt(model) => {
265                let data: Vec<Data> = seqs
266                    .iter()
267                    .map(|s| Data::new_test_data(to_f32(&rt_features(s.as_ref())), None))
268                    .collect();
269                model.predict(&data).into_iter().map(|v| v as f64).collect()
270            }
271            RtBackend::Mlp(m) => m.predict_many(seqs),
272            RtBackend::Cnn(m) => m.predict_many(seqs),
273        }
274    }
275}
276
277/// FNV-1a over the model bytes. Stable across processes/machines (unlike
278/// `DefaultHasher`, which is randomized), so a cache file written by one run is
279/// keyed identically by the next.
280fn fingerprint_bytes(bytes: &[u8]) -> u64 {
281    let mut h: u64 = 0xcbf29ce484222325;
282    for &b in bytes {
283        h ^= b as u64;
284        h = h.wrapping_mul(0x100000001b3);
285    }
286    h
287}
288
289/// Read a distillation label file: `SEQUENCE\tRT` per line (no header), as
290/// produced by sherlock's DeepLC scripts. Returns (sequences, rts).
291pub fn read_label_tsv(path: impl AsRef<Path>) -> Result<(Vec<String>, Vec<f64>)> {
292    let path = path.as_ref();
293    let mut rdr = csv::ReaderBuilder::new()
294        .delimiter(b'\t')
295        .has_headers(false)
296        .flexible(true)
297        .from_path(path)
298        .with_context(|| format!("opening label tsv {}", path.display()))?;
299
300    let mut seqs = Vec::new();
301    let mut rts = Vec::new();
302    for rec in rdr.records() {
303        let rec = rec?;
304        let seq = match rec.get(0) {
305            Some(s) if !s.is_empty() => s,
306            _ => continue,
307        };
308        let rt: f64 = match rec.get(1).and_then(|s| s.trim().parse().ok()) {
309            Some(v) => v,
310            None => continue,
311        };
312        // skip a possible header row
313        if seq.chars().all(|c| c.is_ascii_uppercase()) {
314            seqs.push(seq.to_string());
315            rts.push(rt);
316        }
317    }
318    Ok((seqs, rts))
319}
320
321#[cfg(test)]
322mod cnn_workflow_tests {
323    use super::*;
324
325    #[test]
326    fn bundled_cnn_is_portable_and_matches_training_package() {
327        assert_eq!(
328            DEFAULT_RT_CNN,
329            include_bytes!("../../uno-rt-train/python/uno_rt/data/default.urtc")
330        );
331        let model = RtModel::bundled().unwrap();
332        let direct = crate::rt_cnn::RtCnn::from_bytes(DEFAULT_RT_CNN).unwrap();
333        assert_eq!(
334            model.predict_seq("PEPTIDEK"),
335            direct.predict_seq("PEPTIDEK")
336        );
337        assert_eq!(model.fingerprint(), fingerprint_bytes(DEFAULT_RT_CNN));
338    }
339
340    #[test]
341    fn cnn_rejects_invalid_dimensions_normalization_and_version() {
342        for (offset, value) in [(4, 99u32), (12, 0), (16, 0), (24, 2), (28, u32::MAX)] {
343            let mut bytes = DEFAULT_RT_CNN.to_vec();
344            bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
345            assert!(crate::rt_cnn::RtCnn::from_bytes(&bytes).is_err());
346        }
347        assert!(crate::rt_cnn::RtCnn::from_bytes(&DEFAULT_RT_CNN[..80]).is_err());
348    }
349}