Skip to main content

legume_numeric/param/
dmatrix_gamma.rs

1#![allow(dead_code)]
2
3extern crate special;
4
5use crate::param::io::*;
6use crate::param::traits::*;
7use nalgebra::{DMatrix, DVector};
8use rayon::prelude::*;
9
10#[derive(Debug, Clone)]
11pub struct GammaMatrix {
12    num_rows: usize,
13    num_columns: usize,
14    //////////////////////
15    // hyper parameters //
16    //////////////////////
17    a0: f32,
18    b0: f32,
19    /// Per-row prior `(a0, b0)`, overriding the scalar pair when set.
20    /// Lengths equal `num_rows`. See [`Self::with_row_prior`].
21    row_prior: Option<(DVector<f32>, DVector<f32>)>,
22    ///////////////////////////
23    // sufficient statistics //
24    ///////////////////////////
25    a_stat: DMatrix<f32>,
26    b_stat: DMatrix<f32>,
27    //////////////////////////
28    // estimated parameters //
29    //////////////////////////
30    estimated_mean: DMatrix<f32>,
31    estimated_sd: DMatrix<f32>,
32    estimated_log_mean: DMatrix<f32>,
33    estimated_log_sd: DMatrix<f32>,
34}
35
36impl ParamIo for GammaMatrix {
37    type Mat = DMatrix<f32>;
38}
39
40impl TwoStatParam for GammaMatrix {
41    type Mat = DMatrix<f32>;
42    type Scalar = f32;
43
44    fn new(dims: (usize, usize), a: Self::Scalar, b: Self::Scalar) -> Self {
45        Self {
46            num_rows: dims.0,
47            num_columns: dims.1,
48            a0: a,
49            b0: b,
50            row_prior: None,
51            a_stat: DMatrix::from_element(dims.0, dims.1, a),
52            b_stat: DMatrix::from_element(dims.0, dims.1, b),
53            // `estimated_mean` is eager: the coordinate descent reads
54            // `posterior_mean()` before the first calibration (relying on a
55            // zero start), and it gets allocated immediately anyway.
56            estimated_mean: DMatrix::zeros(dims.0, dims.1),
57            // The sd / log_mean / log_sd planes are lazily allocated by
58            // `map_calibrate_*` (via `calibrate_with`). An iterative fit that
59            // only reads `posterior_mean()` (calibrating `MeanOnly`) never
60            // pays for them — they're materialized only when output needs
61            // them (a calibrate with `All` / `MeanAndLogMean`).
62            estimated_sd: DMatrix::zeros(0, 0),
63            estimated_log_mean: DMatrix::zeros(0, 0),
64            estimated_log_sd: DMatrix::zeros(0, 0),
65        }
66    }
67
68    fn add_stat(&mut self, add_a: &Self::Mat, add_b: &Self::Mat) {
69        self.a_stat += add_a;
70        self.b_stat += add_b;
71    }
72    fn update_stat(&mut self, update_a: &Self::Mat, update_b: &Self::Mat) {
73        self.reset_stat();
74        self.add_stat(update_a, update_b);
75    }
76    fn reset_stat(&mut self) {
77        match &self.row_prior {
78            None => {
79                self.a_stat.fill(self.a0);
80                self.b_stat.fill(self.b0);
81            }
82            Some((a0, b0)) => {
83                // Column-wise copies are contiguous in column-major storage and
84                // are a no-op on a released (0 x 0) plane.
85                for mut col in self.a_stat.column_iter_mut() {
86                    col.copy_from(a0);
87                }
88                for mut col in self.b_stat.column_iter_mut() {
89                    col.copy_from(b0);
90                }
91            }
92        }
93    }
94    fn update_stat_col(&mut self, update_a: &Self::Mat, update_b: &Self::Mat, k: usize) {
95        match &self.row_prior {
96            None => {
97                self.a_stat
98                    .column_mut(k)
99                    .copy_from(&update_a.map(|x| x + self.a0));
100                self.b_stat
101                    .column_mut(k)
102                    .copy_from(&update_b.map(|x| x + self.b0));
103            }
104            Some((a0, b0)) => {
105                let mut a = self.a_stat.column_mut(k);
106                a.copy_from(update_a);
107                a += a0;
108                let mut b = self.b_stat.column_mut(k);
109                b.copy_from(update_b);
110                b += b0;
111            }
112        }
113    }
114
115    // fn nrows(&self) -> usize {
116    //     self.num_rows
117    // }
118
119    // fn ncols(&self) -> usize {
120    //     self.num_columns
121    // }
122
123    // fn len(&self) -> usize {
124    //     self.num_rows * self.num_columns
125    // }
126    fn map_calibrate_mean(&mut self) {
127        self.estimated_mean = self.a_stat.zip_map(&self.b_stat, |a, b| a / b);
128    }
129    fn map_calibrate_sd(&mut self) {
130        self.estimated_sd = self.a_stat.zip_map(&self.b_stat, |a, b| a.sqrt() / b);
131    }
132    fn map_calibrate_log_mean(&mut self) {
133        use special::Gamma;
134        self.estimated_log_mean = self
135            .a_stat
136            .zip_map(&self.b_stat, |a, b| a.digamma() - b.ln());
137    }
138    fn map_calibrate_log_sd(&mut self) {
139        // `sd[ln X] = sqrt(trigamma(a))` exactly, for `X ~ Gamma(a, b)` — note it
140        // does not depend on the rate, which is why `b_stat` plays no part here.
141        //
142        // This replaced `1/sqrt(a - 1)`, the large-`a` asymptote, which was
143        // wrong in the regime that dominates sparse count data: 46% high at
144        // `a = 1.5`, 21% high at `a = 2.2` (a typical detected feature), and
145        // agreeing only past `a ~ 100`. Below `a = 1` it has no real value at
146        // all, and the old code returned 0 there — i.e. it reported PERFECT
147        // certainty for a feature with no counts, whose posterior is the prior
148        // and whose true `sd` is the largest in the matrix (1.283 at `a = 1`).
149        // Anything reading `log_sd` as a precision was being handed the
150        // inversion of the truth.
151        use special::Gamma;
152        self.estimated_log_sd = self.a_stat.map(|a| a.trigamma().sqrt());
153    }
154}
155
156impl Inference for GammaMatrix {
157    type Mat = DMatrix<f32>;
158    type Scalar = f32;
159
160    fn posterior_mean(&self) -> &Self::Mat {
161        &self.estimated_mean
162    }
163
164    fn posterior_sd(&self) -> &Self::Mat {
165        &self.estimated_sd
166    }
167
168    fn posterior_log_mean(&self) -> &Self::Mat {
169        &self.estimated_log_mean
170    }
171
172    fn posterior_log_sd(&self) -> &Self::Mat {
173        &self.estimated_log_sd
174    }
175
176    fn posterior_sample(&self) -> anyhow::Result<Self::Mat> {
177        use rand_distr::{Distribution, Gamma};
178        let eps = 1e-8;
179
180        let sampled = self
181            .a_stat
182            .as_slice()
183            .par_iter()
184            .zip(self.b_stat.as_slice().par_iter())
185            .map_init(rand::rng, |rng, (&a, &b)| -> anyhow::Result<f32> {
186                let shape = a + eps;
187                let scale = (b + eps).recip();
188                let pdf = Gamma::new(shape, scale)?;
189                Ok(pdf.sample(rng))
190            })
191            .collect::<anyhow::Result<Vec<_>>>()?;
192
193        Ok(Self::Mat::from_vec(self.nrows(), self.ncols(), sampled))
194    }
195
196    fn posterior_sample_seeded(&self, seed: u64) -> anyhow::Result<Self::Mat> {
197        let sampled = super::traits::gamma_sample_seeded(
198            self.a_stat.as_slice(),
199            self.b_stat.as_slice(),
200            seed,
201        )?;
202        Ok(Self::Mat::from_vec(self.nrows(), self.ncols(), sampled))
203    }
204
205    fn posterior_log_sample(&self, seed: u64) -> anyhow::Result<Self::Mat> {
206        use rand::rngs::SmallRng;
207        use rand::SeedableRng;
208        use rand_distr::{Distribution, StandardNormal};
209
210        // Fixed chunk width, so which elements share an RNG is a property of
211        // the data shape and not of how rayon happened to split the work.
212        const CHUNK: usize = 1024;
213        let m_slice = self.estimated_log_mean.as_slice();
214        let s_slice = self.estimated_log_sd.as_slice();
215        let mut sampled = vec![0.0f32; m_slice.len()];
216        sampled
217            .par_chunks_mut(CHUNK)
218            .enumerate()
219            .for_each(|(ci, out)| {
220                let mut rng =
221                    SmallRng::seed_from_u64(seed ^ (ci as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
222                let base = ci * CHUNK;
223                for (k, o) in out.iter_mut().enumerate() {
224                    let z: f32 = StandardNormal.sample(&mut rng);
225                    *o = m_slice[base + k] + s_slice[base + k] * z;
226                }
227            });
228
229        Ok(Self::Mat::from_vec(self.nrows(), self.ncols(), sampled))
230    }
231
232    fn nrows(&self) -> usize {
233        self.num_rows
234    }
235
236    fn ncols(&self) -> usize {
237        self.num_columns
238    }
239}
240
241/// Row-stack one plane across blocks. Returns an empty matrix (and skips
242/// the work) when `enabled` is false or the plane is lazily-unallocated in
243/// the first block, so empty/dropped planes never get materialized.
244fn stack_field<F>(
245    blocks: &[GammaMatrix],
246    nrows: usize,
247    ncols: usize,
248    enabled: bool,
249    sel: F,
250) -> DMatrix<f32>
251where
252    F: Fn(&GammaMatrix) -> &DMatrix<f32>,
253{
254    if !enabled || blocks.is_empty() || sel(&blocks[0]).nrows() == 0 {
255        return DMatrix::zeros(0, 0);
256    }
257    let mut out = DMatrix::zeros(nrows, ncols);
258    let mut r0 = 0;
259    for b in blocks {
260        let src = sel(b);
261        out.rows_mut(r0, src.nrows()).copy_from(src);
262        r0 += src.nrows();
263    }
264    out
265}
266
267impl GammaMatrix {
268    /// Drop the sufficient-stat planes (`a_stat` / `b_stat`) after
269    /// calibration, keeping only the posterior estimates. Use when the
270    /// consumer reads posterior means / log-means but never
271    /// `posterior_sample` (which is the only reader of `a_stat`/`b_stat`).
272    /// Halves the resident footprint of a calibrated parameter.
273    pub fn release_stats(&mut self) {
274        self.a_stat = DMatrix::zeros(0, 0);
275        self.b_stat = DMatrix::zeros(0, 0);
276    }
277
278    /// Zero every `estimated_mean` entry whose corresponding `numerator` is
279    /// zero, collapsing the per-column Gamma prior baseline (`a0/denom`,
280    /// present at *every* unobserved cell) to exact zero. This lets a
281    /// downstream triplet-ization of the mean be **sparse** — only the
282    /// observed support survives. It's the lossy-but-correct choice for
283    /// count-based consumers (the baseline is a regularization floor, not
284    /// signal). `numerator` must match the mean's shape; only meaningful
285    /// after a mean calibration.
286    pub fn sparsify_mean_to_support(&mut self, numerator: &DMatrix<f32>) {
287        debug_assert_eq!(self.estimated_mean.shape(), numerator.shape());
288        self.estimated_mean
289            .iter_mut()
290            .zip(numerator.iter())
291            .for_each(|(m, &n)| {
292                if n == 0.0 {
293                    *m = 0.0;
294                }
295            });
296    }
297
298    /// Whether the posterior at `(row, col)` carries any data beyond the
299    /// prior: `a_stat > a0`. The read-only counterpart of
300    /// [`Self::sparsify_mean_to_support`], for consumers that serialize the
301    /// mean without owning the numerator — an unsupported entry's mean is the
302    /// prior floor `a0 / (b0 + denom)`, which is regularization, not signal.
303    /// Writing those floors out turns a sparse posterior dense: a carried
304    /// pseudobulk reference measured 100.0% dense (34M of 34M entries) before
305    /// its writer checked this.
306    #[must_use]
307    pub fn has_data_support(&self, row: usize, col: usize) -> bool {
308        self.a_stat[(row, col)] > self.a0_at(row)
309    }
310
311    /// The unregularized rate at `(row, col)`: `(a_stat − a0) / (b_stat − b0)`
312    /// — data sum over data denominator, no prior in either. Zero when the
313    /// entry has no data support.
314    ///
315    /// This is what a *serialized* posterior should usually store: paired with
316    /// its denominator, it is a bijection of the sufficient statistics, so a
317    /// consumer reconstructs `a_stat`/`b_stat` exactly. The posterior mean
318    /// `(a0 + sum)/(b0 + n)` is the right *estimate* but the wrong *carrier* —
319    /// its prior shrinkage (1.85× at `sum = 1, n = 12`) gets re-ingested as if
320    /// it were data, and a second posterior forms around an already-shrunk
321    /// value.
322    #[must_use]
323    pub fn evidence_mean(&self, row: usize, col: usize) -> f32 {
324        let a = self.a_stat[(row, col)] - self.a0_at(row);
325        let b = self.b_stat[(row, col)] - self.b0_at(row);
326        if a > 0.0 && b > 0.0 {
327            a / b
328        } else {
329            0.0
330        }
331    }
332
333    /// Prior shape for `row`: its row prior when set, else the scalar `a0`.
334    #[inline]
335    fn a0_at(&self, row: usize) -> f32 {
336        self.row_prior.as_ref().map_or(self.a0, |(a, _)| a[row])
337    }
338
339    /// Prior rate for `row`: its row prior when set, else the scalar `b0`.
340    #[inline]
341    fn b0_at(&self, row: usize) -> f32 {
342        self.row_prior.as_ref().map_or(self.b0, |(_, b)| b[row])
343    }
344
345    /// A matrix whose row `d` has prior `Gamma(a0[d], b0[d])`. The statistics
346    /// start at the prior, as with [`TwoStatParam::new`].
347    pub fn with_row_prior(dims: (usize, usize), a0: &DVector<f32>, b0: &DVector<f32>) -> Self {
348        let mut out = Self::new(dims, 0.0, 0.0);
349        out.set_row_prior(a0, b0);
350        out.reset_stat();
351        out
352    }
353
354    /// Replace the per-row prior. Accumulated statistics are left as they
355    /// are; the new prior applies at the next `reset_stat` / `update_stat`.
356    pub fn set_row_prior(&mut self, a0: &DVector<f32>, b0: &DVector<f32>) {
357        assert_eq!(a0.len(), self.num_rows, "row prior a0 length != rows");
358        assert_eq!(b0.len(), self.num_rows, "row prior b0 length != rows");
359        self.row_prior = Some((a0.clone(), b0.clone()));
360    }
361
362    /// The per-row prior, if one is set.
363    #[must_use]
364    pub fn row_prior(&self) -> Option<(&DVector<f32>, &DVector<f32>)> {
365        self.row_prior.as_ref().map(|(a, b)| (a, b))
366    }
367
368    /// Row-stack per-feature-block parameters (from a gene-blocked fit)
369    /// into one `[Σrowsᵢ × K]` parameter. All blocks must share the column
370    /// count and either share the scalar hyper-params or all carry a row
371    /// prior, in which case the row priors are concatenated. Calibrated
372    /// planes present in the first block
373    /// are stacked; lazily-empty planes stay empty. `stack_stats` controls
374    /// whether `a_stat`/`b_stat` are carried through — pass `false` when the
375    /// output only needs posterior estimates, so the heavy sufficient-stat
376    /// planes are never assembled at full width.
377    pub fn vconcat(blocks: Vec<GammaMatrix>, stack_stats: bool) -> Self {
378        assert!(!blocks.is_empty(), "vconcat of empty block list");
379        let ncols = blocks[0].num_columns;
380        let a0 = blocks[0].a0;
381        let b0 = blocks[0].b0;
382        let nrows: usize = blocks.iter().map(|b| b.num_rows).sum();
383        let row_prior = if blocks[0].row_prior.is_some() {
384            assert!(
385                blocks.iter().all(|b| b.row_prior.is_some()),
386                "vconcat: blocks mix scalar and row priors"
387            );
388            let a = DVector::from_iterator(
389                nrows,
390                blocks
391                    .iter()
392                    .flat_map(|b| b.row_prior.as_ref().expect("checked").0.iter().copied()),
393            );
394            let b = DVector::from_iterator(
395                nrows,
396                blocks
397                    .iter()
398                    .flat_map(|b| b.row_prior.as_ref().expect("checked").1.iter().copied()),
399            );
400            Some((a, b))
401        } else {
402            assert!(
403                blocks
404                    .iter()
405                    .all(|b| b.row_prior.is_none() && b.a0 == a0 && b.b0 == b0),
406                "vconcat: blocks must share hyper-params"
407            );
408            None
409        };
410        let a_stat = stack_field(&blocks, nrows, ncols, stack_stats, |g| &g.a_stat);
411        let b_stat = stack_field(&blocks, nrows, ncols, stack_stats, |g| &g.b_stat);
412        let estimated_mean = stack_field(&blocks, nrows, ncols, true, |g| &g.estimated_mean);
413        let estimated_sd = stack_field(&blocks, nrows, ncols, true, |g| &g.estimated_sd);
414        let estimated_log_mean =
415            stack_field(&blocks, nrows, ncols, true, |g| &g.estimated_log_mean);
416        let estimated_log_sd = stack_field(&blocks, nrows, ncols, true, |g| &g.estimated_log_sd);
417        Self {
418            num_rows: nrows,
419            num_columns: ncols,
420            a0,
421            b0,
422            row_prior,
423            a_stat,
424            b_stat,
425            estimated_mean,
426            estimated_sd,
427            estimated_log_mean,
428            estimated_log_sd,
429        }
430    }
431}