Skip to main content

salmon_model/
seqbias.rs

1//! Sequence-specific bias model (`SBModel`).
2//!
3//! A faithful port of salmon's `SBModel` (`src/model/SBModel.cpp`): a
4//! variable-order Markov model over the 9-base sequence context surrounding a
5//! fragment's start position (3 bases before the start, the start, and 5 after).
6//! Per-position Markov orders are `{0,1,2,2,2,2,2,2,2}`. Counts are accumulated
7//! from observed fragment-start contexts (the *observed* model) and from the
8//! transcriptome (the *expected* model); the ratio of the two scores the
9//! sequence bias at any position, which is used to correct effective lengths.
10//!
11//! The 2-bit base encoding only needs to be self-consistent (the bias is a
12//! ratio of two models built with the same encoding), so we use A=0, C=1,
13//! G=2, T=3.
14
15/// Per-position Markov orders (salmon's "simple" model). Length is the context.
16const ORDER: [u32; 9] = [0, 1, 2, 2, 2, 2, 2, 2, 2];
17/// Context length (= ORDER.len()): 3 left + start + 5 right.
18pub const CONTEXT_LENGTH: usize = 9;
19/// Bases before the fragment-start position.
20pub const CONTEXT_LEFT: usize = 3;
21/// Bases at/after the fragment-start position.
22pub const CONTEXT_RIGHT: usize = 5;
23/// Rows in the probability table: 4^(maxOrder+1) = 4^3.
24const ROWS: usize = 64;
25/// Pseudocount prior.
26const PRIOR: f64 = 1e-10;
27/// Floor used when taking the log of a zero probability.
28const LOG_SMALL: f64 = -11.512_925_464_970_229; // ln(1e-5)
29
30/// 2-bit encode an ASCII base (non-ACGT -> 0).
31#[inline]
32fn base2bit(b: u8) -> u32 {
33    match b {
34        b'A' | b'a' => 0,
35        b'C' | b'c' => 1,
36        b'G' | b'g' => 2,
37        b'T' | b't' => 3,
38        _ => 0,
39    }
40}
41
42#[inline]
43fn complement_bit(x: u32) -> u32 {
44    3 - x // A<->T (0<->3), C<->G (1<->2)
45}
46
47/// The sequence-specific bias Markov model.
48#[derive(Debug, Clone)]
49pub struct SBModel {
50    /// log (after [`normalize`](Self::normalize)) or linear (before) transition
51    /// probabilities, laid out position-major: `probs[pos * ROWS + idx]`
52    probs: Vec<f64>,
53    /// per-position base marginals: `marginals[pos * 4 + base]`
54    marginals: Vec<f64>,
55    shifts: [u32; CONTEXT_LENGTH],
56    masks: [u32; CONTEXT_LENGTH],
57    trained: bool,
58}
59
60impl Default for SBModel {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl SBModel {
67    pub fn new() -> Self {
68        let mut shifts = [0u32; CONTEXT_LENGTH];
69        let mut masks = [0u32; CONTEXT_LENGTH];
70        for i in 0..CONTEXT_LENGTH {
71            // base i occupies the high bits; isolate the (order+1)-mer ending at i
72            shifts[i] = (2 * CONTEXT_LENGTH as u32) - 2 * (i as u32 + 1);
73            let width = 2 * (ORDER[i] + 1);
74            masks[i] = (1u32 << width) - 1;
75        }
76        Self {
77            probs: vec![PRIOR; ROWS * CONTEXT_LENGTH],
78            marginals: vec![PRIOR; 4 * CONTEXT_LENGTH],
79            shifts,
80            masks,
81            trained: false,
82        }
83    }
84
85    /// Encode a 9-base context (`CONTEXT_LENGTH` bytes) into a 2-bit-per-base
86    /// integer with base 0 in the high bits. `rev_comp` reverse-complements it.
87    fn encode(context: &[u8], rev_comp: bool) -> u32 {
88        debug_assert_eq!(context.len(), CONTEXT_LENGTH);
89        let mut mer = 0u32;
90        if rev_comp {
91            // reverse complement: last base becomes first
92            for &b in context.iter().rev() {
93                mer = (mer << 2) | complement_bit(base2bit(b));
94            }
95        } else {
96            for &b in context {
97                mer = (mer << 2) | base2bit(b);
98            }
99        }
100        mer
101    }
102
103    #[inline]
104    fn index_at(&self, mer: u32, pos: usize) -> usize {
105        ((mer >> self.shifts[pos]) & self.masks[pos]) as usize
106    }
107
108    /// The flattened transition table (`probs[pos * ROWS + idx]`), for dumping to
109    /// the aux bias files. Linear counts before [`normalize`](Self::normalize),
110    /// conditional log-probabilities after.
111    pub fn dump(&self) -> &[f64] {
112        &self.probs
113    }
114
115    /// Accumulate one observed context with the given weight.
116    pub fn add_context(&mut self, context: &[u8], rev_comp: bool, weight: f64) {
117        debug_assert!(!self.trained, "cannot add to a normalized model");
118        let mer = Self::encode(context, rev_comp);
119        for pos in 0..CONTEXT_LENGTH {
120            let idx = self.index_at(mer, pos);
121            self.probs[pos * ROWS + idx] += weight;
122        }
123    }
124
125    /// Convert accumulated counts into conditional log-probabilities. Idempotent
126    /// guard: a model can only be normalized once.
127    pub fn normalize(&mut self) {
128        if self.trained {
129            return;
130        }
131        for pos in 0..CONTEXT_LENGTH {
132            let num_states = 4usize.pow(ORDER[pos]);
133            for s in 0..num_states {
134                let node = s * 4;
135                let base = pos * ROWS + node;
136                let tot: f64 = self.probs[base..base + 4].iter().sum();
137                if tot > 0.0 {
138                    for j in 0..4 {
139                        self.probs[base + j] /= tot;
140                        self.marginals[pos * 4 + j] += self.probs[base + j];
141                    }
142                }
143            }
144            for j in 0..4 {
145                self.marginals[pos * 4 + j] /= num_states as f64;
146            }
147        }
148        for p in &mut self.probs {
149            *p = if *p > 0.0 { p.ln() } else { LOG_SMALL };
150        }
151        self.trained = true;
152    }
153
154    /// Log-probability the (normalized) model assigns to a context.
155    pub fn evaluate_log(&self, context: &[u8], rev_comp: bool) -> f64 {
156        debug_assert!(self.trained, "evaluate_log requires a normalized model");
157        let mer = Self::encode(context, rev_comp);
158        let mut lp = 0.0;
159        for pos in 0..CONTEXT_LENGTH {
160            let idx = self.index_at(mer, pos);
161            lp += self.probs[pos * ROWS + idx];
162        }
163        lp
164    }
165
166    pub fn is_trained(&self) -> bool {
167        self.trained
168    }
169
170    /// Add another (un-normalized) model's counts into this one. Both must be
171    /// pre-normalization; used to merge per-thread observed models.
172    pub fn combine_counts(&mut self, other: &SBModel) {
173        debug_assert!(!self.trained && !other.trained, "combine before normalize");
174        for (a, b) in self.probs.iter_mut().zip(&other.probs) {
175            *a += *b - PRIOR; // avoid double-counting the prior
176        }
177    }
178}
179
180/// Reverse-complement a DNA byte slice (ACGT; other bases map to `A`).
181pub(crate) fn revcomp_bytes(seq: &[u8]) -> Vec<u8> {
182    seq.iter()
183        .rev()
184        .map(|&b| match b {
185            b'A' | b'a' => b'T',
186            b'C' | b'c' => b'G',
187            b'G' | b'g' => b'C',
188            b'T' | b't' => b'A',
189            _ => b'A',
190        })
191        .collect()
192}
193
194/// Minimum transcript abundance to contribute to / be corrected by the bias
195/// background (salmon's `minAlpha`).
196pub(crate) const MIN_ALPHA: f64 = 1e-8;
197/// Minimum reliable CDF mass for a transcript (salmon's `minCDFMass`).
198pub(crate) const MIN_CDF_MASS: f64 = 1e-10;
199/// Fragment-length sampling stride in the effective-length convolution
200/// (salmon's `pdfSampFactor` = `biasSpeedSamp` default).
201pub const FLD_SAMP_STRIDE: usize = 5;
202
203/// Linear cumulative fragment-length distribution plus the `[low, high]`
204/// fragment-length quantile bounds (0.5% / 99.5%), mirroring the `cdf`,
205/// `fldLow`, `fldHigh` salmon computes in `updateEffectiveLengths`.
206pub fn fld_cdf_and_bounds(pmf_lin: &[f64]) -> (Vec<f64>, usize, usize) {
207    let mut cdf = vec![0.0f64; pmf_lin.len()];
208    let mut acc = 0.0;
209    let (mut lo, mut hi) = (0usize, 1usize);
210    let (mut lb, mut ub) = (false, false);
211    for i in 0..pmf_lin.len() {
212        acc += pmf_lin[i];
213        cdf[i] = acc;
214        if !lb && acc >= 0.005 {
215            lb = true;
216            lo = i;
217        }
218        if !ub && acc >= 0.995 {
219            ub = true;
220            hi = i;
221        }
222    }
223    (cdf, lo, hi)
224}
225
226/// Per-transcript conditional fragment-length CDF: salmon's
227/// `conditionalCDF(x) = (x > cdfMaxArg) ? 1.0 : cdf[x] / cdfMaxVal`, where
228/// `cdfMaxArg = min(cdf.len()-1, refLen)` normalizes the FLD to the fragment
229/// lengths that fit in this transcript.
230#[inline]
231pub(crate) fn conditional_cdf(cdf: &[f64], cdf_max_arg: usize, cdf_max_val: f64, x: i32) -> f64 {
232    if x > cdf_max_arg as i32 {
233        1.0
234    } else if x <= 0 {
235        cdf[0] / cdf_max_val
236    } else {
237        cdf[x as usize] / cdf_max_val
238    }
239}
240
241/// Build the expected forward/RC sequence-bias models by sliding the context
242/// window over each expressed transcript. Each context is weighted by the
243/// transcript's abundance density (`alpha / effLen`) times the conditional FLD
244/// mass that can start there (`conditionalCDF(maxFragLen)`), matching salmon's
245/// expected-model construction in `updateEffectiveLengths`.
246pub fn build_expected<'a, F>(
247    num_targets: usize,
248    seq_of: F,
249    alphas: &[f64],
250    eff_lens: &[f64],
251    cdf: &[f64],
252) -> (SBModel, SBModel)
253where
254    F: Fn(usize) -> &'a [u8] + Sync,
255{
256    use rayon::prelude::*;
257    let k = CONTEXT_LENGTH;
258    let cu = CONTEXT_LEFT as i32;
259    // Each expressed transcript contributes independently to the expected
260    // forward/RC context counts, an O(refLen) sweep per transcript. salmon
261    // parallelizes this over transcripts; do the same with rayon (per-thread
262    // `SBModel` partials reduced via `combine_counts`). `seq_of` must be `Sync`
263    // to share across threads (it is: a closure over the index). `num_targets`
264    // excludes decoys (the contiguous tail): decoys are never expressed and so
265    // contribute nothing, but skipping them outright guarantees no O(refLen)
266    // decoy sweep can ever run.
267    let per_tid = |tid: usize| -> Option<(SBModel, SBModel)> {
268        if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
269            return None;
270        }
271        let seq = seq_of(tid);
272        let ref_len = seq.len();
273        if ref_len < k {
274            return None;
275        }
276        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
277        let cdf_max_val = cdf[cdf_max_arg];
278        if cdf_max_val < MIN_CDF_MASS {
279            return None;
280        }
281        let weight = alphas[tid] / eff_lens[tid];
282        let rc = revcomp_bytes(seq);
283        let mut fw = SBModel::new();
284        let mut rc_m = SBModel::new();
285        // fragStartPos in 0..(refLen - K) (salmon's loop bound)
286        for frag_start in 0..(ref_len - k) {
287            let max_frag_len = ref_len as i32 - (frag_start as i32 + cu);
288            if max_frag_len >= 0 && (max_frag_len as usize) < ref_len {
289                let cdensity = conditional_cdf(cdf, cdf_max_arg, cdf_max_val, max_frag_len);
290                let w = weight * cdensity;
291                fw.add_context(&seq[frag_start..frag_start + k], false, w);
292                rc_m.add_context(&rc[frag_start..frag_start + k], false, w);
293            }
294        }
295        Some((fw, rc_m))
296    };
297    let (mut exp_fw, mut exp_rc) = (0..num_targets)
298        .into_par_iter()
299        .fold(
300            || (SBModel::new(), SBModel::new()),
301            |mut acc, tid| {
302                if let Some((fw, rc_m)) = per_tid(tid) {
303                    acc.0.combine_counts(&fw);
304                    acc.1.combine_counts(&rc_m);
305                }
306                acc
307            },
308        )
309        .reduce(
310            || (SBModel::new(), SBModel::new()),
311            |mut a, b| {
312                a.0.combine_counts(&b.0);
313                a.1.combine_counts(&b.1);
314                a
315            },
316        );
317    exp_fw.normalize();
318    exp_rc.normalize();
319    (exp_fw, exp_rc)
320}
321
322/// Bias-corrected effective length of one transcript, matching salmon's
323/// `updateEffectiveLengths` (`src/util/SalmonUtils.cpp`).
324///
325/// `cdf` is the linear cumulative FLD; `fld_low`/`fld_high` the 0.5%/99.5%
326/// fragment-length quantiles (from [`fld_cdf_and_bounds`]). `elen` is the
327/// transcript's *unbiased* effective length (used for the lower barrier and the
328/// `unprocessedLen` guard). `stride` subsamples fragment lengths
329/// ([`FLD_SAMP_STRIDE`] matches salmon).
330///
331/// Per-position 5'/3' bias factors `exp(obsLog − expLog)` are placed at the
332/// fragment *read-start* (`fragStart + contextBefore`), the 3' factors reversed
333/// to forward fragment-end coordinates, then convolved with the conditional FLD:
334/// `effLen = Σ_l flWeight(l) · Σ_s fw[s]·rc[s+l−1]`. The result is floored at
335/// `min(elen, max(1, unprocessedLen))` (salmon's lower "barrier"; there is no
336/// upper cap, so a strongly-biased transcript's effLen can exceed its length).
337#[allow(clippy::too_many_arguments)]
338pub fn corrected_effective_length(
339    seq: &[u8],
340    cdf: &[f64],
341    fld_low: usize,
342    fld_high: usize,
343    obs_fw: &SBModel,
344    exp_fw: &SBModel,
345    obs_rc: &SBModel,
346    exp_rc: &SBModel,
347    elen: f64,
348    stride: usize,
349) -> f64 {
350    let k = CONTEXT_LENGTH;
351    let cu = CONTEXT_LEFT; // contextBefore(false)
352    let ref_len = seq.len();
353    let unprocessed = (ref_len as i32 - elen as i32).max(0);
354    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
355    let cdf_max_val = cdf[cdf_max_arg];
356    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
357        return elen;
358    }
359    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
360
361    // Per-position 5' and 3' sequence-bias factors, placed at the read-start.
362    let rc_seq = revcomp_bytes(seq);
363    let mut fw = vec![1.0f64; ref_len];
364    let mut rc = vec![1.0f64; ref_len];
365    for frag_start in 0..(ref_len - k) {
366        let read_start = frag_start + cu;
367        if read_start < ref_len {
368            fw[read_start] =
369                log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
370            rc[read_start] =
371                log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
372        }
373    }
374    rc.reverse(); // align RC factors with forward fragment-end coordinates
375
376    // Convolve the bias factors with the conditional FLD over [fld_low, fld_high].
377    let stride = stride.max(1) as i32;
378    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
379    let mut fl = fld_low as i32;
380    let mut done = fl >= max_len;
381    let sp = if fl > 0 { fl - 1 } else { 0 };
382    let mut prev_mass = cond(sp);
383    let mut eff = 0.0f64;
384    while !done {
385        if fl >= max_len {
386            done = true;
387            fl = max_len - 1;
388        }
389        let fl_weight = cond(fl) - prev_mass;
390        prev_mass = cond(fl);
391        let mut mass = 0.0f64;
392        let mut kstart = 0i32;
393        while kstart < ref_len as i32 - fl {
394            let frag_start = kstart as usize;
395            let frag_end = (kstart + fl - 1) as usize;
396            if frag_end < ref_len {
397                mass += fw[frag_start] * rc[frag_end];
398            } else {
399                break;
400            }
401            kstart += 1;
402        }
403        eff += fl_weight * mass;
404        fl += stride;
405    }
406
407    // Lower barrier (salmon default; no upper cap).
408    let offset = (unprocessed as f64).max(1.0);
409    eff.max(elen.min(offset))
410}
411
412/// Log bias of `observed` relative to `expected` for a context:
413/// `log P_obs(context) - log P_exp(context)`. The fragment-level bias weight is
414/// `exp` of this.
415pub fn log_bias(observed: &SBModel, expected: &SBModel, context: &[u8], rev_comp: bool) -> f64 {
416    observed.evaluate_log(context, rev_comp) - expected.evaluate_log(context, rev_comp)
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn uniform_contexts_give_near_zero_bias() {
425        // Both models trained on the same uniform set of contexts -> bias ~ 0.
426        let ctxs: Vec<Vec<u8>> = (0..256)
427            .map(|i| {
428                let bases = b"ACGT";
429                (0..CONTEXT_LENGTH)
430                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
431                    .collect()
432            })
433            .collect();
434        let mut obs = SBModel::new();
435        let mut exp = SBModel::new();
436        for c in &ctxs {
437            obs.add_context(c, false, 1.0);
438            exp.add_context(c, false, 1.0);
439        }
440        obs.normalize();
441        exp.normalize();
442        for c in &ctxs {
443            assert!(log_bias(&obs, &exp, c, false).abs() < 1e-9);
444        }
445    }
446
447    #[test]
448    fn enriched_context_has_positive_bias() {
449        // observed enriched for a specific context vs a uniform expected model
450        let target: Vec<u8> = b"ACGTACGTA".to_vec();
451        let bases = b"ACGT";
452        let uniform: Vec<Vec<u8>> = (0..4096)
453            .map(|i| {
454                (0..CONTEXT_LENGTH)
455                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
456                    .collect()
457            })
458            .collect();
459
460        let mut exp = SBModel::new();
461        for c in &uniform {
462            exp.add_context(c, false, 1.0);
463        }
464        exp.normalize();
465
466        let mut obs = SBModel::new();
467        for c in &uniform {
468            obs.add_context(c, false, 1.0);
469        }
470        for _ in 0..5000 {
471            obs.add_context(&target, false, 1.0); // enrich
472        }
473        obs.normalize();
474
475        assert!(
476            log_bias(&obs, &exp, &target, false) > 0.5,
477            "enriched context should have positive log-bias"
478        );
479    }
480
481    #[test]
482    fn unbiased_correction_reduces_to_standard_eff_len() {
483        // obs == exp -> all bias factors 1 -> corrected effLen == standard
484        // effLen = sum_l pmf(l)*(refLen - l). Point-mass FLD at l=100.
485        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
486        let seq: Vec<u8> = (0..400).map(|i| bases[i % bases.len()]).collect();
487        let mut m = SBModel::new();
488        let rc = revcomp_bytes(&seq);
489        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
490            m.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
491            m.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
492        }
493        let mut obs = m.clone();
494        let mut exp = m.clone();
495        obs.normalize();
496        exp.normalize();
497
498        let mut pmf = vec![0.0; 200];
499        pmf[100] = 1.0;
500        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
501        // unbiased effLen at point-mass 100 on a 400nt transcript = 400 - 100 = 300
502        let eff = corrected_effective_length(&seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 300.0, 1);
503        assert!((eff - 300.0).abs() < 1e-6, "got {eff}");
504    }
505
506    #[test]
507    fn revcomp_encoding_is_consistent() {
508        // RC of a context evaluated forward equals the context evaluated as RC.
509        let ctx: Vec<u8> = b"ACGTACGTA".to_vec();
510        let rc: Vec<u8> = ctx
511            .iter()
512            .rev()
513            .map(|&b| match b {
514                b'A' => b'T',
515                b'C' => b'G',
516                b'G' => b'C',
517                b'T' => b'A',
518                x => x,
519            })
520            .collect();
521        assert_eq!(SBModel::encode(&ctx, true), SBModel::encode(&rc, false));
522    }
523
524    #[test]
525    fn build_expected_respects_num_targets_bound() {
526        // Five real transcripts plus a sixth "decoy" with a very distinctive
527        // composition (poly-AC). The decoy must influence the expected model only
528        // when `num_targets` includes it, and must be skipped (cheaply) when its
529        // alpha is zero — the two ways decoys are kept out of the bias models.
530        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
531        let mut refs: Vec<Vec<u8>> = (0..5)
532            .map(|s| (0..200).map(|i| bases[(i + s) % bases.len()]).collect())
533            .collect();
534        refs.push(
535            (0..400)
536                .map(|i| if i % 2 == 0 { b'A' } else { b'C' })
537                .collect(),
538        );
539        let num_refs = refs.len();
540        let alphas = vec![1.0; num_refs];
541        let eff_lens = vec![150.0; num_refs];
542        let mut pmf = vec![0.0; 200];
543        pmf[100] = 1.0;
544        let (cdf, _lo, _hi) = fld_cdf_and_bounds(&pmf);
545
546        // Exclude the decoy (num_targets = 5) vs include it (num_targets = 6).
547        let (a_fw, _) = build_expected(5, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
548        let (b_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
549        assert!(a_fw.is_trained() && b_fw.is_trained());
550        assert!(a_fw.dump().iter().all(|v| v.is_finite()));
551        let diff: f64 = a_fw
552            .dump()
553            .iter()
554            .zip(b_fw.dump())
555            .map(|(x, y)| (x - y).abs())
556            .sum();
557        assert!(
558            diff > 1e-6,
559            "a target beyond num_targets must not contribute (diff={diff})"
560        );
561
562        // With the decoy's alpha zeroed the MIN_ALPHA guard skips it, so including
563        // it (num_targets = 6) must match excluding it (num_targets = 5).
564        let mut alphas0 = alphas.clone();
565        alphas0[5] = 0.0;
566        let (c_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas0, &eff_lens, &cdf);
567        let diff2: f64 = a_fw
568            .dump()
569            .iter()
570            .zip(c_fw.dump())
571            .map(|(x, y)| (x - y).abs())
572            .sum();
573        assert!(
574            diff2 < 1e-9,
575            "zero-alpha target must not contribute (diff={diff2})"
576        );
577    }
578}