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}