Skip to main content

uno_core/
im.rs

1//! Charge-aware IM prediction with the shared RT sequence CNN or legacy tree models.
2use crate::rt::rt_features;
3use anyhow::Result;
4use gbdt::decision_tree::Data;
5use gbdt::gradient_boost::GBDT;
6use std::path::Path;
7
8/// Portable sequence CNN, trained on public measured TIMS labels.
9pub const DEFAULT_IM_CNN: &[u8] = include_bytes!("../models/im_cnn_public_20260914.uimc");
10
11/// Tree-model inputs: RT composition descriptors plus charge.
12pub const IM_N_FEATURES: usize = crate::rt::RT_N_FEATURES + 1;
13
14pub fn im_features(seq: &str, charge: u8) -> Vec<f64> {
15    let mut v = rt_features(seq);
16    v.push(charge as f64);
17    v
18}
19
20fn to_f32(v: &[f64]) -> Vec<f32> {
21    v.iter().map(|&x| x as f32).collect()
22}
23
24/// Supported sequence CNN and legacy tree prediction backends.
25enum ImBackend {
26    Cnn(crate::rt_cnn::RtCnn),
27    Gbdt(GBDT),
28    Sequoia(Box<sequoia_boost::prelude::BoostedModel>),
29}
30
31pub struct ImModel {
32    backend: ImBackend,
33    fingerprint: String,
34    cache: std::sync::Mutex<crate::im_cache::PredictionCache>,
35}
36
37impl ImModel {
38    fn from_backend(backend: ImBackend, bytes: &[u8]) -> Self {
39        use sha2::{Digest, Sha256};
40        let mut hash = Sha256::new();
41        // Bump this namespace if encoding/inference semantics change.
42        hash.update(b"uno-im-prediction-v1\0");
43        hash.update(bytes);
44        let fingerprint = format!("{:x}", hash.finalize());
45        let cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::new(&fingerprint));
46        Self {
47            backend,
48            fingerprint,
49            cache,
50        }
51    }
52
53    pub fn fingerprint(&self) -> &str {
54        &self.fingerprint
55    }
56
57    /// The default charge-aware model needs no model path or Python runtime.
58    pub fn bundled() -> Result<Self> {
59        Ok(Self::from_backend(
60            ImBackend::Cnn(crate::rt_cnn::RtCnn::from_im_bytes(DEFAULT_IM_CNN)?),
61            DEFAULT_IM_CNN,
62        ))
63    }
64
65    /// Load by content: UIMC sequence CNN, sequoia tree, or legacy gbdt.
66    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
67        let path = path.as_ref();
68        let bytes = std::fs::read(path)?;
69        if bytes.starts_with(b"UIMC") {
70            return Ok(Self::from_backend(
71                ImBackend::Cnn(crate::rt_cnn::RtCnn::from_im_bytes(&bytes)?),
72                &bytes,
73            ));
74        }
75        if let Ok(m) = sequoia_boost::prelude::BoostedModel::load_binary(path) {
76            anyhow::ensure!(m.n_features() == IM_N_FEATURES,
77                "IM model expects {} features, current encoder supplies {}. Retrain with the current encoder; do not silently reinterpret an old schema.", m.n_features(), IM_N_FEATURES);
78            return Ok(Self::from_backend(ImBackend::Sequoia(Box::new(m)), &bytes));
79        }
80        let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
81        let width = metadata["conf"]["feature_size"]
82            .as_u64()
83            .ok_or_else(|| anyhow::anyhow!("IM GBDT has no feature-size metadata"))?
84            as usize;
85        anyhow::ensure!(width == IM_N_FEATURES,
86            "IM model expects {} features, current encoder supplies {}. Retrain with the current encoder; do not silently reinterpret an old schema.", width, IM_N_FEATURES);
87        let model = GBDT::load_model(&path.to_string_lossy())
88            .map_err(|e| anyhow::anyhow!("loading im model {}: {e}", path.display()))?;
89        Ok(Self::from_backend(ImBackend::Gbdt(model), &bytes))
90    }
91
92    pub fn cnn_info(&self) -> Option<&serde_json::Value> {
93        match &self.backend {
94            ImBackend::Cnn(m) => Some(m.info()),
95            _ => None,
96        }
97    }
98
99    /// Raw predictions shared across calibration attempts and search runs.
100    pub fn predict_many_cached(&self, items: &[(&str, u8)]) -> Vec<f64> {
101        let t = std::time::Instant::now();
102        let (values, misses) = self
103            .cache
104            .lock()
105            .unwrap()
106            .predict(items, |q| self.predict_many(q));
107        log::info!(
108            "IM predictions: {} unique misses of {} requested ({:.3}s)",
109            misses,
110            items.len(),
111            t.elapsed().as_secs_f64()
112        );
113        values
114    }
115
116    /// Prime every target/decoy library sequence at the supplied observed charges.
117    /// Bounded query batches avoid materializing the full Cartesian product.
118    pub fn precompute_library(&self, seqs: &[&str], charges: &[u8]) {
119        if seqs.is_empty() || charges.is_empty() {
120            return;
121        }
122        let t = std::time::Instant::now();
123        let mut cache = self.cache.lock().unwrap();
124        // Disabled means the original uncached, on-demand path, including no disk IO.
125        if !cache.enabled {
126            return;
127        }
128        let mut seqs = seqs.to_vec();
129        seqs.sort_unstable();
130        seqs.dedup();
131        let mut charges = charges.to_vec();
132        charges.retain(|&z| z > 0);
133        charges.sort_unstable();
134        charges.dedup();
135        let mut misses = 0;
136        for &z in &charges {
137            for chunk in seqs.chunks(crate::im_cache::BATCH_SIZE) {
138                let queries: Vec<_> = chunk.iter().map(|&s| (s, z)).collect();
139                misses += cache.predict(&queries, |q| self.predict_many(q)).1;
140            }
141        }
142        log::info!(
143            "IM library cache: {} sequences x {} charges = {} pairs; {} unique misses ({:.3}s)",
144            seqs.len(),
145            charges.len(),
146            seqs.len() * charges.len(),
147            misses,
148            t.elapsed().as_secs_f64()
149        );
150    }
151
152    /// Predict each distinct sequence/charge once. Bound temporary feature
153    /// matrices so a large PFM list cannot allocate several gigabytes of input.
154    pub fn predict_many(&self, items: &[(&str, u8)]) -> Vec<f64> {
155        let mut lookup = std::collections::HashMap::new();
156        let mut unique = Vec::new();
157        let mut indices = Vec::with_capacity(items.len());
158        for &item in items {
159            let idx = *lookup.entry(item).or_insert_with(|| {
160                unique.push(item);
161                unique.len() - 1
162            });
163            indices.push(idx);
164        }
165        let mut predictions = Vec::with_capacity(unique.len());
166        for chunk in unique.chunks(16_384) {
167            predictions.extend(self.predict_distinct(chunk));
168        }
169        indices.into_iter().map(|i| predictions[i]).collect()
170    }
171
172    fn predict_distinct(&self, items: &[(&str, u8)]) -> Vec<f64> {
173        match &self.backend {
174            ImBackend::Cnn(model) => model.predict_im_many(items),
175            ImBackend::Gbdt(model) => {
176                let data: Vec<Data> = items
177                    .iter()
178                    .map(|&(s, z)| Data::new_test_data(to_f32(&im_features(s, z)), None))
179                    .collect();
180                model.predict(&data).into_iter().map(|v| v as f64).collect()
181            }
182            ImBackend::Sequoia(model) => {
183                use sequoia_boost::prelude::*;
184                let mut flat = Vec::with_capacity(items.len() * IM_N_FEATURES);
185                for &(s, z) in items {
186                    flat.extend_from_slice(&to_f32(&im_features(s, z)));
187                }
188                let matrix = DMatrix::from_dense(&flat, items.len(), IM_N_FEATURES)
189                    .expect("validated IM feature matrix dimensions");
190                model
191                    .predict(&matrix)
192                    .expect("validated IM model dimensions")
193                    .into_iter()
194                    .map(|v| v as f64)
195                    .collect()
196            }
197        }
198    }
199}
200
201/// Read an im2deep `-i` output CSV: `modified_seq,charge,predicted IM`, where
202/// modified_seq is `SEQUENCE/charge`. Returns (stripped_seq, charge, im).
203pub fn read_im2deep_csv(path: impl AsRef<Path>) -> Result<(Vec<String>, Vec<u8>, Vec<f64>)> {
204    let mut rdr = csv::ReaderBuilder::new()
205        .has_headers(true)
206        .flexible(true)
207        .from_path(path.as_ref())?;
208    let (mut seqs, mut charges, mut ims) = (Vec::new(), Vec::new(), Vec::new());
209    for rec in rdr.records() {
210        let rec = rec?;
211        let modseq = match rec.get(0) {
212            Some(s) => s,
213            None => continue,
214        };
215        let seq = modseq.split('/').next().unwrap_or("").to_string();
216        let charge: u8 = match rec.get(1).and_then(|s| s.trim().parse().ok()) {
217            Some(z) => z,
218            None => continue,
219        };
220        let im: f64 = match rec.get(2).and_then(|s| s.trim().parse().ok()) {
221            Some(v) => v,
222            None => continue,
223        };
224        if seq.is_empty() || !seq.bytes().all(|b| b.is_ascii_uppercase()) {
225            continue;
226        }
227        seqs.push(seq);
228        charges.push(charge);
229        ims.push(im);
230    }
231    Ok((seqs, charges, ims))
232}
233
234#[cfg(test)]
235mod schema_tests {
236    use super::*;
237    #[test]
238    fn incompatible_legacy_schema_is_rejected_before_prediction() {
239        let p = std::env::temp_dir().join(format!("uno-im-schema-{}.json", std::process::id()));
240        std::fs::write(&p, r#"{"conf":{"feature_size":70}}"#).unwrap();
241        let err = ImModel::load(&p)
242            .err()
243            .expect("old feature width must fail");
244        std::fs::remove_file(p).unwrap();
245        assert!(err
246            .to_string()
247            .contains("expects 70 features, current encoder supplies 100"));
248    }
249    #[test]
250    fn incompatible_sequoia_schema_is_rejected_at_load() {
251        use sequoia_boost::prelude::*;
252        let d = DMatrix::from_dense(&[0.0, 1.0, 2.0, 3.0], 4, 1)
253            .unwrap()
254            .with_labels(&[0.0, 1.0, 2.0, 3.0])
255            .unwrap();
256        let params = TrainingParams::builder()
257            .objective("reg:squarederror")
258            .build()
259            .unwrap();
260        let model = train(&params, &d, 1).unwrap();
261        let p = std::env::temp_dir().join(format!("uno-im-schema-{}.sequoia", std::process::id()));
262        model.save_binary(&p).unwrap();
263        let err = ImModel::load(&p)
264            .err()
265            .expect("wrong feature width must fail");
266        std::fs::remove_file(p).unwrap();
267        assert!(err
268            .to_string()
269            .contains("expects 1 features, current encoder supplies 100"));
270    }
271    #[test]
272    fn charge_is_last_and_sequence_descriptors_are_charge_independent() {
273        let a = im_features("PEPTIDEK", 2);
274        let b = im_features("PEPTIDEK", 3);
275        assert_eq!(a.len(), IM_N_FEATURES);
276        assert_eq!(&a[..IM_N_FEATURES - 1], &b[..IM_N_FEATURES - 1]);
277        assert_eq!(a[IM_N_FEATURES - 1], 2.0);
278        assert_eq!(b[IM_N_FEATURES - 1], 3.0);
279    }
280}
281
282/// Label-free per-charge calibration on the RT shortlist, treating targets and
283/// decoys identically. Missing/invalid IM never triggers prediction or rejection.
284/// The relative residual is a classifier input, never an acceptance threshold.
285pub fn soft_residuals(model: &ImModel, items: &[(&str, u8, Option<f64>)]) -> Vec<Option<f64>> {
286    let valid: Vec<usize> = items
287        .iter()
288        .enumerate()
289        .filter_map(|(i, &(s, z, o))| {
290            (z > 0 && !s.is_empty() && o.is_some_and(|v| v.is_finite() && v > 0.0)).then_some(i)
291        })
292        .collect();
293    let queries: Vec<(&str, u8)> = valid.iter().map(|&i| (items[i].0, items[i].1)).collect();
294    let predictions = model.predict_many_cached(&queries);
295    let observed: Vec<(u8, f64, f64)> = valid
296        .iter()
297        .zip(&predictions)
298        .map(|(&i, &p)| (items[i].1, items[i].2.unwrap(), p))
299        .collect();
300    let scaled = calibrated_residuals(&observed);
301    let mut result = vec![None; items.len()];
302    for (&i, r) in valid.iter().zip(scaled) {
303        result[i] = r;
304    }
305    let unique = queries
306        .iter()
307        .collect::<std::collections::HashSet<_>>()
308        .len();
309    log::info!("IM soft feature: {} unique sequence/charge predictions for {} RT-shortlisted candidates; {} calibrated, no IM rejection",
310        unique, items.len(), result.iter().filter(|r| r.is_some()).count());
311    result
312}
313
314fn calibrated_residuals(items: &[(u8, f64, f64)]) -> Vec<Option<f64>> {
315    let mut ratios: std::collections::BTreeMap<u8, Vec<f64>> = Default::default();
316    for &(z, o, p) in items {
317        if z > 0 && o.is_finite() && o > 0.0 && p.is_finite() && p > 0.0 {
318            ratios.entry(z).or_default().push(o / p);
319        }
320    }
321    let scales: std::collections::BTreeMap<u8, f64> = ratios
322        .into_iter()
323        .filter_map(|(z, mut v)| {
324            // Do not infer a charge calibration from a handful of candidates.
325            if v.len() < 20 {
326                return None;
327            }
328            v.sort_by(f64::total_cmp);
329            let n = v.len();
330            let median = (v[(n - 1) / 2] + v[n / 2]) / 2.0;
331            (median.is_finite() && median > 0.0).then_some((z, median))
332        })
333        .collect();
334    items
335        .iter()
336        .map(|&(z, o, p)| {
337            if !(o.is_finite() && o > 0.0 && p.is_finite() && p > 0.0) {
338                return None;
339            }
340            scales.get(&z).and_then(|s| {
341                let residual = (o - p * s) / o;
342                residual.is_finite().then_some(residual)
343            })
344        })
345        .collect()
346}
347
348#[cfg(test)]
349mod soft_tests {
350    use super::*;
351    #[test]
352    fn full_library_cache_preserves_predictions_and_recalibrates_each_shortlist() {
353        let dir = std::env::temp_dir().join(format!(
354            "uno-im-library-{}-{}",
355            std::process::id(),
356            std::time::SystemTime::now()
357                .duration_since(std::time::UNIX_EPOCH)
358                .unwrap()
359                .as_nanos()
360        ));
361        let mut model = ImModel::bundled().unwrap();
362        let path = dir.join(model.fingerprint());
363        model.cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::isolated(
364            path.clone(),
365            true,
366        ));
367        let queries = [
368            ("PEPTIDEK", 2),
369            ("AAAAAAAR", 3),
370            ("PEPTIDEK", 3),
371            ("AAAAAAAR", 2),
372        ];
373        let direct = model.predict_many(&queries);
374        model.precompute_library(&["PEPTIDEK", "AAAAAAAR", "PEPTIDEK"], &[2, 3, 2, 0]);
375        let bytes = crate::im_cache::tests::disk_snapshot(&path);
376        // A backend that cannot infer proves both memory and fresh-model disk hits.
377        model.backend = ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new()));
378        let cached = model.predict_many_cached(&queries);
379        assert_eq!(
380            cached.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
381            direct.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
382        );
383        let mut reloaded = ImModel::bundled().unwrap();
384        reloaded.cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::isolated(
385            path.clone(),
386            true,
387        ));
388        reloaded.backend = ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new()));
389        assert_eq!(reloaded.predict_many_cached(&queries), direct);
390        for scale in [2.0, 3.0] {
391            let rows: Vec<_> = (0..30)
392                .flat_map(|_| {
393                    queries
394                        .iter()
395                        .zip(&direct)
396                        .map(|(&(s, z), &p)| (s, z, Some(p * scale)))
397                })
398                .collect();
399            let expected: Vec<_> = (0..30)
400                .flat_map(|_| {
401                    queries
402                        .iter()
403                        .zip(&direct)
404                        .map(|(&(_, z), &p)| (z, p * scale, p))
405                })
406                .collect();
407            assert_eq!(
408                soft_residuals(&reloaded, &rows),
409                calibrated_residuals(&expected)
410            );
411        }
412        assert_eq!(crate::im_cache::tests::disk_snapshot(&path), bytes);
413        std::fs::remove_dir_all(dir).unwrap();
414    }
415
416    #[test]
417    fn model_content_controls_cache_identity() {
418        let a = ImModel::bundled().unwrap();
419        let same = ImModel::bundled().unwrap();
420        assert_eq!(a.fingerprint(), same.fingerprint());
421        let different = ImModel::from_backend(
422            ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new())),
423            b"different-weights",
424        );
425        assert_ne!(a.fingerprint(), different.fingerprint());
426    }
427
428    #[test]
429    fn bundled_cnn_is_portable_and_charge_aware() {
430        let m = ImModel::bundled().unwrap();
431        assert_eq!(m.cnn_info().unwrap()["format"], "UIMC");
432        let p = m.predict_many(&[("PEPTIDEK", 2), ("PEPTIDEK", 3), ("PEPTIDEK", 2)]);
433        assert!(p.iter().all(|v| v.is_finite() && *v > 0.0));
434        assert_eq!(p[0], p[2]);
435        assert!((p[0] - p[1]).abs() > 0.01);
436    }
437    #[test]
438    fn missing_im_never_invokes_the_untrained_predictor() {
439        let model = ImModel::from_backend(
440            ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new())),
441            b"untrained-test",
442        );
443        let rows = [
444            ("PEPTIDEK", 2, None),
445            ("PEPTIDER", 3, Some(f64::NAN)),
446            ("AAAAK", 2, Some(0.0)),
447        ];
448        assert_eq!(soft_residuals(&model, &rows), vec![None, None, None]);
449    }
450    #[test]
451    fn calibration_is_charge_specific_and_permutation_equivariant() {
452        let mut items = vec![(2, 2.0, 1.0); 30];
453        items.extend(vec![(3, 3.0, 1.0); 30]);
454        items.extend([
455            (2, 4.0, 1.0),
456            (4, 1.0, 1.0),
457            (2, f64::NAN, 1.0),
458            (2, 2.0, -1.0),
459        ]);
460        let r = calibrated_residuals(&items);
461        assert_eq!(r[0], Some(0.0));
462        assert_eq!(r[30], Some(0.0));
463        assert_eq!(r[60], Some(0.5));
464        assert_eq!(&r[61..], &[None, None, None]);
465        items.reverse();
466        let mut reverse = calibrated_residuals(&items);
467        reverse.reverse();
468        assert_eq!(r, reverse);
469    }
470}