Skip to main content

salmon_model/
spline.rs

1//! Cubic spline interpolation — a faithful port of the vendored `tk::spline`
2//! (Tino Kluge) used by salmon's `SimplePosBias`.
3//!
4//! # What a spline is for here
5//!
6//! The positional bias model measures fragment starts in a handful of coarse
7//! bins along each transcript. To *use* that model we need a smooth value at any
8//! position, not just at bin centres. A cubic spline draws a smooth curve that
9//! passes exactly through every measured point ("knot") while keeping the curve
10//! and its slope continuous — so the correction never jumps at a bin boundary.
11//!
12//! Matches salmon's default construction: **cubic** spline with **natural**
13//! boundary conditions (second derivative 0 at both ends) and **quadratic**
14//! extrapolation outside the knot range. "Natural" means the curve straightens
15//! out at the ends rather than curling off, which is the conservative choice when
16//! there is no data beyond the last knot.
17//!
18//! The band-matrix LU solve is ported verbatim (tridiagonal, one upper + one
19//! lower band) so the spline coefficients match the C++ implementation.
20
21/// A tridiagonal band matrix with one upper and one lower band (plus a saved
22/// diagonal used during LU). Mirrors `tk::band_matrix` for the `n_u = n_l = 1`
23/// case that cubic-spline construction needs.
24///
25/// **Why a band matrix.** Spline construction produces one equation per knot,
26/// each involving only that knot and its two neighbours. So the matrix is all
27/// zeros except for three diagonals, and storing only those three costs `O(n)`
28/// instead of `O(n²)` and makes the solve linear rather than cubic.
29struct BandMatrix {
30    dim: usize,
31    // m_upper[0] = main diagonal, m_upper[1] = super-diagonal
32    upper: [Vec<f64>; 2],
33    // m_lower[0] = saved diagonal (set during LU), m_lower[1] = sub-diagonal
34    lower: [Vec<f64>; 2],
35}
36
37impl BandMatrix {
38    fn new(dim: usize) -> Self {
39        Self {
40            dim,
41            upper: [vec![0.0; dim], vec![0.0; dim]],
42            lower: [vec![0.0; dim], vec![0.0; dim]],
43        }
44    }
45
46    /// `A(i, j)` accessor (band index `k = j - i`; `k >= 0` -> upper, else lower).
47    ///
48    /// The distance from the diagonal picks the band, and the row index picks the
49    /// slot within it — which is how a 2-D index reaches the flat band storage.
50    #[inline]
51    fn get(&self, i: usize, j: usize) -> f64 {
52        let k = j as isize - i as isize;
53        if k >= 0 {
54            self.upper[k as usize][i]
55        } else {
56            self.lower[(-k) as usize][i]
57        }
58    }
59
60    #[inline]
61    fn set(&mut self, i: usize, j: usize, v: f64) {
62        let k = j as isize - i as isize;
63        if k >= 0 {
64            self.upper[k as usize][i] = v;
65        } else {
66            self.lower[(-k) as usize][i] = v;
67        }
68    }
69
70    /// The reciprocal of row `i`'s original diagonal, stashed during
71    /// decomposition and reused by the forward solve.
72    #[inline]
73    fn saved_diag(&self, i: usize) -> f64 {
74        self.lower[0][i]
75    }
76    #[inline]
77    fn set_saved_diag(&mut self, i: usize, v: f64) {
78        self.lower[0][i] = v;
79    }
80
81    /// LR-decomposition of the band matrix (ported from `tk::band_matrix`).
82    ///
83    /// "LU" (here called LR) factors the matrix into a lower- and an
84    /// upper-triangular part, after which a system can be solved by two cheap
85    /// sweeps — forward through L, then backward through R.
86    fn lu_decompose(&mut self) {
87        let dim = self.dim as isize;
88        // preconditioning: normalize row i so a_ii = 1
89        //
90        // Scaling each row by its diagonal keeps the elimination below numerically
91        // well behaved and makes the diagonal exactly 1 rather than
92        // nearly 1 after rounding.
93        for i in 0..self.dim {
94            let diag = self.get(i, i);
95            self.set_saved_diag(i, 1.0 / diag);
96            // Only the three band entries of this row exist; clamp the range at
97            // the matrix edges.
98            let j_min = (i as isize - 1).max(0) as usize;
99            let j_max = (i + 1).min(self.dim - 1);
100            let s = self.saved_diag(i);
101            for j in j_min..=j_max {
102                self.set(i, j, self.get(i, j) * s);
103            }
104            self.set(i, i, 1.0); // prevents rounding errors
105        }
106        // Gauss LR-decomposition
107        //
108        // Standard elimination, but only over the band: row k can affect just row
109        // k+1, and only in columns k..k+1.
110        for k in 0..self.dim {
111            let i_max = ((k + 1).min(self.dim - 1)) as isize;
112            let mut i = k as isize + 1;
113            while i <= i_max {
114                let iu = i as usize;
115                let akk = self.get(k, k);
116                let x = -self.get(iu, k) / akk;
117                self.set(iu, k, -x); // assembly part of L
118                let j_max = ((k + 1).min(self.dim - 1)) as isize;
119                let mut j = k as isize + 1;
120                while j <= j_max {
121                    let ju = j as usize;
122                    self.set(iu, ju, self.get(iu, ju) + x * self.get(k, ju));
123                    j += 1;
124                }
125                i += 1;
126            }
127        }
128        // `dim` is unused after the loops; kept to mirror the C++ source.
129        let _ = dim;
130    }
131
132    /// Solve `Ly = b`.
133    ///
134    /// Forward substitution: row `i` depends only on rows before it, so `x` can be
135    /// filled left to right.
136    fn l_solve(&self, b: &[f64]) -> Vec<f64> {
137        let mut x = vec![0.0; self.dim];
138        for i in 0..self.dim {
139            let mut sum = 0.0;
140            let j_start = (i as isize - 1).max(0) as usize;
141            for j in j_start..i {
142                sum += self.get(i, j) * x[j];
143            }
144            x[i] = b[i] * self.saved_diag(i) - sum;
145        }
146        x
147    }
148
149    /// Solve `Rx = y`.
150    ///
151    /// Back substitution: the mirror image, filling `x` right to left.
152    fn r_solve(&self, b: &[f64]) -> Vec<f64> {
153        let mut x = vec![0.0; self.dim];
154        for i in (0..self.dim).rev() {
155            let mut sum = 0.0;
156            let j_stop = (i + 1).min(self.dim - 1);
157            for j in (i + 1)..=j_stop {
158                if j > i {
159                    sum += self.get(i, j) * x[j];
160                }
161            }
162            x[i] = (b[i] - sum) / self.get(i, i);
163        }
164        x
165    }
166
167    /// Decompose, then solve — the whole point of the two routines above.
168    fn lu_solve(&mut self, b: &[f64]) -> Vec<f64> {
169        self.lu_decompose();
170        let y = self.l_solve(b);
171        self.r_solve(&y)
172    }
173}
174
175/// A cubic spline `f(x) = a·(x−x_i)³ + b·(x−x_i)² + c·(x−x_i) + y_i`.
176///
177/// One cubic piece per interval, expressed relative to that interval's left knot;
178/// `a`/`b`/`c` are the per-interval coefficients and `y` the knot values.
179#[derive(Debug, Clone, Default)]
180pub struct Spline {
181    x: Vec<f64>,
182    y: Vec<f64>,
183    a: Vec<f64>,
184    b: Vec<f64>,
185    c: Vec<f64>,
186    /// Coefficients for extrapolating below `x[0]` (quadratic, so `a` is absent).
187    b0: f64,
188    c0: f64,
189}
190
191impl Spline {
192    /// Build a cubic spline with natural boundary conditions and quadratic
193    /// extrapolation — salmon's default `tk::spline(xs, ys)`.
194    ///
195    /// The construction: demand that neighbouring cubics agree in value, slope and
196    /// curvature at each interior knot. That gives one equation per interior knot
197    /// in the unknown second derivatives (`b`), the natural boundary conditions
198    /// pin the two ends, and the resulting tridiagonal system is solved above.
199    /// `a` and `c` then follow directly from `b`.
200    pub fn new(xs: Vec<f64>, ys: Vec<f64>) -> Self {
201        let n = xs.len();
202        // Two points define a line, not a cubic with interior continuity.
203        assert!(n >= 3, "cubic spline needs >= 3 points");
204        let x = xs;
205        let y = ys;
206
207        let mut mat = BandMatrix::new(n);
208        let mut rhs = vec![0.0; n];
209        // Interior knots: continuity of the second derivative. The right-hand side
210        // is the difference of the slopes on either side of knot `i`.
211        for i in 1..n - 1 {
212            mat.set(i, i - 1, (x[i] - x[i - 1]) / 3.0);
213            mat.set(i, i, 2.0 / 3.0 * (x[i + 1] - x[i - 1]));
214            mat.set(i, i + 1, (x[i + 1] - x[i]) / 3.0);
215            rhs[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i]) - (y[i] - y[i - 1]) / (x[i] - x[i - 1]);
216        }
217        // natural (second_deriv = 0) boundary conditions
218        // A zero right-hand side with a 2 on the diagonal forces `b = 0` at each end.
219        mat.set(0, 0, 2.0);
220        mat.set(0, 1, 0.0);
221        rhs[0] = 0.0;
222        mat.set(n - 1, n - 1, 2.0);
223        mat.set(n - 1, n - 2, 0.0);
224        rhs[n - 1] = 0.0;
225
226        // `b` holds the (scaled) second derivatives at the knots.
227        let b = mat.lu_solve(&rhs);
228
229        // Derive the cubic and linear coefficients from the second derivatives.
230        let mut a = vec![0.0; n];
231        let mut c = vec![0.0; n];
232        for i in 0..n - 1 {
233            a[i] = (b[i + 1] - b[i]) / (x[i + 1] - x[i]) / 3.0;
234            c[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i])
235                - (2.0 * b[i] + b[i + 1]) * (x[i + 1] - x[i]) / 3.0;
236        }
237
238        // quadratic-extrapolation coefficients (default)
239        //
240        // Below the first knot: zero curvature (`b0 = 0`) and the spline's slope
241        // there, i.e. a straight continuation. Above the last knot: continue with
242        // the slope the last interval ends on, evaluated at its right edge.
243        let b0 = 0.0;
244        let c0 = c[0];
245        let h = x[n - 1] - x[n - 2];
246        a[n - 1] = 0.0;
247        c[n - 1] = 3.0 * a[n - 2] * h * h + 2.0 * b[n - 2] * h + c[n - 2];
248
249        Self {
250            x,
251            y,
252            a,
253            b,
254            c,
255            b0,
256            c0,
257        }
258    }
259
260    /// `m_x[idx] <= x`, with `idx = 0` even when `x < m_x[0]`.
261    ///
262    /// Binary search rather than a scan: `eval` is called for every position of
263    /// every transcript.
264    #[inline]
265    fn closest_idx_to(&self, x: f64) -> usize {
266        // lower_bound: first element >= x
267        let it = self.x.partition_point(|&v| v < x);
268        if it == 0 {
269            0
270        } else {
271            it - 1
272        }
273    }
274
275    /// Evaluate the spline at `x` (interpolating, or extrapolating quadratically
276    /// outside `[x_0, x_{n-1}]`).
277    ///
278    /// The nesting `((a·h + b)·h + c)·h + y` is Horner's rule: the same polynomial
279    /// with three multiplications instead of six, and better rounding behaviour.
280    pub fn eval(&self, x: f64) -> f64 {
281        let n = self.x.len();
282        let idx = self.closest_idx_to(x);
283        // Offset from the left knot of the containing interval.
284        let h = x - self.x[idx];
285        if x < self.x[0] {
286            // Below the range: quadratic continuation from the first knot.
287            (self.b0 * h + self.c0) * h + self.y[0]
288        } else if x > self.x[n - 1] {
289            // Above the range: quadratic continuation from the last knot.
290            (self.b[n - 1] * h + self.c[n - 1]) * h + self.y[n - 1]
291        } else {
292            ((self.a[idx] * h + self.b[idx]) * h + self.c[idx]) * h + self.y[idx]
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// The defining property of interpolation: the curve must pass exactly
302    /// through every point it was built from.
303    #[test]
304    fn interpolates_knots_exactly() {
305        let xs = vec![0.0, 1.0, 2.0, 3.0, 4.0];
306        let ys = vec![0.0, 1.0, 4.0, 9.0, 16.0]; // ~ x^2
307        let s = Spline::new(xs.clone(), ys.clone());
308        for (x, y) in xs.iter().zip(&ys) {
309            assert!(
310                (s.eval(*x) - y).abs() < 1e-9,
311                "knot {x}: {} != {y}",
312                s.eval(*x)
313            );
314        }
315    }
316
317    /// Between the knots too: a straight line has zero curvature everywhere, so a
318    /// natural cubic spline must reproduce it exactly — which catches a sign or
319    /// scaling error in the coefficients that knot-only checks would miss.
320    #[test]
321    fn monotone_line_is_reproduced() {
322        // a straight line should be reproduced exactly by a natural cubic spline
323        let xs = vec![0.0, 0.25, 0.5, 0.75, 1.0];
324        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
325        let s = Spline::new(xs, ys);
326        for i in 0..=10 {
327            let x = i as f64 / 10.0;
328            assert!((s.eval(x) - (2.0 * x + 1.0)).abs() < 1e-9, "x={x}");
329        }
330    }
331
332    /// Out-of-range evaluation must take the extrapolation branches rather than
333    /// indexing out of bounds; the values themselves are unconstrained.
334    #[test]
335    fn extrapolates_without_panic() {
336        let xs = vec![0.0, 0.5, 1.0];
337        let ys = vec![1.0, 2.0, 1.0];
338        let s = Spline::new(xs, ys);
339        let _ = s.eval(-0.3);
340        let _ = s.eval(1.7);
341    }
342}