Skip to main content

legume_numeric/matrix/
running_quantile.rs

1//! Online quantile estimation via the **P² algorithm** (Jain & Chlamtac, 1985):
2//! an arbitrary target quantile from a stream using five markers, O(1) memory,
3//! no stored samples. Companion to the running mean/variance in
4//! [`crate::matrix::traits::RunningStatOps`] for cases (e.g. MCMC credible intervals)
5//! where storing every draw to sort for a quantile is too expensive.
6
7/// Single-quantile P² estimator for a stream of scalars.
8///
9/// ```
10/// use legume_numeric::matrix::running_quantile::P2Quantile;
11/// let mut med = P2Quantile::new(0.5);
12/// for i in 0..1000 { med.add(f64::from(i)); }
13/// assert!((med.quantile() - 500.0).abs() < 25.0);
14/// ```
15#[derive(Clone, Debug)]
16pub struct P2Quantile {
17    p: f64,
18    /// First ≤5 observations, buffered until the markers are initialized.
19    init: Vec<f64>,
20    q: [f64; 5],  // marker heights
21    n: [f64; 5],  // marker positions (integer-valued)
22    np: [f64; 5], // desired marker positions
23    dn: [f64; 5], // desired-position increments
24    count: usize,
25}
26
27impl P2Quantile {
28    #[must_use]
29    pub fn new(p: f64) -> Self {
30        let p = p.clamp(0.0, 1.0);
31        Self {
32            p,
33            init: Vec::with_capacity(5),
34            q: [0.0; 5],
35            n: [1.0, 2.0, 3.0, 4.0, 5.0],
36            np: [1.0, 1.0 + 2.0 * p, 1.0 + 4.0 * p, 3.0 + 2.0 * p, 5.0],
37            dn: [0.0, p / 2.0, p, (1.0 + p) / 2.0, 1.0],
38            count: 0,
39        }
40    }
41
42    pub fn add(&mut self, x: f64) {
43        self.count += 1;
44        if self.count <= 5 {
45            self.init.push(x);
46            if self.count == 5 {
47                self.init.sort_by(f64::total_cmp);
48                self.q.copy_from_slice(&self.init);
49            }
50            return;
51        }
52        // 1. Locate the cell k containing x, adjusting the extreme markers.
53        let k = if x < self.q[0] {
54            self.q[0] = x;
55            0
56        } else if x >= self.q[4] {
57            self.q[4] = x;
58            3
59        } else {
60            (0..4)
61                .find(|&i| self.q[i] <= x && x < self.q[i + 1])
62                .unwrap_or(3)
63        };
64        // 2. Increment positions of the markers above the cell.
65        for i in (k + 1)..5 {
66            self.n[i] += 1.0;
67        }
68        // 3. Advance the desired positions.
69        for i in 0..5 {
70            self.np[i] += self.dn[i];
71        }
72        // 4. Adjust the three interior markers (parabolic, or linear if it
73        //    would break monotonicity).
74        for i in 1..4 {
75            let d = self.np[i] - self.n[i];
76            let go_up = d >= 1.0 && (self.n[i + 1] - self.n[i]) > 1.0;
77            let go_dn = d <= -1.0 && (self.n[i - 1] - self.n[i]) < -1.0;
78            if go_up || go_dn {
79                let ds = if d >= 0.0 { 1.0 } else { -1.0 };
80                let qp = self.parabolic(i, ds);
81                self.q[i] = if self.q[i - 1] < qp && qp < self.q[i + 1] {
82                    qp
83                } else {
84                    self.linear(i, ds)
85                };
86                self.n[i] += ds;
87            }
88        }
89    }
90
91    fn parabolic(&self, i: usize, d: f64) -> f64 {
92        let (q, n) = (&self.q, &self.n);
93        q[i] + d / (n[i + 1] - n[i - 1])
94            * ((n[i] - n[i - 1] + d) * (q[i + 1] - q[i]) / (n[i + 1] - n[i])
95                + (n[i + 1] - n[i] - d) * (q[i] - q[i - 1]) / (n[i] - n[i - 1]))
96    }
97
98    fn linear(&self, i: usize, d: f64) -> f64 {
99        let j = if d > 0.0 { i + 1 } else { i - 1 };
100        self.q[i] + d * (self.q[j] - self.q[i]) / (self.n[j] - self.n[i])
101    }
102
103    /// Current estimate of the target quantile.
104    #[must_use]
105    pub fn quantile(&self) -> f64 {
106        if self.count == 0 {
107            return f64::NAN;
108        }
109        if self.count < 5 {
110            let mut v = self.init.clone();
111            v.sort_by(f64::total_cmp);
112            let idx = (((v.len() - 1) as f64) * self.p).round() as usize;
113            return v[idx.min(v.len() - 1)];
114        }
115        self.q[2]
116    }
117
118    #[must_use]
119    pub fn count(&self) -> usize {
120        self.count
121    }
122}
123
124/// Per-row P² trackers for a set of target quantiles, fed one dense column
125/// (observation vector) at a time — mirrors the column-add convention of
126/// [`crate::matrix::sparse_stat::SparseRunningStatistics`]. Row `r`'s estimate of the
127/// `qi`-th quantile is `quantile(qi)[r]`.
128pub struct RunningQuantiles {
129    nrows: usize,
130    quantiles: Vec<f64>,
131    est: Vec<P2Quantile>, // row-major: [row * n_quantiles + qi]
132}
133
134impl RunningQuantiles {
135    #[must_use]
136    pub fn new(nrows: usize, quantiles: &[f64]) -> Self {
137        let est = (0..nrows)
138            .flat_map(|_| quantiles.iter().map(|&p| P2Quantile::new(p)))
139            .collect();
140        Self {
141            nrows,
142            quantiles: quantiles.to_vec(),
143            est,
144        }
145    }
146
147    /// Feed one observation vector (length `nrows`).
148    pub fn add_dense_column(&mut self, values: &[f32]) {
149        assert_eq!(values.len(), self.nrows, "column length != nrows");
150        let nq = self.quantiles.len();
151        for (row, &v) in values.iter().enumerate() {
152            for qi in 0..nq {
153                self.est[row * nq + qi].add(f64::from(v));
154            }
155        }
156    }
157
158    /// Per-row estimate of the `qi`-th target quantile.
159    #[must_use]
160    pub fn quantile(&self, qi: usize) -> Vec<f32> {
161        let nq = self.quantiles.len();
162        (0..self.nrows)
163            .map(|row| self.est[row * nq + qi].quantile() as f32)
164            .collect()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // Deterministic near-uniform permutation of 0..m so the empirical CDF is
173    // known: the p-quantile is ≈ p·m.
174    fn perm(m: u64) -> impl Iterator<Item = f64> {
175        (0..m).map(move |i| ((i.wrapping_mul(7919)) % m) as f64)
176    }
177
178    #[test]
179    fn recovers_quantiles_within_tolerance() {
180        let m = 10_007u64;
181        for &p in &[0.05, 0.25, 0.5, 0.75, 0.95] {
182            let mut est = P2Quantile::new(p);
183            for x in perm(m) {
184                est.add(x);
185            }
186            let want = p * m as f64;
187            let got = est.quantile();
188            assert!(
189                (got - want).abs() < 0.02 * m as f64,
190                "p={p}: got {got:.1}, want ≈ {want:.1}"
191            );
192        }
193    }
194
195    #[test]
196    fn running_quantiles_per_row() {
197        // Two rows: row 0 ~ U(0,m), row 1 = row0 + m (shifted). Check both.
198        let m = 5_003u64;
199        let mut rq = RunningQuantiles::new(2, &[0.5]);
200        for x in perm(m) {
201            rq.add_dense_column(&[x as f32, x as f32 + m as f32]);
202        }
203        let med = rq.quantile(0);
204        assert!((med[0] - 0.5 * m as f32).abs() < 0.02 * m as f32);
205        assert!((med[1] - 1.5 * m as f32).abs() < 0.02 * m as f32);
206    }
207
208    #[test]
209    fn handles_fewer_than_five() {
210        let mut est = P2Quantile::new(0.5);
211        est.add(3.0);
212        est.add(1.0);
213        est.add(2.0);
214        assert!((est.quantile() - 2.0).abs() < 1e-9);
215    }
216}