Skip to main content

gam_models/
multinomial_predictive.rs

1//! Posterior-predictive class probabilities for the penalized multinomial
2//! logit, computed as a RATIO OF NORMALISING CONSTANTS.
3//!
4//! # Why this exists rather than integrating the Gaussian posterior
5//!
6//! The published estimand is the posterior mean probability
7//! `E[softmax(x'β) | data]`.  The obvious implementation — approximate the
8//! posterior of `β` by the Laplace Gaussian `N(β̂, H⁻¹)` and integrate `softmax`
9//! against it — is **not a valid approximation of that estimand**, and the
10//! failure is not small.
11//!
12//! Write the posterior mean of any positive functional `g(β)` as a ratio of
13//! integrals.  Laplace applied SEPARATELY to numerator and denominator (the
14//! "fully exponential" form of Tierney and Kadane) has its `O(n⁻¹)` errors
15//! cancel between the two, leaving `O(n⁻²)`.  Integrating `g` against the
16//! Gaussian instead keeps only the CURVATURE half of the `O(n⁻¹)` correction
17//! (`½ tr(H⁻¹ ∇²g)`) and silently drops the SKEWNESS half, which comes from the
18//! third derivative of the log-posterior.  On a well-conditioned fit the two
19//! halves are both small and nobody notices.  On a (quasi-)separated
20//! multinomial they are not small and they have opposite signs: the likelihood
21//! is flat toward more separation and steep away from it, so the true posterior
22//! is strongly skewed toward LARGER `|η|`, while the symmetric Gaussian puts
23//! half of its mass on the side the likelihood has already excluded.  `softmax`
24//! is concave along the winning coordinate, so that misplaced mass converts
25//! directly into under-confidence: right argmax, flattened probabilities.
26//!
27//! For `g = p_c(x) = P(new row at x is class c | β)` the ratio is not merely a
28//! device — it is exactly the posterior predictive, because the extra row's
29//! likelihood factor IS the functional being averaged:
30//!
31//! ```text
32//!     E[p_c(x) | D]  =  Z(D ∪ {(x, c)}) / Z(D)
33//! ```
34//!
35//! with `Z` the posterior normalising constant.  Approximating each `Z` by
36//! Laplace at its own mode gives
37//!
38//! ```text
39//!     E[p_c(x)] ≈ exp( L⁺(β̂⁺) − L(β̂) ) · sqrt( det H / det H⁺ )
40//! ```
41//!
42//! where `L` is the penalized log-posterior, `β̂⁺` the mode with the extra row
43//! present, and `H`, `H⁺` the corresponding negative Hessians.  The `(2π)^{d/2}`
44//! factors cancel exactly (same dimension on both sides).
45//!
46//! The identity `Σ_c E[p_c(x)] = 1` is exact for the true integrals, so the
47//! deviation of the computed `Σ_c` from one is a MEASURED accuracy statement
48//! about this approximation, available at every prediction row and requiring no
49//! reference. [`MultinomialPredictiveModel`] refuses rather than publishing a
50//! row whose mass defect exceeds [`PREDICTIVE_MASS_DEFECT_TOLERANCE`].
51//!
52//! The same machinery supplies the second moments the standard-error surface
53//! consumes, with two extra rows instead of one:
54//!
55//! ```text
56//!     E[p_c(x) · p_d(x)]  =  Z(D ∪ {(x, c), (x, d)}) / Z(D)
57//! ```
58//!
59//! # Cost
60//!
61//! One warm-started Newton solve per (row, class) — the augmented objective is
62//! strictly convex, so Newton with backtracking is unconditionally safe — plus
63//! `K(K+1)/2` more per row when second moments are requested.  Each Newton
64//! iteration is `O(n·M²·P²)` for the curvature (as `M(M+1)/2` GEMMs) and
65//! `O(d³)` for the factorisation, so the whole predictive is
66//! `O(R·K·iters·(n M² P² + d³))`.
67//!
68//! On the fixture this exists for that is a large improvement, not a cost: the
69//! Smolyak integrator it replaces spent ~930 s on one penguins prediction
70//! block, because its level requirement grows with exactly the posterior width
71//! that makes the Gaussian wrong in the first place, while the same block here
72//! is `n = 228`, `P = 37`, `M = 2` — under a second. The scaling is different
73//! in kind, though, and worth stating plainly: this method's cost grows with
74//! the TRAINING size, which the Gaussian route's did not, because evaluating a
75//! posterior away from its mode is what the Gaussian route was avoiding by
76//! being wrong.
77
78use crate::model_types::EstimationError;
79use gam_linalg::faer_ndarray::FaerCholesky;
80use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2};
81
82/// Largest tolerated deviation of `Σ_c E[p_c(x)]` from one before a prediction
83/// row is refused.
84///
85/// This is not a fudge factor on the answer: the sum is an EXACT identity of the
86/// estimand, so its deviation is the approximation's own error, measured at the
87/// row being published.
88///
89/// The value is set from what the defect is worth as a predictor of the error
90/// that survives renormalisation, which is measured rather than assumed. Two
91/// independent fixtures:
92///
93/// ```text
94///   K = 3, p = 10, asymmetric quasi-separated, MCMC truth
95///       worst-row defect 1.37e-2   worst-row error after normalising 4.4e-3   (3.1x)
96///   K = 2, p = 2,  quasi-separated, exact 2-D quadrature truth
97///       worst-row defect 4.93e-3   worst-row error after normalising 5.2e-4   (9.5x)
98/// ```
99///
100/// So the defect OVER-states the published error by roughly 3-10x, and a bar at
101/// `5e-2` refuses where the published probability would be wrong by more than
102/// about `1e-2` — the second decimal of a probability, which is the right place
103/// to stop publishing one. A tighter bar would refuse rows whose answers are
104/// good to four decimals; a looser one would publish a probability wrong in its
105/// first. A refusal here is a real statement — the posterior at that row is not
106/// described by either Laplace expansion well enough — and it is louder and more
107/// useful than a number nobody can bound.
108pub const PREDICTIVE_MASS_DEFECT_TOLERANCE: f64 = 5.0e-2;
109
110/// Convergence target for the augmented-mode Newton solve, stated as the NEWTON
111/// DECREMENT `½ gᵀH⁻¹g` — the quadratic model's own bound on how much
112/// log-posterior is left to gain.
113///
114/// A gradient-norm target would be the wrong currency here twice over. It is
115/// not scale-free (the gradient of an `n`-row log-likelihood is `O(n)`, so the
116/// same threshold means different things on different fixtures), and it is not
117/// the quantity the answer depends on: every ratio this module publishes is
118/// `exp(L⁺ − L)`, so what has to be small is the residual error in `L`, which
119/// is exactly what the decrement bounds. At `1e-10` the ratio is converged to
120/// `1e-10` relative — five orders below the `1e-2` mass defect the estimator's
121/// own identity is checked at, so the solve is never the binding error.
122const AUGMENTED_MODE_DECREMENT_TOLERANCE: f64 = 1.0e-10;
123
124/// How far the base-mode polish may move the supplied coefficients, relative to
125/// their own largest magnitude, before the predictive refuses.
126///
127/// The polish exists so the base of every ratio is exactly a stationary point of
128/// the objective the ratios are taken against; on a fit whose mode was found
129/// under that same objective it moves the coefficients by the solver's own
130/// residual, which is orders below this. A LARGE move means the supplied mode
131/// belongs to a different objective, and the bar is set where "solver slack" and
132/// "different model" cannot be confused: an inner solve certified at a scaled
133/// KKT residual of `1e-5` leaves a mode displacement far under `1e-3` of the
134/// coefficient scale, while a first-order objective difference in a
135/// near-unpenalized direction moves it by `O(1)`.
136const BASE_MODE_POLISH_TOLERANCE: f64 = 1.0e-3;
137
138/// Maximum Newton iterations for one augmented mode. The objective is strictly
139/// convex and the start point is the un-augmented mode, one observation away, so
140/// this bound is never approached on a well-posed fit; it exists so a
141/// pathological row fails loudly instead of spinning.
142const AUGMENTED_MODE_MAX_ITERATIONS: usize = 100;
143
144/// Backtracking line-search contraction factor and its iteration bound.
145const LINE_SEARCH_CONTRACTION: f64 = 0.5;
146const LINE_SEARCH_MAX_STEPS: usize = 60;
147
148/// The training data and penalty a saved multinomial model needs in order to
149/// evaluate its own log-posterior away from the mode.
150///
151/// A Laplace SUMMARY (`β̂`, `H⁻¹`) is not enough to compute a posterior mean:
152/// the summary is precisely the quadratic model whose inadequacy is the defect.
153/// The predictive therefore needs the likelihood itself, which means the rows.
154#[derive(Debug, Clone, Copy)]
155pub struct MultinomialPredictiveModel<'a> {
156    /// Training design in the SAME (raw) basis as the saved coefficients and as
157    /// the design rebuilt for prediction, shape `(n, P)`.
158    pub training_design: ArrayView2<'a, f64>,
159    /// Training class index per row, values in `0..K`, aligned to
160    /// `class_levels`.
161    pub training_class_index: &'a [u32],
162    /// Training row weights, length `n`.
163    pub training_weights: ArrayView1<'a, f64>,
164    /// The joint penalty `S_λ` at the selected smoothing parameters, in the
165    /// stacked class-major coefficient order `θ[a·P + i] = β[i, a]`, shape
166    /// `(P·M, P·M)` with `M = K − 1`.
167    pub joint_penalty: ArrayView2<'a, f64>,
168    /// Total class count `K` (the reference class `K − 1` carries `η ≡ 0`).
169    pub n_classes: usize,
170}
171
172/// Posterior-predictive moments at a block of prediction rows.
173#[derive(Debug, Clone)]
174pub struct MultinomialPredictiveMoments {
175    /// `E[p_c(x)]`, shape `(R, K)`, rows summing to one.
176    pub class_mean: Array2<f64>,
177    /// `E[p_c(x) · p_d(x)]`, shape `(R, K, K)`, present only when second
178    /// moments were requested.
179    pub class_second_moment: Option<Array3<f64>>,
180    /// Per-row `|Σ_c E[p_c] − 1|` BEFORE renormalisation — the approximation's
181    /// own measured error at that row.
182    pub mass_defect: Array1<f64>,
183}
184
185/// One extra observation appended to the training data: a design row and the
186/// class it is assigned.
187#[derive(Debug, Clone, Copy)]
188struct ExtraRow<'a> {
189    design: ArrayView1<'a, f64>,
190    class: usize,
191}
192
193impl<'a> MultinomialPredictiveModel<'a> {
194    fn active_classes(&self) -> usize {
195        self.n_classes.saturating_sub(1)
196    }
197
198    fn coefficient_dim(&self) -> usize {
199        self.training_design.ncols() * self.active_classes()
200    }
201
202    fn validate(&self) -> Result<(), EstimationError> {
203        let n = self.training_design.nrows();
204        let p = self.training_design.ncols();
205        let m = self.active_classes();
206        if self.n_classes < 2 {
207            crate::bail_invalid_estim!(
208                "multinomial predictive requires K >= 2 classes, got {}",
209                self.n_classes
210            );
211        }
212        if self.training_class_index.len() != n || self.training_weights.len() != n {
213            crate::bail_invalid_estim!(
214                "multinomial predictive training frame has {n} design rows, {} labels and {} \
215                 weights",
216                self.training_class_index.len(),
217                self.training_weights.len(),
218            );
219        }
220        if let Some(bad) = self
221            .training_class_index
222            .iter()
223            .find(|&&c| c as usize >= self.n_classes)
224        {
225            crate::bail_invalid_estim!(
226                "multinomial predictive training label {bad} is outside 0..{}",
227                self.n_classes
228            );
229        }
230        if self.training_weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
231            crate::bail_invalid_estim!(
232                "multinomial predictive training weights must be finite and non-negative"
233            );
234        }
235        if self.training_design.iter().any(|v| !v.is_finite()) {
236            crate::bail_invalid_estim!("multinomial predictive training design must be finite");
237        }
238        let d = p * m;
239        if self.joint_penalty.dim() != (d, d) {
240            crate::bail_invalid_estim!(
241                "multinomial predictive joint penalty is {}x{}, expected {d}x{d}",
242                self.joint_penalty.nrows(),
243                self.joint_penalty.ncols(),
244            );
245        }
246        if self.joint_penalty.iter().any(|v| !v.is_finite()) {
247            crate::bail_invalid_estim!("multinomial predictive joint penalty must be finite");
248        }
249        Ok(())
250    }
251
252    /// Softmax probabilities of one row's active logits, with the reference
253    /// class pinned at `η = 0`. Written with the max subtracted so a saturated
254    /// logit cannot overflow.
255    fn row_probabilities(&self, eta: &[f64], out: &mut [f64]) {
256        let shift = eta.iter().copied().fold(0.0_f64, f64::max);
257        let mut total = (-shift).exp();
258        for (a, &value) in eta.iter().enumerate() {
259            let e = (value - shift).exp();
260            out[a] = e;
261            total += e;
262        }
263        out[self.n_classes - 1] = (-shift).exp();
264        for value in out.iter_mut() {
265            *value /= total;
266        }
267    }
268
269    /// `log Σ_k exp(η_k)` over the active logits plus the pinned reference `0`.
270    fn row_log_partition(eta: &[f64]) -> f64 {
271        let shift = eta.iter().copied().fold(0.0_f64, f64::max);
272        let mut total = (-shift).exp();
273        for &value in eta {
274            total += (value - shift).exp();
275        }
276        shift + total.ln()
277    }
278
279    fn row_eta(&self, design_row: ArrayView1<'_, f64>, theta: &[f64], eta: &mut [f64]) {
280        let p = self.training_design.ncols();
281        for (a, slot) in eta.iter_mut().enumerate() {
282            let block = &theta[a * p..(a + 1) * p];
283            *slot = design_row
284                .iter()
285                .zip(block.iter())
286                .map(|(x, b)| x * b)
287                .sum::<f64>();
288        }
289    }
290
291    /// The objective this predictive integrates:
292    /// `ℓ(θ) − ½ θ' S_λ θ + cᵀθ`, optionally with extra observations appended.
293    ///
294    /// See [`Self::stationarity_tilt`] for what `c` is and why it is measured
295    /// rather than assumed.
296    fn log_posterior(&self, theta: &[f64], extra: &[ExtraRow<'_>], tilt: &[f64]) -> f64 {
297        let m = self.active_classes();
298        let mut eta = vec![0.0_f64; m];
299        let mut total = 0.0_f64;
300        for (row, &label) in self.training_class_index.iter().enumerate() {
301            let weight = self.training_weights[row];
302            if weight == 0.0 {
303                continue;
304            }
305            self.row_eta(self.training_design.row(row), theta, &mut eta);
306            let picked = if (label as usize) < m {
307                eta[label as usize]
308            } else {
309                0.0
310            };
311            total += weight * (picked - Self::row_log_partition(&eta));
312        }
313        for row in extra {
314            self.row_eta(row.design, theta, &mut eta);
315            let picked = if row.class < m { eta[row.class] } else { 0.0 };
316            total += picked - Self::row_log_partition(&eta);
317        }
318        let mut quadratic = 0.0_f64;
319        for (i, &ti) in theta.iter().enumerate() {
320            let mut acc = 0.0_f64;
321            for (j, &tj) in theta.iter().enumerate() {
322                acc += self.joint_penalty[[i, j]] * tj;
323            }
324            quadratic += ti * acc;
325        }
326        let linear: f64 = theta.iter().zip(tilt.iter()).map(|(t, c)| t * c).sum();
327        total - 0.5 * quadratic + linear
328    }
329
330    /// Gradient of the NEGATIVE penalized log-posterior and its Hessian, both
331    /// in the stacked class-major order.
332    ///
333    /// The `(a, b)` curvature block is `Xᵀ diag(w_ab) X` with
334    /// `w_ab[row] = weight · p_a (δ_ab − p_b)`, which is a GEMM. Accumulating it
335    /// row-by-row instead would be the same flops with none of the locality,
336    /// and this is the inner loop of every augmented mode: one per (prediction
337    /// row, class).
338    fn gradient_and_precision(
339        &self,
340        theta: &[f64],
341        extra: &[ExtraRow<'_>],
342        tilt: &[f64],
343    ) -> (Array1<f64>, Array2<f64>) {
344        let n = self.training_design.nrows();
345        let p = self.training_design.ncols();
346        let m = self.active_classes();
347        let d = p * m;
348        let mut gradient = Array1::<f64>::zeros(d);
349        let mut precision = self.joint_penalty.to_owned();
350        let mut eta = vec![0.0_f64; m];
351        let mut probs = vec![0.0_f64; self.n_classes];
352        // `curvature_weights[(row, a * m + b)]` is the row's contribution to the
353        // `(a, b)` block, kept as a column so each block is one GEMM.
354        let mut curvature_weights = Array2::<f64>::zeros((n, m * m));
355
356        for (row, &label) in self.training_class_index.iter().enumerate() {
357            let weight = self.training_weights[row];
358            if weight == 0.0 {
359                continue;
360            }
361            let design_row = self.training_design.row(row);
362            self.row_eta(design_row, theta, &mut eta);
363            self.row_probabilities(&eta, &mut probs);
364            let label = label as usize;
365            for a in 0..m {
366                let residual = weight * (probs[a] - if label == a { 1.0 } else { 0.0 });
367                for (i, &xi) in design_row.iter().enumerate() {
368                    gradient[a * p + i] += residual * xi;
369                }
370                for b in 0..m {
371                    let delta = if a == b { 1.0 } else { 0.0 };
372                    curvature_weights[[row, a * m + b]] = weight * probs[a] * (delta - probs[b]);
373                }
374            }
375        }
376
377        for a in 0..m {
378            for b in a..m {
379                let column = curvature_weights.column(a * m + b);
380                let mut scaled = self.training_design.to_owned();
381                for (mut design_row, &w) in scaled.rows_mut().into_iter().zip(column.iter()) {
382                    design_row.map_inplace(|value| *value *= w);
383                }
384                let block = self.training_design.t().dot(&scaled);
385                for i in 0..p {
386                    for j in 0..p {
387                        precision[[a * p + i, b * p + j]] += block[[i, j]];
388                        if a != b {
389                            // `w_ab = w·p_a(δ_ab − p_b)` is symmetric in `(a, b)`,
390                            // so the mirrored block is the transpose and does not
391                            // need its own GEMM.
392                            precision[[b * p + j, a * p + i]] += block[[i, j]];
393                        }
394                    }
395                }
396            }
397        }
398
399        // The extra observations are rank-`m` and there are at most two of them,
400        // so they are accumulated directly rather than through another GEMM.
401        for row in extra {
402            self.row_eta(row.design, theta, &mut eta);
403            self.row_probabilities(&eta, &mut probs);
404            for a in 0..m {
405                let residual = probs[a] - if row.class == a { 1.0 } else { 0.0 };
406                for (i, &xi) in row.design.iter().enumerate() {
407                    gradient[a * p + i] += residual * xi;
408                }
409            }
410            for a in 0..m {
411                for b in 0..m {
412                    let delta = if a == b { 1.0 } else { 0.0 };
413                    let w = probs[a] * (delta - probs[b]);
414                    if w == 0.0 {
415                        continue;
416                    }
417                    for (i, &xi) in row.design.iter().enumerate() {
418                        let scaled = w * xi;
419                        if scaled == 0.0 {
420                            continue;
421                        }
422                        for (j, &xj) in row.design.iter().enumerate() {
423                            precision[[a * p + i, b * p + j]] += scaled * xj;
424                        }
425                    }
426                }
427            }
428        }
429
430        // The penalty's own contribution to the gradient of the NEGATIVE
431        // log-posterior is `S_λ θ`; the tilt's is `−c`. Neither touches the
432        // curvature — the penalty is already in `precision` and a linear term
433        // has no second derivative.
434        for i in 0..d {
435            let mut acc = 0.0_f64;
436            for j in 0..d {
437                acc += self.joint_penalty[[i, j]] * theta[j];
438            }
439            gradient[i] += acc - tilt[i];
440        }
441        (gradient, precision)
442    }
443
444    /// The linear term `c` that makes the SUPPLIED coefficients an exact
445    /// stationary point of the objective this predictive integrates.
446    ///
447    /// # What it is measuring
448    ///
449    /// A Laplace ratio is only a Laplace ratio if its denominator is expanded at
450    /// a mode. The published coefficients are the mode of the objective the FIT
451    /// maximised, and that objective is not always `ℓ − ½θ'S_λθ`: the multinomial
452    /// formula path arms a Jeffreys/Firth term `Φ` on separation evidence, and
453    /// on the geometry where it arms, `Φ`'s `O(1)` gradient is what pins a
454    /// direction the penalized likelihood leaves at `O(λ)`. Integrating the
455    /// bare penalized likelihood against a mode that belongs to `ℓ − ½θ'S_λθ + Φ`
456    /// would take every ratio against a posterior the published coefficients do
457    /// not live in.
458    ///
459    /// `c = ∇(−[ℓ − ½θ'S_λθ])(β̂)` is EXACTLY `∇Φ(β̂)`, by stationarity of the
460    /// fit's own mode — so the first-order content of whatever extra term the
461    /// objective carried is *measurable* at the published point rather than
462    /// something this module has to reconstruct. Carrying it as a linear term is
463    /// free: it moves no curvature (a linear function has no second derivative),
464    /// so the augmented modes and their log-determinants stay the ones the ratio
465    /// needs.
466    ///
467    /// When the fit carried no extra term, `c` is the inner solve's own residual
468    /// gradient — orders below anything that matters, and absorbing it makes the
469    /// base exactly stationary rather than nearly so, which is a strict
470    /// improvement on using the raw mode.
471    ///
472    /// What is NOT carried is the extra term's CURVATURE. That is second-order
473    /// in the ratio: `Φ`'s Hessian appears in the numerator's and the
474    /// denominator's log-determinants alike, one observation apart, and the
475    /// residual is what the mass-defect identity below measures at every row.
476    fn stationarity_tilt(&self, mode: &[f64]) -> Array1<f64> {
477        let zero = vec![0.0_f64; mode.len()];
478        let (gradient, _) = self.gradient_and_precision(mode, &[], &zero);
479        gradient
480    }
481
482    /// Newton with backtracking on the strictly convex negative penalized
483    /// log-posterior, warm-started at `start`.
484    ///
485    /// Returns the mode, the objective value there, and the log-determinant of
486    /// the precision at that point — the three quantities a Laplace ratio needs
487    /// from one side of it.
488    fn augmented_mode(
489        &self,
490        start: &[f64],
491        extra: &[ExtraRow<'_>],
492        tilt: &[f64],
493    ) -> Result<(Vec<f64>, f64, f64), EstimationError> {
494        let d = self.coefficient_dim();
495        let mut theta = start.to_vec();
496        let mut value = self.log_posterior(&theta, extra, tilt);
497        let mut logdet;
498        for _iteration in 0..AUGMENTED_MODE_MAX_ITERATIONS {
499            let (gradient, precision) = self.gradient_and_precision(&theta, extra, tilt);
500            let factor = precision.cholesky(faer::Side::Lower).map_err(|error| {
501                EstimationError::InvalidInput(format!(
502                    "multinomial predictive: augmented posterior precision is not positive \
503                     definite ({error}); the fit's own posterior is not Laplace-describable at \
504                     this prediction row"
505                ))
506            })?;
507            logdet = factor.diag().iter().map(|v| v.abs().ln()).sum::<f64>() * 2.0;
508            let step = factor.solvevec(&(-&gradient));
509            if step.iter().any(|v| !v.is_finite()) {
510                crate::bail_invalid_estim!(
511                    "multinomial predictive: augmented Newton step is not finite"
512                );
513            }
514            // `½ gᵀH⁻¹g = −½ gᵀ·step`, the quadratic model's predicted gain.
515            let decrement = -0.5
516                * gradient
517                    .iter()
518                    .zip(step.iter())
519                    .map(|(g, s)| g * s)
520                    .sum::<f64>();
521            if decrement <= AUGMENTED_MODE_DECREMENT_TOLERANCE {
522                return Ok((theta, value, logdet));
523            }
524            let mut accepted = false;
525            let mut length = 1.0_f64;
526            for _attempt in 0..LINE_SEARCH_MAX_STEPS {
527                let mut trial = vec![0.0_f64; d];
528                for i in 0..d {
529                    trial[i] = theta[i] + length * step[i];
530                }
531                let trial_value = self.log_posterior(&trial, extra, tilt);
532                if trial_value.is_finite() && trial_value >= value {
533                    theta = trial;
534                    value = trial_value;
535                    accepted = true;
536                    break;
537                }
538                length *= LINE_SEARCH_CONTRACTION;
539            }
540            if !accepted {
541                // A convex objective whose Newton direction admits no ascent at
542                // any step length is at its optimum to floating-point
543                // resolution; the decrement test above has not fired only
544                // because the remaining gain is below what the objective can
545                // represent, which is the same statement.
546                return Ok((theta, value, logdet));
547            }
548        }
549        Err(EstimationError::InvalidInput(format!(
550            "multinomial predictive: augmented mode did not converge in \
551             {AUGMENTED_MODE_MAX_ITERATIONS} Newton iterations"
552        )))
553    }
554
555    /// Posterior-predictive moments at each row of `x_new`.
556    ///
557    /// `mode` is the un-augmented posterior mode in the same stacked class-major
558    /// order; it is the warm start for every augmented solve and the base of
559    /// every ratio.
560    pub fn predictive_moments(
561        &self,
562        mode: ArrayView1<'_, f64>,
563        x_new: ArrayView2<'_, f64>,
564        want_second_moments: bool,
565    ) -> Result<MultinomialPredictiveMoments, EstimationError> {
566        self.validate()?;
567        let d = self.coefficient_dim();
568        if mode.len() != d {
569            crate::bail_invalid_estim!(
570                "multinomial predictive mode has {} entries, expected {d}",
571                mode.len()
572            );
573        }
574        if x_new.ncols() != self.training_design.ncols() {
575            crate::bail_invalid_estim!(
576                "multinomial predictive design has {} columns, training design has {}",
577                x_new.ncols(),
578                self.training_design.ncols(),
579            );
580        }
581        let base_theta: Vec<f64> = mode.iter().copied().collect();
582        // The base mode and its log-determinant are recomputed here rather than
583        // read from the saved covariance ON PURPOSE: numerator and denominator
584        // of every ratio must come from the same assembly, or the difference of
585        // two log-determinants inherits whatever the two paths disagree about.
586        let tilt = self.stationarity_tilt(&base_theta);
587        let tilt = tilt.as_slice().expect("owned gradient is contiguous");
588        let (base_mode, base_value, base_logdet) = self.augmented_mode(&base_theta, &[], tilt)?;
589        // ... and with the stationarity tilt in place the polish must be a
590        // NO-OP: `c` was measured so that the supplied coefficients ARE the
591        // stationary point of this objective. A polish that moves anywhere is
592        // therefore a statement about this module, not about the fit — the tilt
593        // and the gradient it was built from have come apart — and it is checked
594        // rather than assumed, because every ratio below is expanded at this
595        // point and a base that is not a mode makes each of them something other
596        // than a Laplace approximation.
597        let scale = base_theta
598            .iter()
599            .fold(1.0_f64, |acc, value| acc.max(value.abs()));
600        let drift = base_mode
601            .iter()
602            .zip(base_theta.iter())
603            .fold(0.0_f64, |acc, (polished, supplied)| {
604                acc.max((polished - supplied).abs())
605            });
606        if drift > BASE_MODE_POLISH_TOLERANCE * scale {
607            crate::bail_invalid_estim!(
608                "multinomial predictive: the stationarity-tilted base is not stationary — \
609                 polishing the supplied coefficients moved them by {drift:e} against a \
610                 coefficient scale of {scale:e} (relative {relative:e} > {tol:e}), so the tilt \
611                 and the gradient it was measured from disagree and every ratio below would be \
612                 expanded somewhere other than a mode",
613                relative = drift / scale,
614                tol = BASE_MODE_POLISH_TOLERANCE,
615            );
616        }
617
618        let rows = x_new.nrows();
619        let k = self.n_classes;
620        let mut class_mean = Array2::<f64>::zeros((rows, k));
621        let mut mass_defect = Array1::<f64>::zeros(rows);
622        let mut second = if want_second_moments {
623            Some(Array3::<f64>::zeros((rows, k, k)))
624        } else {
625            None
626        };
627        // `(row, mass, defect)` of the worst row over tolerance, and how many
628        // rows are over it. The refusal below is raised from these rather than
629        // from the first row that trips, so it states the estimand's accuracy
630        // over the whole block instead of naming one witness.
631        let mut worst_defect: Option<(usize, f64, f64)> = None;
632        let mut over_tolerance_rows = 0usize;
633
634        for row in 0..rows {
635            let design_row = x_new.row(row);
636            let mut raw = vec![0.0_f64; k];
637            for class in 0..k {
638                let extra = [ExtraRow {
639                    design: design_row,
640                    class,
641                }];
642                let (_, value, logdet) = self.augmented_mode(&base_mode, &extra, tilt)?;
643                raw[class] = (value - base_value + 0.5 * (base_logdet - logdet)).exp();
644            }
645            let total: f64 = raw.iter().sum();
646            if !total.is_finite() || total <= 0.0 {
647                crate::bail_invalid_estim!(
648                    "multinomial predictive: row {row} produced a non-positive total predictive \
649                     mass {total}"
650                );
651            }
652            mass_defect[row] = (total - 1.0).abs();
653            if mass_defect[row] > PREDICTIVE_MASS_DEFECT_TOLERANCE {
654                // Recorded, not raised — see the refusal after the loop. Stopping
655                // here would report an EXAMPLE where the estimand's own accuracy
656                // statement is a MEASUREMENT: "row 46 is bad" and "3 of 86 rows
657                // are bad, the worst at 7.2e-2, the 90th percentile at 4e-3" are
658                // different findings, and the first cannot be told from the
659                // second by a caller who only ever sees the first bad row.
660                if worst_defect.is_none_or(|(_, _, defect)| defect < mass_defect[row]) {
661                    worst_defect = Some((row, total, mass_defect[row]));
662                }
663                over_tolerance_rows += 1;
664            }
665            for class in 0..k {
666                class_mean[[row, class]] = raw[class] / total;
667            }
668
669            if let Some(second) = second.as_mut() {
670                let mut raw_second = vec![0.0_f64; k * k];
671                for c in 0..k {
672                    for dd in c..k {
673                        let extra = [
674                            ExtraRow {
675                                design: design_row,
676                                class: c,
677                            },
678                            ExtraRow {
679                                design: design_row,
680                                class: dd,
681                            },
682                        ];
683                        let (_, value, logdet) = self.augmented_mode(&base_mode, &extra, tilt)?;
684                        let entry = (value - base_value + 0.5 * (base_logdet - logdet)).exp();
685                        raw_second[c * k + dd] = entry;
686                        raw_second[dd * k + c] = entry;
687                    }
688                }
689                let second_total: f64 = raw_second.iter().sum();
690                if !second_total.is_finite() || second_total <= 0.0 {
691                    crate::bail_invalid_estim!(
692                        "multinomial predictive: row {row} produced a non-positive second-moment \
693                         mass {second_total}"
694                    );
695                }
696                // `Σ_{c,d} E[p_c p_d] = E[(Σ_c p_c)²] = 1` is the same exact
697                // identity one order up, so the same normalisation applies.
698                for c in 0..k {
699                    for dd in 0..k {
700                        second[[row, c, dd]] = raw_second[c * k + dd] / second_total;
701                    }
702                }
703            }
704        }
705
706        if let Some((row, mass, defect)) = worst_defect {
707            // The distribution, not just the extreme: a block where one row in
708            // eighty-six is over and the rest are at `1e-4` is a statement about
709            // that row, while a block where a third of the rows are over is a
710            // statement about the fit. Those need different repairs and used to
711            // print identically.
712            let mut sorted: Vec<f64> = mass_defect.iter().copied().collect();
713            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
714            let quantile = |q: f64| -> f64 {
715                let index = ((sorted.len() - 1) as f64 * q).round() as usize;
716                sorted[index]
717            };
718            crate::bail_invalid_estim!(
719                "multinomial predictive: {over_tolerance_rows} of {rows} row(s) exceed the \
720                 predictive mass-defect tolerance {tol:e}; the posterior at those rows is not \
721                 described by either Laplace expansion well enough to publish a probability. \
722                 Worst row {row} has predictive mass {mass} (|Σ_c E[p_c] − 1| = {defect:e}). \
723                 Defect over the block: median {median:e}, 90th percentile {p90:e}, max {max:e}",
724                tol = PREDICTIVE_MASS_DEFECT_TOLERANCE,
725                median = quantile(0.5),
726                p90 = quantile(0.9),
727                max = quantile(1.0),
728            );
729        }
730
731        Ok(MultinomialPredictiveMoments {
732            class_mean,
733            class_second_moment: second,
734            mass_defect,
735        })
736    }
737}
738
739/// Per-class posterior standard deviation of the probability, from the moments
740/// above: `sd(p_c) = sqrt(E[p_c²] − E[p_c]²)`.
741///
742/// A materially negative variance is refused rather than clamped: `E[p_c²]` and
743/// `E[p_c]` come from two different ratios, so a negative difference means the
744/// two expansions disagree by more than the quantity being reported, which is
745/// exactly the situation in which a clamped `sd = 0` would be a lie.
746pub fn predictive_standard_deviation(
747    moments: &MultinomialPredictiveMoments,
748) -> Result<Array2<f64>, EstimationError> {
749    let second = moments.class_second_moment.as_ref().ok_or_else(|| {
750        EstimationError::InvalidInput(
751            "multinomial predictive standard deviation requires second moments".to_string(),
752        )
753    })?;
754    let (rows, k) = moments.class_mean.dim();
755    let mut sd = Array2::<f64>::zeros((rows, k));
756    for row in 0..rows {
757        for class in 0..k {
758            let mean = moments.class_mean[[row, class]];
759            let variance = second[[row, class, class]] - mean * mean;
760            // `E[p²]` and `E[p]²` are two different ratios, so their difference
761            // is only resolvable down to the accuracy of the ratios themselves —
762            // and that accuracy is MEASURED at this row by the mass defect, not
763            // guessed. A probability's variance is bounded by `mean(1 − mean)`,
764            // so the envelope is that scale times the row's own measured error,
765            // floored at round-off. Anything more negative than that is not
766            // cancellation: it is the two expansions disagreeing by more than the
767            // quantity being reported, which is exactly the case where a clamped
768            // `sd = 0` would be a lie.
769            let bound = (mean * (1.0 - mean)).max(f64::EPSILON);
770            let envelope =
771                bound * moments.mass_defect[row].max(16.0 * f64::EPSILON);
772            if variance < -envelope {
773                crate::bail_invalid_estim!(
774                    "multinomial predictive: row {row} class {class} has negative probability \
775                     variance {variance:e} (mean {mean}, backward-error envelope {envelope:e})"
776                );
777            }
778            sd[[row, class]] = variance.max(0.0).sqrt();
779        }
780    }
781    Ok(sd)
782}