Skip to main content

salmon_model/
fld.rs

1//! Fragment-length distribution.
2//!
3//! Direct port of salmon's `FragmentLengthDistribution`
4//! (`src/model/FragmentLengthDistribution.cpp`): a log-space histogram seeded
5//! with a Gaussian (or uniform) prior, updated by adding a binomial smoothing
6//! kernel around each observed length. All masses and probabilities are in log
7//! space. Updates are lock-free so worker threads can call [`add_val`] with a
8//! shared reference, matching the C++ design.
9//!
10//! [`add_val`]: FragmentLengthDistribution::add_val
11
12use salmon_core::atomic::AtomicF64;
13use salmon_core::math::{log_add, LOG_0, LOG_EPSILON};
14use statrs::distribution::{Binomial, ContinuousCDF, Discrete, Normal};
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Arc, RwLock};
17
18/// Tracks the observed distribution of fragment lengths.
19#[derive(Debug)]
20pub struct FragmentLengthDistribution {
21    /// logged binomial smoothing kernel
22    kernel: Vec<f64>,
23    /// logged observed mass per length bin
24    hist: Vec<AtomicF64>,
25    /// logged total observed mass (including pseudo-counts)
26    tot_mass: AtomicF64,
27    /// logged sum of length*mass, for fast mean computation
28    sum: AtomicF64,
29    /// minimum observed length (bin units)
30    min: AtomicUsize,
31    /// internal bin size
32    bin_size: usize,
33
34    /// cached normalized PMF, valid once [`cache`](Self::cache) is called
35    cached_pmf: Vec<f64>,
36    /// cached CMF
37    cached_cmf: Vec<f64>,
38    have_cache: bool,
39
40    /// Periodically-refreshed snapshot of the (un-normalized) log-PMF used during
41    /// the *online* phase, indexed by raw length. Reading the live `hist`/`tot_mass`
42    /// directly (two separate atomic loads on a concurrently-updated distribution)
43    /// returns slightly different values for the same length across calls, which
44    /// breaks the weight symmetry of exact-duplicate transcripts and is then
45    /// amplified by the VBEM `α<1` prior. Mirroring C++ salmon (`cachedPMF_` +
46    /// `LogCMFCache`), worker threads instead capture an immutable snapshot of this
47    /// once per fragment ([`online_snapshot`](Self::online_snapshot)) so every
48    /// transcript of a given length in that fragment gets an identical value;
49    /// [`refresh_online`](Self::refresh_online) rebuilds it at mini-batch
50    /// boundaries.
51    online_pmf: RwLock<Arc<Vec<f64>>>,
52
53    /// Periodically-refreshed snapshot of the (normalized) log-CMF, the
54    /// cumulative companion to [`online_pmf`](Self::online_pmf). Used for the
55    /// ambiguous (orphan / single-end) fragment-length probability and for the
56    /// `pmf(flen) − cmf(txpLen)` length-conditioning of proper pairs, both of
57    /// which need cumulative mass. Rebuilt alongside `online_pmf` in
58    /// [`refresh_online`](Self::refresh_online); mirrors C++ salmon's
59    /// `LogCMFCache`.
60    online_cmf: RwLock<Arc<Vec<f64>>>,
61}
62
63impl FragmentLengthDistribution {
64    /// Construct a distribution.
65    ///
66    /// * `alpha` – total pseudo-count mass (linear space).
67    /// * `max_val` – maximum representable length.
68    /// * `prior_mu` – Gaussian prior mean; if `<= 0`, a uniform prior is used.
69    /// * `prior_sigma` – Gaussian prior standard deviation.
70    /// * `kernel_n` – binomial kernel trials; must be even (after binning).
71    /// * `kernel_p` – binomial kernel success probability.
72    /// * `bin_size` – internal length binning (use 1 for no binning).
73    pub fn new(
74        alpha: f64,
75        max_val: usize,
76        prior_mu: f64,
77        prior_sigma: f64,
78        kernel_n: usize,
79        kernel_p: f64,
80        bin_size: usize,
81    ) -> Self {
82        assert!(bin_size >= 1, "bin_size must be >= 1");
83        let max_val = max_val / bin_size;
84        let kernel_n = kernel_n / bin_size;
85        assert!(
86            kernel_n.is_multiple_of(2),
87            "kernel_n must be even after binning"
88        );
89
90        let tot = alpha.ln();
91        let hist: Vec<AtomicF64>;
92        let mut sum = LOG_0;
93        let mut tot_mass;
94
95        if prior_mu > 0.0 {
96            let norm = Normal::new(
97                prior_mu / bin_size as f64,
98                prior_sigma / (bin_size * bin_size) as f64,
99            )
100            .expect("valid normal prior");
101            hist = (0..=max_val).map(|_| AtomicF64::new(LOG_0)).collect();
102            tot_mass = LOG_0;
103            for (i, slot) in hist.iter().enumerate() {
104                let norm_mass = norm.cdf(i as f64 + 0.5) - norm.cdf(i as f64 - 0.5);
105                let mass = if norm_mass != 0.0 {
106                    tot + norm_mass.ln()
107                } else {
108                    LOG_EPSILON
109                };
110                slot.store(mass);
111                sum = log_add(sum, (i as f64).ln() + mass);
112                tot_mass = log_add(tot_mass, mass);
113            }
114        } else {
115            // uniform prior
116            let per = tot - (max_val as f64).ln();
117            hist = (0..=max_val).map(|_| AtomicF64::new(per)).collect();
118            hist[0].store(LOG_0);
119            let h1 = hist.get(1).map(|a| a.load()).unwrap_or(per);
120            sum = h1 + ((max_val * (max_val + 1)) as f64).ln() - 2.0_f64.ln();
121            tot_mass = tot;
122        }
123
124        // binomial smoothing kernel
125        let binom = Binomial::new(kernel_p, kernel_n as u64).expect("valid binomial kernel");
126        let kernel: Vec<f64> = (0..=kernel_n).map(|i| binom.pmf(i as u64).ln()).collect();
127
128        Self {
129            kernel,
130            hist,
131            tot_mass: AtomicF64::new(tot_mass),
132            sum: AtomicF64::new(sum),
133            min: AtomicUsize::new(max_val),
134            bin_size,
135            cached_pmf: Vec::new(),
136            cached_cmf: Vec::new(),
137            have_cache: false,
138            online_pmf: RwLock::new(Arc::new(Vec::new())),
139            online_cmf: RwLock::new(Arc::new(Vec::new())),
140        }
141    }
142
143    /// salmon's default fragment-length distribution: pseudo-count 1.0, max
144    /// length 1000, no Gaussian prior (uniform), kernel `n=4, p=0.5`.
145    pub fn default_for_paired() -> Self {
146        Self::new(1.0, 1000, 0.0, 0.0, 4, 0.5, 1)
147    }
148
149    pub fn max_val(&self) -> usize {
150        (self.hist.len() - 1) * self.bin_size
151    }
152
153    pub fn min_val(&self) -> usize {
154        let m = self.min.load(Ordering::Relaxed);
155        if m == self.hist.len() - 1 {
156            1
157        } else {
158            m
159        }
160    }
161
162    /// Add `mass` (log space) for an observed fragment of length `len`,
163    /// spreading it over the smoothing kernel. Lock-free; safe to call from
164    /// multiple threads. (Must not race with [`cache`](Self::cache).)
165    pub fn add_val(&self, len: usize, mass: f64) {
166        let mut len = len / self.bin_size;
167        let max_v = self.max_val() / self.bin_size;
168        if len > max_v {
169            len = max_v;
170        }
171        self.min.fetch_min(len, Ordering::Relaxed);
172
173        let half = self.kernel.len() / 2;
174        // offset can go negative conceptually; use isize math then bound-check.
175        let mut offset = len as isize - half as isize;
176        for &k in &self.kernel {
177            if offset > 0 && (offset as usize) < self.hist.len() {
178                let o = offset as usize;
179                let k_mass = mass + k;
180                self.hist[o].log_add_assign(k_mass);
181                self.sum.log_add_assign((o as f64).ln() + k_mass);
182                self.tot_mass.log_add_assign(k_mass);
183            }
184            offset += 1;
185        }
186    }
187
188    /// Logged probability of observing a fragment of length `len`.
189    pub fn pmf(&self, len: usize) -> f64 {
190        if self.have_cache {
191            return *self
192                .cached_pmf
193                .get(len)
194                .unwrap_or_else(|| self.cached_pmf.last().unwrap());
195        }
196        let mut l = len / self.bin_size;
197        let max_v = self.max_val() / self.bin_size;
198        if l > max_v {
199            l = max_v;
200        }
201        self.hist[l].load() - self.tot_mass.load()
202    }
203
204    /// Rebuild the online log-PMF snapshot from the current histogram (one pass
205    /// over the length bins, with a single `tot_mass` read so the snapshot is
206    /// internally consistent). Call at mini-batch boundaries during the online
207    /// phase; no-op once the final [`cache`](Self::cache) has been taken. Cheap
208    /// relative to mapping a batch, and decouples per-fragment reads from the
209    /// concurrent `add_val` writes so identical lengths read identical values.
210    pub fn refresh_online(&self) {
211        if self.have_cache {
212            return;
213        }
214        let max_raw = self.max_val();
215        let max_v = max_raw / self.bin_size;
216        let tot = self.tot_mass.load();
217        // Per-bin cumulative mass (matches `cmf()`), so the snapshot CMF at raw
218        // index `raw` equals `cmf(raw)`. Built first, then both the PMF and CMF
219        // snapshots are expanded over raw indices from the same `tot` read so
220        // they are mutually consistent.
221        let mut bin_cum = Vec::with_capacity(max_v + 1);
222        let mut cum = LOG_0;
223        for b in 0..=max_v {
224            cum = log_add(cum, self.hist[b].load() - tot);
225            bin_cum.push(cum);
226        }
227        let mut v = Vec::with_capacity(max_raw + 1);
228        let mut c = Vec::with_capacity(max_raw + 1);
229        for raw in 0..=max_raw {
230            let l = (raw / self.bin_size).min(max_v);
231            v.push(self.hist[l].load() - tot);
232            c.push(bin_cum[l]);
233        }
234        *self.online_pmf.write().unwrap() = Arc::new(v);
235        *self.online_cmf.write().unwrap() = Arc::new(c);
236    }
237
238    /// Cheap (one `Arc` clone) immutable handle to the current online log-PMF
239    /// snapshot. Capture once per fragment and index by raw length: every
240    /// transcript of a given length then reads an identical value even if another
241    /// thread refreshes the shared snapshot meanwhile. Empty until the first
242    /// [`refresh_online`](Self::refresh_online) (the pre-burn-in window, where this
243    /// term is not folded into the eq-class weight anyway).
244    pub fn online_snapshot(&self) -> Arc<Vec<f64>> {
245        self.online_pmf.read().unwrap().clone()
246    }
247
248    /// Cheap (one `Arc` clone) immutable handle to the current online log-CMF
249    /// snapshot, the cumulative companion to [`online_snapshot`](Self::online_snapshot).
250    /// Capture once per fragment for the ambiguous (orphan / single-end)
251    /// fragment-length probability and the proper-pair length-conditioning.
252    /// Empty until the first [`refresh_online`](Self::refresh_online).
253    pub fn online_cmf_snapshot(&self) -> Arc<Vec<f64>> {
254        self.online_cmf.read().unwrap().clone()
255    }
256
257    /// Logged cumulative mass up to and including `len`.
258    pub fn cmf(&self, len: usize) -> f64 {
259        if self.have_cache {
260            return *self
261                .cached_cmf
262                .get(len)
263                .unwrap_or_else(|| self.cached_cmf.last().unwrap());
264        }
265        let mut l = len / self.bin_size;
266        let max_v = self.max_val() / self.bin_size;
267        if l > max_v {
268            l = max_v;
269        }
270        let mut cum = LOG_0;
271        for i in 0..=l {
272            cum = log_add(cum, self.hist[i].load());
273        }
274        cum - self.tot_mass.load()
275    }
276
277    /// Total observed mass (log space).
278    pub fn tot_mass(&self) -> f64 {
279        self.tot_mass.load()
280    }
281
282    /// Mean observed length.
283    pub fn mean(&self) -> f64 {
284        (self.sum.load() - self.tot_mass.load()).exp()
285    }
286
287    /// Standard deviation of the observed length distribution, computed from the
288    /// cached normalized PMF (call after [`cache`](Self::cache)).
289    pub fn sd(&self) -> f64 {
290        let lp = self.log_pmf();
291        if lp.is_empty() {
292            return 0.0;
293        }
294        let mut mean = 0.0;
295        for (l, &p) in lp.iter().enumerate() {
296            mean += (l as f64) * p.exp();
297        }
298        let mut var = 0.0;
299        for (l, &p) in lp.iter().enumerate() {
300            let d = l as f64 - mean;
301            var += d * d * p.exp();
302        }
303        var.max(0.0).sqrt()
304    }
305
306    /// Freeze the distribution and precompute normalized PMF/CMF for fast,
307    /// allocation-free lookup. Call once after updates have stopped.
308    pub fn cache(&mut self) {
309        if self.have_cache {
310            return;
311        }
312        let max_v = self.max_val();
313        // normalized PMF over [0, max_v]
314        let mut pmf = Vec::with_capacity(max_v + 1);
315        let mut tot = LOG_0;
316        for i in 0..=max_v {
317            let p = self.pmf(i);
318            pmf.push(p);
319            tot = log_add(tot, p);
320        }
321        for p in &mut pmf {
322            *p -= tot;
323        }
324        // CMF from the normalized PMF
325        let mut cmf = Vec::with_capacity(pmf.len());
326        let mut cum = LOG_0;
327        for &p in &pmf {
328            cum = log_add(cum, p);
329            cmf.push(cum);
330        }
331        self.cached_pmf = pmf;
332        self.cached_cmf = cmf;
333        self.have_cache = true;
334    }
335
336    /// The cached, normalized log-PMF over `[0, max_val]`. Requires [`cache`](Self::cache).
337    pub fn log_pmf(&self) -> &[f64] {
338        debug_assert!(self.have_cache, "call cache() before log_pmf()");
339        &self.cached_pmf
340    }
341
342    /// Cumulative conditional means `E[L | L ≤ i]` over `[0, max_val]`, i.e.
343    /// salmon's `correctionFactorsFromMass` (`DistributionUtils.cpp`):
344    /// `cm[i] = (Σ_{l≤i} l·pmf[l]) / (Σ_{l≤i} pmf[l])`.
345    ///
346    /// These are the per-length correction factors `computeSmoothedEffectiveLengths`
347    /// subtracts from the reference length to get the base effective length. The
348    /// ratio is invariant to the PMF normalization, so the cached (normalized) PMF
349    /// gives the same values as salmon's `100·exp(logPMF)` mass. Requires
350    /// [`cache`](Self::cache).
351    pub fn conditional_means(&self) -> Vec<f64> {
352        debug_assert!(self.have_cache, "call cache() before conditional_means()");
353        let n = self.cached_pmf.len();
354        let mut cms = vec![0.0f64; n];
355        let mut vals = 0.0; // Σ l·pmf[l]
356        let mut mult = 0.0; // Σ pmf[l]
357        for i in 0..n {
358            let p = self.cached_pmf[i].exp();
359            vals += (i as f64) * p;
360            mult += p;
361            cms[i] = if mult > 0.0 { vals / mult } else { 0.0 };
362        }
363        cms
364    }
365}
366
367/// Index a length into a (log) CMF snapshot, clamping out-of-range lengths to
368/// the last bin (which holds the total mass). Returns [`LOG_0`] for an empty
369/// snapshot.
370#[inline]
371fn cmf_at(cmf: &[f64], len: i32) -> f64 {
372    if cmf.is_empty() {
373        return LOG_0;
374    }
375    let i = (len.max(0) as usize).min(cmf.len() - 1);
376    cmf[i]
377}
378
379/// Logged ambiguous-fragment-length probability for an orphan / single-end
380/// read, given a (log) CMF snapshot. Direct port of C++ salmon's
381/// `LogCMFCache::getAmbigFragLengthProb` (`DistributionUtils.cpp`).
382///
383/// The mapped mate bounds the maximum possible fragment length: a forward read
384/// at `pos` can extend downstream to the transcript 3' end (`txp_len − pos`); a
385/// reverse read's outer (5') end sits at `pos + read_len`, bounding the upstream
386/// extent toward the 5' end. The weight is the FLD mass up to that bound,
387/// *conditioned* on the mass up to the full transcript length — i.e.
388/// `cmf(maxFragLen) − cmf(txpLen)` — so orphan weights sit on the same
389/// length-conditioned scale as proper pairs. Returns [`LOG_EPSILON`] when the
390/// transcript admits no representable fragment mass, and `LOG_1` (= 0) when no
391/// snapshot is available yet (pre-burn-in), leaving the weight unmodelled.
392pub fn ambig_frag_log_prob(cmf: &[f64], fwd: bool, pos: i32, read_len: i32, txp_len: i32) -> f64 {
393    if cmf.is_empty() {
394        return 0.0; // LOG_1: no model yet
395    }
396    let stxp = txp_len.max(0);
397    let max_frag_len = if fwd {
398        stxp - pos.clamp(0, stxp)
399    } else {
400        (pos + read_len).clamp(0, stxp)
401    };
402    let ref_cm = cmf_at(cmf, stxp);
403    if ref_cm <= LOG_0 {
404        return LOG_EPSILON;
405    }
406    cmf_at(cmf, max_frag_len) - ref_cm
407}
408
409/// salmon's base effective length (`computeSmoothedEffectiveLengths`):
410/// `effLen = refLen − E[L | L ≤ refLen]`, clamped back to `refLen` if it would
411/// fall below 1. `cond_means` is [`FragmentLengthDistribution::conditional_means`].
412///
413/// This replaces the truncated-PMF `Σ pmf(l)·(refLen−l+1)` estimate (which falls
414/// back to the raw `refLen` for any transcript shorter than the FLD mean), matching
415/// salmon's behaviour exactly.
416pub fn smoothed_effective_length(cond_means: &[f64], ref_len: usize) -> f64 {
417    if cond_means.is_empty() {
418        return ref_len as f64;
419    }
420    let max_len = cond_means.len();
421    let cf = if ref_len >= max_len {
422        cond_means[max_len - 1]
423    } else {
424        cond_means[ref_len]
425    };
426    let eff = ref_len as f64 - cf;
427    if eff < 1.0 {
428        ref_len as f64
429    } else {
430        eff
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn uniform_prior_pmf_normalizes() {
440        let mut fld = FragmentLengthDistribution::new(1.0, 200, 0.0, 0.0, 4, 0.5, 1);
441        fld.cache();
442        let total: f64 = fld.log_pmf().iter().map(|p| p.exp()).sum();
443        assert!((total - 1.0).abs() < 1e-9, "pmf sums to {total}");
444    }
445
446    #[test]
447    fn gaussian_prior_mean_is_near_mu() {
448        let fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
449        let m = fld.mean();
450        assert!((m - 250.0).abs() < 5.0, "mean {m} not near 250");
451    }
452
453    #[test]
454    fn observations_shift_the_distribution() {
455        let mut fld = FragmentLengthDistribution::new(1.0, 1000, 250.0, 25.0, 4, 0.5, 1);
456        // pile observations around 400
457        for _ in 0..100_000 {
458            fld.add_val(400, 0.0); // mass = log(1) = 0
459        }
460        let m = fld.mean();
461        assert!(m > 300.0, "mean {m} did not move toward 400");
462        fld.cache();
463        // length 400 should be among the most probable
464        let p400 = fld.pmf(400);
465        let p250 = fld.pmf(250);
466        assert!(p400 > p250, "p(400)={p400} not > p(250)={p250}");
467    }
468
469    #[test]
470    fn smoothed_efflen_shrinks_short_transcripts() {
471        // Gaussian prior mean 250: a transcript far shorter than the mean should
472        // get a heavily shrunk effective length (NOT the raw refLen the old
473        // truncated-PMF estimate fell back to).
474        let mut fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
475        fld.cache();
476        let cm = fld.conditional_means();
477        // conditional means are non-decreasing
478        for w in cm.windows(2) {
479            assert!(
480                w[1] >= w[0] - 1e-9,
481                "cond means not monotonic: {} < {}",
482                w[1],
483                w[0]
484            );
485        }
486        let short = smoothed_effective_length(&cm, 201);
487        assert!(
488            short < 201.0 && short > 1.0,
489            "short effLen {short} not shrunk"
490        );
491        // a long transcript keeps most of its length
492        let long = smoothed_effective_length(&cm, 5000);
493        assert!(long > 4000.0, "long effLen {long} shrunk too much");
494        // below the 1.0 barrier the raw length is returned
495        let tiny = smoothed_effective_length(&cm, 2);
496        assert_eq!(tiny, 2.0, "tiny transcript should fall back to refLen");
497    }
498
499    #[test]
500    fn ambig_frag_prob_bounds_and_orientation() {
501        let mut fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
502        fld.cache();
503        // Use the cached (frozen) CMF as a stand-in for the online snapshot.
504        let cmf = fld.cached_cmf.clone();
505        let txp_len = 2000i32;
506        // A forward read with ample downstream space (mate fits at the typical
507        // insert) should be near log(1) ≈ 0, since cmf(maxFrag) ≈ cmf(txpLen).
508        let ample = ambig_frag_log_prob(&cmf, true, 100, 75, txp_len);
509        assert!(ample > -0.01, "ample-space orphan logProb {ample} not ~0");
510        // A forward read crammed against the 3' end (little downstream space)
511        // implies an implausibly short fragment -> much smaller probability.
512        let crammed = ambig_frag_log_prob(&cmf, true, txp_len - 50, 75, txp_len);
513        assert!(
514            crammed < ample - 1.0,
515            "crammed orphan {crammed} not << ample {ample}"
516        );
517        // Reverse-strand orientation uses pos + read_len for the upstream bound:
518        // a reverse read whose outer end is near the 5' start is likewise crammed.
519        let rc_crammed = ambig_frag_log_prob(&cmf, false, 0, 50, txp_len);
520        assert!(
521            rc_crammed < ample - 1.0,
522            "rc crammed orphan {rc_crammed} not << ample {ample}"
523        );
524        // Empty snapshot -> unmodelled (LOG_1 = 0).
525        assert_eq!(ambig_frag_log_prob(&[], true, 100, 75, txp_len), 0.0);
526    }
527
528    #[test]
529    fn cmf_is_monotonic() {
530        let mut fld = FragmentLengthDistribution::new(1000.0, 500, 200.0, 30.0, 4, 0.5, 1);
531        fld.cache();
532        let mut prev = f64::NEG_INFINITY;
533        for l in 0..=500 {
534            let c = fld.cmf(l);
535            assert!(c >= prev - 1e-9, "cmf decreased at {l}: {c} < {prev}");
536            prev = c;
537        }
538        assert!((prev - 0.0).abs() < 1e-6, "cmf endpoint {prev} != log(1)");
539    }
540}