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};
16
17/// Tracks the observed distribution of fragment lengths.
18#[derive(Debug)]
19pub struct FragmentLengthDistribution {
20    /// logged binomial smoothing kernel
21    kernel: Vec<f64>,
22    /// logged observed mass per length bin
23    hist: Vec<AtomicF64>,
24    /// logged total observed mass (including pseudo-counts)
25    tot_mass: AtomicF64,
26    /// logged sum of length*mass, for fast mean computation
27    sum: AtomicF64,
28    /// minimum observed length (bin units)
29    min: AtomicUsize,
30    /// internal bin size
31    bin_size: usize,
32
33    /// cached normalized PMF, valid once [`cache`](Self::cache) is called
34    cached_pmf: Vec<f64>,
35    /// cached CMF
36    cached_cmf: Vec<f64>,
37    have_cache: bool,
38}
39
40impl FragmentLengthDistribution {
41    /// Construct a distribution.
42    ///
43    /// * `alpha` – total pseudo-count mass (linear space).
44    /// * `max_val` – maximum representable length.
45    /// * `prior_mu` – Gaussian prior mean; if `<= 0`, a uniform prior is used.
46    /// * `prior_sigma` – Gaussian prior standard deviation.
47    /// * `kernel_n` – binomial kernel trials; must be even (after binning).
48    /// * `kernel_p` – binomial kernel success probability.
49    /// * `bin_size` – internal length binning (use 1 for no binning).
50    pub fn new(
51        alpha: f64,
52        max_val: usize,
53        prior_mu: f64,
54        prior_sigma: f64,
55        kernel_n: usize,
56        kernel_p: f64,
57        bin_size: usize,
58    ) -> Self {
59        assert!(bin_size >= 1, "bin_size must be >= 1");
60        let max_val = max_val / bin_size;
61        let kernel_n = kernel_n / bin_size;
62        assert!(
63            kernel_n.is_multiple_of(2),
64            "kernel_n must be even after binning"
65        );
66
67        let tot = alpha.ln();
68        let hist: Vec<AtomicF64>;
69        let mut sum = LOG_0;
70        let mut tot_mass;
71
72        if prior_mu > 0.0 {
73            let norm = Normal::new(
74                prior_mu / bin_size as f64,
75                prior_sigma / (bin_size * bin_size) as f64,
76            )
77            .expect("valid normal prior");
78            hist = (0..=max_val).map(|_| AtomicF64::new(LOG_0)).collect();
79            tot_mass = LOG_0;
80            for (i, slot) in hist.iter().enumerate() {
81                let norm_mass = norm.cdf(i as f64 + 0.5) - norm.cdf(i as f64 - 0.5);
82                let mass = if norm_mass != 0.0 {
83                    tot + norm_mass.ln()
84                } else {
85                    LOG_EPSILON
86                };
87                slot.store(mass);
88                sum = log_add(sum, (i as f64).ln() + mass);
89                tot_mass = log_add(tot_mass, mass);
90            }
91        } else {
92            // uniform prior
93            let per = tot - (max_val as f64).ln();
94            hist = (0..=max_val).map(|_| AtomicF64::new(per)).collect();
95            hist[0].store(LOG_0);
96            let h1 = hist.get(1).map(|a| a.load()).unwrap_or(per);
97            sum = h1 + ((max_val * (max_val + 1)) as f64).ln() - 2.0_f64.ln();
98            tot_mass = tot;
99        }
100
101        // binomial smoothing kernel
102        let binom = Binomial::new(kernel_p, kernel_n as u64).expect("valid binomial kernel");
103        let kernel: Vec<f64> = (0..=kernel_n).map(|i| binom.pmf(i as u64).ln()).collect();
104
105        Self {
106            kernel,
107            hist,
108            tot_mass: AtomicF64::new(tot_mass),
109            sum: AtomicF64::new(sum),
110            min: AtomicUsize::new(max_val),
111            bin_size,
112            cached_pmf: Vec::new(),
113            cached_cmf: Vec::new(),
114            have_cache: false,
115        }
116    }
117
118    /// salmon's default fragment-length distribution: pseudo-count 1.0, max
119    /// length 1000, no Gaussian prior (uniform), kernel `n=4, p=0.5`.
120    pub fn default_for_paired() -> Self {
121        Self::new(1.0, 1000, 0.0, 0.0, 4, 0.5, 1)
122    }
123
124    pub fn max_val(&self) -> usize {
125        (self.hist.len() - 1) * self.bin_size
126    }
127
128    pub fn min_val(&self) -> usize {
129        let m = self.min.load(Ordering::Relaxed);
130        if m == self.hist.len() - 1 {
131            1
132        } else {
133            m
134        }
135    }
136
137    /// Add `mass` (log space) for an observed fragment of length `len`,
138    /// spreading it over the smoothing kernel. Lock-free; safe to call from
139    /// multiple threads. (Must not race with [`cache`](Self::cache).)
140    pub fn add_val(&self, len: usize, mass: f64) {
141        let mut len = len / self.bin_size;
142        let max_v = self.max_val() / self.bin_size;
143        if len > max_v {
144            len = max_v;
145        }
146        self.min.fetch_min(len, Ordering::Relaxed);
147
148        let half = self.kernel.len() / 2;
149        // offset can go negative conceptually; use isize math then bound-check.
150        let mut offset = len as isize - half as isize;
151        for &k in &self.kernel {
152            if offset > 0 && (offset as usize) < self.hist.len() {
153                let o = offset as usize;
154                let k_mass = mass + k;
155                self.hist[o].log_add_assign(k_mass);
156                self.sum.log_add_assign((o as f64).ln() + k_mass);
157                self.tot_mass.log_add_assign(k_mass);
158            }
159            offset += 1;
160        }
161    }
162
163    /// Logged probability of observing a fragment of length `len`.
164    pub fn pmf(&self, len: usize) -> f64 {
165        if self.have_cache {
166            return *self
167                .cached_pmf
168                .get(len)
169                .unwrap_or_else(|| self.cached_pmf.last().unwrap());
170        }
171        let mut l = len / self.bin_size;
172        let max_v = self.max_val() / self.bin_size;
173        if l > max_v {
174            l = max_v;
175        }
176        self.hist[l].load() - self.tot_mass.load()
177    }
178
179    /// Logged cumulative mass up to and including `len`.
180    pub fn cmf(&self, len: usize) -> f64 {
181        if self.have_cache {
182            return *self
183                .cached_cmf
184                .get(len)
185                .unwrap_or_else(|| self.cached_cmf.last().unwrap());
186        }
187        let mut l = len / self.bin_size;
188        let max_v = self.max_val() / self.bin_size;
189        if l > max_v {
190            l = max_v;
191        }
192        let mut cum = LOG_0;
193        for i in 0..=l {
194            cum = log_add(cum, self.hist[i].load());
195        }
196        cum - self.tot_mass.load()
197    }
198
199    /// Total observed mass (log space).
200    pub fn tot_mass(&self) -> f64 {
201        self.tot_mass.load()
202    }
203
204    /// Mean observed length.
205    pub fn mean(&self) -> f64 {
206        (self.sum.load() - self.tot_mass.load()).exp()
207    }
208
209    /// Standard deviation of the observed length distribution, computed from the
210    /// cached normalized PMF (call after [`cache`](Self::cache)).
211    pub fn sd(&self) -> f64 {
212        let lp = self.log_pmf();
213        if lp.is_empty() {
214            return 0.0;
215        }
216        let mut mean = 0.0;
217        for (l, &p) in lp.iter().enumerate() {
218            mean += (l as f64) * p.exp();
219        }
220        let mut var = 0.0;
221        for (l, &p) in lp.iter().enumerate() {
222            let d = l as f64 - mean;
223            var += d * d * p.exp();
224        }
225        var.max(0.0).sqrt()
226    }
227
228    /// Freeze the distribution and precompute normalized PMF/CMF for fast,
229    /// allocation-free lookup. Call once after updates have stopped.
230    pub fn cache(&mut self) {
231        if self.have_cache {
232            return;
233        }
234        let max_v = self.max_val();
235        // normalized PMF over [0, max_v]
236        let mut pmf = Vec::with_capacity(max_v + 1);
237        let mut tot = LOG_0;
238        for i in 0..=max_v {
239            let p = self.pmf(i);
240            pmf.push(p);
241            tot = log_add(tot, p);
242        }
243        for p in &mut pmf {
244            *p -= tot;
245        }
246        // CMF from the normalized PMF
247        let mut cmf = Vec::with_capacity(pmf.len());
248        let mut cum = LOG_0;
249        for &p in &pmf {
250            cum = log_add(cum, p);
251            cmf.push(cum);
252        }
253        self.cached_pmf = pmf;
254        self.cached_cmf = cmf;
255        self.have_cache = true;
256    }
257
258    /// The cached, normalized log-PMF over `[0, max_val]`. Requires [`cache`](Self::cache).
259    pub fn log_pmf(&self) -> &[f64] {
260        debug_assert!(self.have_cache, "call cache() before log_pmf()");
261        &self.cached_pmf
262    }
263
264    /// Cumulative conditional means `E[L | L ≤ i]` over `[0, max_val]`, i.e.
265    /// salmon's `correctionFactorsFromMass` (`DistributionUtils.cpp`):
266    /// `cm[i] = (Σ_{l≤i} l·pmf[l]) / (Σ_{l≤i} pmf[l])`.
267    ///
268    /// These are the per-length correction factors `computeSmoothedEffectiveLengths`
269    /// subtracts from the reference length to get the base effective length. The
270    /// ratio is invariant to the PMF normalization, so the cached (normalized) PMF
271    /// gives the same values as salmon's `100·exp(logPMF)` mass. Requires
272    /// [`cache`](Self::cache).
273    pub fn conditional_means(&self) -> Vec<f64> {
274        debug_assert!(self.have_cache, "call cache() before conditional_means()");
275        let n = self.cached_pmf.len();
276        let mut cms = vec![0.0f64; n];
277        let mut vals = 0.0; // Σ l·pmf[l]
278        let mut mult = 0.0; // Σ pmf[l]
279        for i in 0..n {
280            let p = self.cached_pmf[i].exp();
281            vals += (i as f64) * p;
282            mult += p;
283            cms[i] = if mult > 0.0 { vals / mult } else { 0.0 };
284        }
285        cms
286    }
287}
288
289/// salmon's base effective length (`computeSmoothedEffectiveLengths`):
290/// `effLen = refLen − E[L | L ≤ refLen]`, clamped back to `refLen` if it would
291/// fall below 1. `cond_means` is [`FragmentLengthDistribution::conditional_means`].
292///
293/// This replaces the truncated-PMF `Σ pmf(l)·(refLen−l+1)` estimate (which falls
294/// back to the raw `refLen` for any transcript shorter than the FLD mean), matching
295/// salmon's behaviour exactly.
296pub fn smoothed_effective_length(cond_means: &[f64], ref_len: usize) -> f64 {
297    if cond_means.is_empty() {
298        return ref_len as f64;
299    }
300    let max_len = cond_means.len();
301    let cf = if ref_len >= max_len {
302        cond_means[max_len - 1]
303    } else {
304        cond_means[ref_len]
305    };
306    let eff = ref_len as f64 - cf;
307    if eff < 1.0 {
308        ref_len as f64
309    } else {
310        eff
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn uniform_prior_pmf_normalizes() {
320        let mut fld = FragmentLengthDistribution::new(1.0, 200, 0.0, 0.0, 4, 0.5, 1);
321        fld.cache();
322        let total: f64 = fld.log_pmf().iter().map(|p| p.exp()).sum();
323        assert!((total - 1.0).abs() < 1e-9, "pmf sums to {total}");
324    }
325
326    #[test]
327    fn gaussian_prior_mean_is_near_mu() {
328        let fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
329        let m = fld.mean();
330        assert!((m - 250.0).abs() < 5.0, "mean {m} not near 250");
331    }
332
333    #[test]
334    fn observations_shift_the_distribution() {
335        let mut fld = FragmentLengthDistribution::new(1.0, 1000, 250.0, 25.0, 4, 0.5, 1);
336        // pile observations around 400
337        for _ in 0..100_000 {
338            fld.add_val(400, 0.0); // mass = log(1) = 0
339        }
340        let m = fld.mean();
341        assert!(m > 300.0, "mean {m} did not move toward 400");
342        fld.cache();
343        // length 400 should be among the most probable
344        let p400 = fld.pmf(400);
345        let p250 = fld.pmf(250);
346        assert!(p400 > p250, "p(400)={p400} not > p(250)={p250}");
347    }
348
349    #[test]
350    fn smoothed_efflen_shrinks_short_transcripts() {
351        // Gaussian prior mean 250: a transcript far shorter than the mean should
352        // get a heavily shrunk effective length (NOT the raw refLen the old
353        // truncated-PMF estimate fell back to).
354        let mut fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
355        fld.cache();
356        let cm = fld.conditional_means();
357        // conditional means are non-decreasing
358        for w in cm.windows(2) {
359            assert!(
360                w[1] >= w[0] - 1e-9,
361                "cond means not monotonic: {} < {}",
362                w[1],
363                w[0]
364            );
365        }
366        let short = smoothed_effective_length(&cm, 201);
367        assert!(
368            short < 201.0 && short > 1.0,
369            "short effLen {short} not shrunk"
370        );
371        // a long transcript keeps most of its length
372        let long = smoothed_effective_length(&cm, 5000);
373        assert!(long > 4000.0, "long effLen {long} shrunk too much");
374        // below the 1.0 barrier the raw length is returned
375        let tiny = smoothed_effective_length(&cm, 2);
376        assert_eq!(tiny, 2.0, "tiny transcript should fall back to refLen");
377    }
378
379    #[test]
380    fn cmf_is_monotonic() {
381        let mut fld = FragmentLengthDistribution::new(1000.0, 500, 200.0, 30.0, 4, 0.5, 1);
382        fld.cache();
383        let mut prev = f64::NEG_INFINITY;
384        for l in 0..=500 {
385            let c = fld.cmf(l);
386            assert!(c >= prev - 1e-9, "cmf decreased at {l}: {c} < {prev}");
387            prev = c;
388        }
389        assert!((prev - 0.0).abs() < 1e-6, "cmf endpoint {prev} != log(1)");
390    }
391}