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}