1use crate::model_types::EstimationError;
20use gam_linalg::faer_ndarray::FaerEigh;
21use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
22
23const PSD_BACKWARD_ERROR_MULTIPLIER: f64 = 16.0;
31
32const SUMMATION_ROUNDOFF_MULTIPLIER: f64 = 16.0;
34
35#[derive(Clone, Copy, Debug)]
41pub struct MultinomialPosteriorIntegrationControl {
42 pub absolute_tolerance: f64,
45 pub relative_tolerance: f64,
47 pub minimum_sparse_level: usize,
49 pub maximum_sparse_level: usize,
51 pub maximum_function_evaluations: usize,
53}
54
55impl Default for MultinomialPosteriorIntegrationControl {
56 fn default() -> Self {
57 let tolerance = f64::EPSILON.sqrt();
72 Self {
73 absolute_tolerance: tolerance,
74 relative_tolerance: tolerance,
75 minimum_sparse_level: 2,
76 maximum_sparse_level: 12,
77 maximum_function_evaluations: 2_000_000,
78 }
79 }
80}
81
82#[derive(Clone, Debug)]
85pub struct MultinomialPosteriorRowMoments {
86 pub class_mean: Array2<f64>,
87 pub class_standard_deviation: Array2<f64>,
88}
89
90pub fn integrate_multinomial_design_moments(
98 coefficients: ArrayView2<'_, f64>,
99 coefficient_covariance: ArrayView2<'_, f64>,
100 design: ArrayView2<'_, f64>,
101 control: &MultinomialPosteriorIntegrationControl,
102) -> Result<MultinomialPosteriorRowMoments, EstimationError> {
103 let (p, m) = coefficients.dim();
104 if p == 0 || m == 0 {
105 return Err(EstimationError::InvalidInput(format!(
106 "multinomial posterior prediction needs nonempty coefficients, got {p}x{m}"
107 )));
108 }
109 if design.ncols() != p {
110 return Err(EstimationError::InvalidInput(format!(
111 "multinomial posterior prediction design has {} columns, expected {p}",
112 design.ncols()
113 )));
114 }
115 let d = p.checked_mul(m).ok_or_else(|| {
116 EstimationError::InvalidInput(
117 "multinomial posterior prediction coefficient dimension overflowed usize".to_string(),
118 )
119 })?;
120 if coefficient_covariance.dim() != (d, d) {
121 return Err(EstimationError::InvalidInput(format!(
122 "multinomial posterior prediction covariance shape {:?} does not match (P*M, P*M) = ({d}, {d})",
123 coefficient_covariance.dim()
124 )));
125 }
126
127 let n = design.nrows();
128 let k = m + 1;
129 let mut class_mean = Array2::<f64>::zeros((n, k));
130 let mut class_standard_deviation = Array2::<f64>::zeros((n, k));
131 let mut active_mean = Array1::<f64>::zeros(m);
132 let mut active_covariance = Array2::<f64>::zeros((m, m));
133 for row in 0..n {
134 let x = design.row(row);
135 for a in 0..m {
136 active_mean[a] = x.dot(&coefficients.column(a));
137 }
138 for a in 0..m {
139 for b in 0..m {
140 let mut value = 0.0_f64;
141 let a_base = a * p;
142 let b_base = b * p;
143 for i in 0..p {
144 let xi = x[i];
145 if xi == 0.0 {
146 continue;
147 }
148 let mut row_product = 0.0_f64;
149 for j in 0..p {
150 row_product += coefficient_covariance[[a_base + i, b_base + j]] * x[j];
151 }
152 value += xi * row_product;
153 }
154 active_covariance[[a, b]] = value;
155 }
156 }
157 let moments = integrate_logistic_normal_softmax_moments(
158 active_mean.view(),
159 active_covariance.view(),
160 control,
161 )?;
162 class_mean.row_mut(row).assign(&moments.class_mean);
163 class_standard_deviation
164 .row_mut(row)
165 .assign(&moments.class_standard_deviation);
166 }
167 Ok(MultinomialPosteriorRowMoments {
168 class_mean,
169 class_standard_deviation,
170 })
171}
172
173impl MultinomialPosteriorIntegrationControl {
174 fn validate(&self) -> Result<(), EstimationError> {
175 if !(self.absolute_tolerance.is_finite() && self.absolute_tolerance >= 0.0) {
176 return Err(EstimationError::InvalidInput(format!(
177 "multinomial posterior integration absolute_tolerance must be finite and >= 0, got {}",
178 self.absolute_tolerance
179 )));
180 }
181 if !(self.relative_tolerance.is_finite() && self.relative_tolerance >= 0.0) {
182 return Err(EstimationError::InvalidInput(format!(
183 "multinomial posterior integration relative_tolerance must be finite and >= 0, got {}",
184 self.relative_tolerance
185 )));
186 }
187 if self.absolute_tolerance == 0.0 && self.relative_tolerance == 0.0 {
188 return Err(EstimationError::InvalidInput(
189 "multinomial posterior integration requires a positive absolute or relative tolerance"
190 .to_string(),
191 ));
192 }
193 if self.minimum_sparse_level == 0 {
194 return Err(EstimationError::InvalidInput(
195 "multinomial posterior integration minimum_sparse_level must be >= 1 so a level difference exists"
196 .to_string(),
197 ));
198 }
199 if self.maximum_sparse_level < self.minimum_sparse_level {
200 return Err(EstimationError::InvalidInput(format!(
201 "multinomial posterior integration maximum_sparse_level ({}) is below minimum_sparse_level ({})",
202 self.maximum_sparse_level, self.minimum_sparse_level
203 )));
204 }
205 if self.maximum_function_evaluations == 0 {
206 return Err(EstimationError::InvalidInput(
207 "multinomial posterior integration maximum_function_evaluations must be positive"
208 .to_string(),
209 ));
210 }
211 Ok(())
212 }
213}
214
215#[derive(Clone, Debug)]
221pub struct MultinomialPosteriorMoments {
222 pub class_mean: Array1<f64>,
224 pub class_covariance: Array2<f64>,
226 pub class_standard_deviation: Array1<f64>,
228 pub latent_rank: usize,
230 pub sparse_level: Option<usize>,
233 pub function_evaluations: usize,
235 pub max_raw_moment_level_difference: f64,
238 pub covariance_range_projection_bound: f64,
242}
243
244pub fn integrate_logistic_normal_softmax_moments(
251 active_mean: ArrayView1<'_, f64>,
252 active_covariance: ArrayView2<'_, f64>,
253 control: &MultinomialPosteriorIntegrationControl,
254) -> Result<MultinomialPosteriorMoments, EstimationError> {
255 control.validate()?;
256 validate_inputs(active_mean, active_covariance)?;
257
258 let mean = active_mean.to_vec();
259 let m = mean.len();
260 if m == 1 {
261 return integrate_binary(mean[0], active_covariance[[0, 0]]);
262 }
263
264 let maximum_covariance_entry = active_covariance
265 .iter()
266 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
267 if maximum_covariance_entry == 0.0 {
268 return point_mass_moments(&mean);
269 }
270
271 let projected = project_active_covariance(active_covariance, control.absolute_tolerance)?;
272 if projected.factor.ncols() == 0 {
273 let mut out = point_mass_moments(&mean)?;
278 out.covariance_range_projection_bound = projected.projection_bound;
279 return Ok(out);
280 }
281
282 integrate_general(&mean, &projected, control)
283}
284
285fn validate_inputs(
286 active_mean: ArrayView1<'_, f64>,
287 active_covariance: ArrayView2<'_, f64>,
288) -> Result<(), EstimationError> {
289 let m = active_mean.len();
290 if m == 0 {
291 return Err(EstimationError::InvalidInput(
292 "multinomial posterior integration needs at least one active logit (K >= 2)"
293 .to_string(),
294 ));
295 }
296 if active_covariance.dim() != (m, m) {
297 return Err(EstimationError::InvalidInput(format!(
298 "multinomial posterior integration covariance shape {:?} does not match active mean length {m}",
299 active_covariance.dim()
300 )));
301 }
302 if let Some((index, value)) = active_mean
303 .iter()
304 .copied()
305 .enumerate()
306 .find(|(_, value)| !value.is_finite())
307 {
308 return Err(EstimationError::InvalidInput(format!(
309 "multinomial posterior integration active_mean[{index}] is non-finite: {value}"
310 )));
311 }
312 if let Some(((row, column), value)) = active_covariance
313 .indexed_iter()
314 .map(|(index, &value)| (index, value))
315 .find(|(_, value)| !value.is_finite())
316 {
317 return Err(EstimationError::InvalidInput(format!(
318 "multinomial posterior integration covariance[{row},{column}] is non-finite: {value}"
319 )));
320 }
321
322 let scale = active_covariance
323 .iter()
324 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
325 let symmetry_tolerance = covariance_roundoff_tolerance(scale, m);
326 let mut maximum_asymmetry = 0.0_f64;
327 for row in 0..m {
328 for column in (row + 1)..m {
329 maximum_asymmetry = maximum_asymmetry
330 .max((active_covariance[[row, column]] - active_covariance[[column, row]]).abs());
331 }
332 }
333 if maximum_asymmetry > symmetry_tolerance {
334 return Err(EstimationError::InvalidInput(format!(
335 "multinomial posterior integration covariance is not symmetric: max asymmetry {maximum_asymmetry:.6e} exceeds backward-error tolerance {symmetry_tolerance:.6e}"
336 )));
337 }
338 Ok(())
339}
340
341fn covariance_roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
342 PSD_BACKWARD_ERROR_MULTIPLIER * f64::EPSILON * (dimension.max(1) as f64) * scale
343}
344
345fn integrate_binary(
346 active_mean: f64,
347 active_variance: f64,
348) -> Result<MultinomialPosteriorMoments, EstimationError> {
349 if active_variance < 0.0 {
350 return Err(EstimationError::InvalidInput(format!(
351 "binary logistic-normal variance must be non-negative, got {active_variance:.6e}"
352 )));
353 }
354 let sigma = active_variance.sqrt();
355 let (probability_mean, mean_logistic_slope) =
356 gam_solve::quadrature::logit_posterior_meanwith_deriv(active_mean, sigma)?;
357
358 let probability_second_moment = probability_mean - mean_logistic_slope;
363 let variance = (probability_second_moment - probability_mean * probability_mean).max(0.0);
364 let reference_mean = 1.0 - probability_mean;
365
366 let class_mean = Array1::from_vec(vec![probability_mean, reference_mean]);
367 let class_covariance =
368 Array2::from_shape_vec((2, 2), vec![variance, -variance, -variance, variance]).map_err(
369 |error| {
370 EstimationError::InvalidInput(format!(
371 "binary logistic-normal covariance construction failed: {error}"
372 ))
373 },
374 )?;
375 let standard_deviation = variance.sqrt();
376 Ok(MultinomialPosteriorMoments {
377 class_mean,
378 class_covariance,
379 class_standard_deviation: Array1::from_vec(vec![standard_deviation, standard_deviation]),
380 latent_rank: if active_variance > 0.0 { 1 } else { 0 },
381 sparse_level: None,
382 function_evaluations: 0,
383 max_raw_moment_level_difference: 0.0,
384 covariance_range_projection_bound: 0.0,
385 })
386}
387
388fn point_mass_moments(active_mean: &[f64]) -> Result<MultinomialPosteriorMoments, EstimationError> {
389 let class_mean = Array1::from_vec(softmax_with_reference(active_mean)?);
390 let k = class_mean.len();
391 Ok(MultinomialPosteriorMoments {
392 class_mean,
393 class_covariance: Array2::zeros((k, k)),
394 class_standard_deviation: Array1::zeros(k),
395 latent_rank: 0,
396 sparse_level: None,
397 function_evaluations: 1,
398 max_raw_moment_level_difference: 0.0,
399 covariance_range_projection_bound: 0.0,
400 })
401}
402
403struct ProjectedGaussian {
404 factor: Array2<f64>,
406 projection_bound: f64,
407}
408
409fn project_active_covariance(
410 covariance: ArrayView2<'_, f64>,
411 absolute_tolerance: f64,
412) -> Result<ProjectedGaussian, EstimationError> {
413 let m = covariance.nrows();
414 let symmetric = (&covariance.to_owned() + &covariance.t().to_owned()) * 0.5;
415 let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
416 EstimationError::InvalidInput(format!(
417 "multinomial posterior covariance eigendecomposition failed: {error}"
418 ))
419 })?;
420 let eigenvalue_scale = eigenvalues
421 .iter()
422 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
423 let tolerance = covariance_roundoff_tolerance(eigenvalue_scale, m);
424 let minimum_eigenvalue = eigenvalues
425 .iter()
426 .fold(f64::INFINITY, |minimum, &value| minimum.min(value));
427 if minimum_eigenvalue < -tolerance {
428 return Err(EstimationError::InvalidInput(format!(
429 "multinomial posterior active-logit covariance is not positive semidefinite: minimum eigenvalue {minimum_eigenvalue:.6e} is below -{tolerance:.6e} (scale {eigenvalue_scale:.6e})"
430 )));
431 }
432
433 let small_positive_trace: f64 = eigenvalues
434 .iter()
435 .copied()
436 .filter(|value| *value > 0.0 && *value <= tolerance)
437 .sum();
438 let candidate_projection_bound = small_positive_trace.sqrt();
442 let discard_small_positive = candidate_projection_bound <= absolute_tolerance;
443
444 let retained: Vec<(usize, f64)> = eigenvalues
445 .iter()
446 .copied()
447 .enumerate()
448 .filter(|(_, value)| *value > 0.0 && (!discard_small_positive || *value > tolerance))
449 .collect();
450 let projection_bound = if discard_small_positive {
451 candidate_projection_bound
452 } else {
453 0.0
454 };
455 let mut factor = Array2::<f64>::zeros((m, retained.len()));
456 for (output_column, (eigenvector_column, eigenvalue)) in retained.into_iter().enumerate() {
457 let scale = eigenvalue.sqrt();
458 for row in 0..m {
459 factor[[row, output_column]] = eigenvectors[[row, eigenvector_column]] * scale;
460 }
461 }
462 Ok(ProjectedGaussian {
463 factor,
464 projection_bound,
465 })
466}
467
468fn integrate_general(
469 active_mean: &[f64],
470 projected: &ProjectedGaussian,
471 control: &MultinomialPosteriorIntegrationControl,
472) -> Result<MultinomialPosteriorMoments, EstimationError> {
473 let rank = projected.factor.ncols();
474 let k = active_mean.len() + 1;
475 let mut rules = Vec::<GaussHermiteRule>::new();
476 let mut previous: Option<Vec<f64>> = None;
477 let mut total_evaluations = 0usize;
478 let mut last_max_difference = f64::INFINITY;
479 let mut last_max_normalized_error = f64::INFINITY;
480
481 for level in 0..=control.maximum_sparse_level {
482 let required_rule_count = level.checked_add(1).ok_or_else(|| {
483 EstimationError::InvalidInput(
484 "multinomial posterior sparse level overflowed usize".to_string(),
485 )
486 })?;
487 while rules.len() < required_rule_count {
488 let rule_index = rules.len() + 1;
489 rules.push(gauss_hermite_rule(rule_index)?);
490 }
491
492 let evaluation = evaluate_smolyak_level(
493 active_mean,
494 projected,
495 &rules,
496 level,
497 k,
498 &mut total_evaluations,
499 control.maximum_function_evaluations,
500 control.absolute_tolerance,
501 )?;
502 let current = evaluation.raw_moments;
503
504 if let Some(previous_moments) = previous.as_ref() {
505 let mut certified = level >= control.minimum_sparse_level;
506 let mut maximum_difference = 0.0_f64;
507 let mut maximum_normalized_error = 0.0_f64;
508 for (&new_value, &old_value) in current.iter().zip(previous_moments.iter()) {
509 let difference = (new_value - old_value).abs();
510 maximum_difference = maximum_difference.max(difference);
511 let tolerance = control.absolute_tolerance
512 + control.relative_tolerance * new_value.abs().max(old_value.abs());
513 let controlled_error = difference + projected.projection_bound;
514 if controlled_error > tolerance {
515 certified = false;
516 }
517 if tolerance > 0.0 {
518 maximum_normalized_error =
519 maximum_normalized_error.max(controlled_error / tolerance);
520 }
521 }
522 last_max_difference = maximum_difference;
523 last_max_normalized_error = maximum_normalized_error;
524
525 if certified {
526 return moments_from_raw(
527 current,
528 k,
529 rank,
530 level,
531 total_evaluations,
532 maximum_difference,
533 projected.projection_bound,
534 );
535 }
536 }
537 previous = Some(current);
538 }
539
540 Err(EstimationError::InvalidInput(format!(
541 "multinomial logistic-normal quadrature did not converge through Smolyak level {}: final max raw-moment level difference {last_max_difference:.6e}, max normalized error {last_max_normalized_error:.6e}, projection bound {:.6e}, evaluations {total_evaluations}/{}",
542 control.maximum_sparse_level,
543 projected.projection_bound,
544 control.maximum_function_evaluations
545 )))
546}
547
548struct SmolyakEvaluation {
549 raw_moments: Vec<f64>,
550}
551
552fn evaluate_smolyak_level(
553 active_mean: &[f64],
554 projected: &ProjectedGaussian,
555 rules: &[GaussHermiteRule],
556 level: usize,
557 k: usize,
558 total_evaluations: &mut usize,
559 maximum_function_evaluations: usize,
560 absolute_tolerance: f64,
561) -> Result<SmolyakEvaluation, EstimationError> {
562 let rank = projected.factor.ncols();
563 let q = rank.checked_add(level).ok_or_else(|| {
564 EstimationError::InvalidInput(
565 "multinomial posterior Smolyak index overflowed usize".to_string(),
566 )
567 })?;
568 let lower_total = q.saturating_sub(rank.saturating_sub(1)).max(rank);
569 let moment_count = packed_moment_count(k)?;
570 let upper_offsets = upper_triangle_offsets(k)?;
571 let mut workspace = QuadratureWorkspace::new(
572 active_mean,
573 projected,
574 rules,
575 &upper_offsets,
576 moment_count,
577 total_evaluations,
578 maximum_function_evaluations,
579 )?;
580 let mut indices = vec![1usize; rank];
581
582 for total in lower_total..=q {
583 let alternating_power = q - total;
584 let mut coefficient = binomial_as_f64(rank - 1, alternating_power)?;
585 if alternating_power % 2 == 1 {
586 coefficient = -coefficient;
587 }
588 workspace.stream_compositions(0, total, &mut indices, coefficient)?;
589 }
590
591 let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
592 if !(mass.is_finite() && mass > 0.0 && absolute_weight_sum.is_finite()) {
593 return Err(EstimationError::InvalidInput(format!(
594 "multinomial posterior Smolyak level {level} produced invalid total weight {mass} (absolute sum {absolute_weight_sum})"
595 )));
596 }
597 let mass_error = (mass - 1.0).abs();
598 let summation_envelope =
599 SUMMATION_ROUNDOFF_MULTIPLIER * f64::EPSILON * absolute_weight_sum.max(1.0);
600 if mass_error > absolute_tolerance + summation_envelope {
601 return Err(EstimationError::InvalidInput(format!(
602 "multinomial posterior Smolyak level {level} failed constant-function exactness: total weight {mass:.17e}, error {mass_error:.6e}, allowed {:.6e}",
603 absolute_tolerance + summation_envelope
604 )));
605 }
606 for value in &mut raw_moments {
607 *value /= mass;
608 }
609 Ok(SmolyakEvaluation { raw_moments })
610}
611
612fn packed_moment_count(k: usize) -> Result<usize, EstimationError> {
613 let triangular = k
614 .checked_add(1)
615 .and_then(|next| k.checked_mul(next))
616 .map(|product| product / 2)
617 .ok_or_else(|| {
618 EstimationError::InvalidInput(
619 "multinomial posterior moment dimension overflowed usize".to_string(),
620 )
621 })?;
622 k.checked_add(triangular).ok_or_else(|| {
623 EstimationError::InvalidInput(
624 "multinomial posterior packed moment count overflowed usize".to_string(),
625 )
626 })
627}
628
629fn upper_triangle_offsets(k: usize) -> Result<Vec<usize>, EstimationError> {
630 let mut offsets = Vec::new();
631 offsets.try_reserve_exact(k).map_err(|error| {
632 EstimationError::InvalidInput(format!(
633 "multinomial posterior could not allocate upper-triangle offsets: {error}"
634 ))
635 })?;
636 let mut cursor = 0usize;
637 for row in 0..k {
638 offsets.push(cursor);
639 cursor = cursor.checked_add(k - row).ok_or_else(|| {
640 EstimationError::InvalidInput(
641 "multinomial posterior upper-triangle offset overflowed usize".to_string(),
642 )
643 })?;
644 }
645 Ok(offsets)
646}
647
648fn zeroed_vec(length: usize, label: &str) -> Result<Vec<f64>, EstimationError> {
649 let mut values = Vec::new();
650 values.try_reserve_exact(length).map_err(|error| {
651 EstimationError::InvalidInput(format!(
652 "multinomial posterior could not allocate {label} (length {length}): {error}"
653 ))
654 })?;
655 values.resize(length, 0.0);
656 Ok(values)
657}
658
659struct CompensatedSum {
660 sum: f64,
661 correction: f64,
662}
663
664impl CompensatedSum {
665 fn new() -> Self {
666 Self {
667 sum: 0.0,
668 correction: 0.0,
669 }
670 }
671
672 fn add(&mut self, value: f64) {
673 let combined = self.sum + value;
674 if self.sum.abs() >= value.abs() {
675 self.correction += (self.sum - combined) + value;
676 } else {
677 self.correction += (value - combined) + self.sum;
678 }
679 self.sum = combined;
680 }
681
682 fn value(&self) -> f64 {
683 self.sum + self.correction
684 }
685}
686
687struct QuadratureAccumulator {
688 sums: Vec<f64>,
689 corrections: Vec<f64>,
690 mass: CompensatedSum,
691 absolute_weight_sum: f64,
692}
693
694impl QuadratureAccumulator {
695 fn new(moment_count: usize) -> Result<Self, EstimationError> {
696 Ok(Self {
697 sums: zeroed_vec(moment_count, "quadrature sums")?,
698 corrections: zeroed_vec(moment_count, "quadrature corrections")?,
699 mass: CompensatedSum::new(),
700 absolute_weight_sum: 0.0,
701 })
702 }
703
704 fn add_moment(&mut self, index: usize, value: f64) {
705 let combined = self.sums[index] + value;
706 if self.sums[index].abs() >= value.abs() {
707 self.corrections[index] += (self.sums[index] - combined) + value;
708 } else {
709 self.corrections[index] += (value - combined) + self.sums[index];
710 }
711 self.sums[index] = combined;
712 }
713
714 fn add_weight(&mut self, weight: f64) {
715 self.mass.add(weight);
716 self.absolute_weight_sum += weight.abs();
717 }
718
719 fn finish(mut self) -> (Vec<f64>, f64, f64) {
720 for (sum, correction) in self.sums.iter_mut().zip(self.corrections.iter()) {
721 *sum += *correction;
722 }
723 (self.sums, self.mass.value(), self.absolute_weight_sum)
724 }
725}
726
727struct QuadratureWorkspace<'a, 'b> {
728 active_mean: &'a [f64],
729 projected: &'a ProjectedGaussian,
730 rules: &'a [GaussHermiteRule],
731 upper_offsets: &'a [usize],
732 z: Vec<f64>,
733 active_eta: Vec<f64>,
734 probabilities: Vec<f64>,
735 accumulator: QuadratureAccumulator,
736 total_evaluations: &'b mut usize,
737 maximum_function_evaluations: usize,
738}
739
740impl<'a, 'b> QuadratureWorkspace<'a, 'b> {
741 fn new(
742 active_mean: &'a [f64],
743 projected: &'a ProjectedGaussian,
744 rules: &'a [GaussHermiteRule],
745 upper_offsets: &'a [usize],
746 moment_count: usize,
747 total_evaluations: &'b mut usize,
748 maximum_function_evaluations: usize,
749 ) -> Result<Self, EstimationError> {
750 let rank = projected.factor.ncols();
751 let m = active_mean.len();
752 Ok(Self {
753 active_mean,
754 projected,
755 rules,
756 upper_offsets,
757 z: zeroed_vec(rank, "standard-normal quadrature coordinate")?,
758 active_eta: zeroed_vec(m, "active-logit quadrature buffer")?,
759 probabilities: zeroed_vec(m + 1, "softmax quadrature buffer")?,
760 accumulator: QuadratureAccumulator::new(moment_count)?,
761 total_evaluations,
762 maximum_function_evaluations,
763 })
764 }
765
766 fn stream_compositions(
767 &mut self,
768 position: usize,
769 remaining: usize,
770 indices: &mut [usize],
771 coefficient: f64,
772 ) -> Result<(), EstimationError> {
773 let dimensions_left = indices.len() - position;
774 if dimensions_left == 1 {
775 if remaining == 0 {
776 return Ok(());
777 }
778 indices[position] = remaining;
779 return self.stream_tensor(0, indices, coefficient);
780 }
781 let maximum_here = remaining.saturating_sub(dimensions_left - 1);
782 for index in 1..=maximum_here {
783 indices[position] = index;
784 self.stream_compositions(position + 1, remaining - index, indices, coefficient)?;
785 }
786 Ok(())
787 }
788
789 fn stream_tensor(
790 &mut self,
791 axis: usize,
792 indices: &[usize],
793 weight: f64,
794 ) -> Result<(), EstimationError> {
795 if axis == indices.len() {
796 return self.accumulate_node(weight);
797 }
798 let rule_index = indices[axis] - 1;
799 let node_count = self.rules[rule_index].nodes.len();
800 for node_index in 0..node_count {
801 let node = self.rules[rule_index].nodes[node_index];
802 let node_weight = self.rules[rule_index].weights[node_index];
803 self.z[axis] = node;
804 self.stream_tensor(axis + 1, indices, weight * node_weight)?;
805 }
806 Ok(())
807 }
808
809 fn accumulate_node(&mut self, weight: f64) -> Result<(), EstimationError> {
810 if *self.total_evaluations >= self.maximum_function_evaluations {
811 return Err(EstimationError::InvalidInput(format!(
812 "multinomial logistic-normal quadrature exhausted its function-evaluation budget ({}) before convergence",
813 self.maximum_function_evaluations
814 )));
815 }
816 *self.total_evaluations += 1;
817
818 for row in 0..self.active_mean.len() {
819 let mut value = self.active_mean[row];
820 for column in 0..self.z.len() {
821 value += self.projected.factor[[row, column]] * self.z[column];
822 }
823 self.active_eta[row] = value;
824 }
825 softmax_with_reference_into(&self.active_eta, &mut self.probabilities)?;
826
827 let k = self.probabilities.len();
828 self.accumulator.add_weight(weight);
829 for class in 0..k {
830 self.accumulator
831 .add_moment(class, weight * self.probabilities[class]);
832 }
833 let second_offset = k;
834 for row in 0..k {
835 for column in row..k {
836 let packed = second_offset + self.upper_offsets[row] + column - row;
837 self.accumulator.add_moment(
838 packed,
839 weight * self.probabilities[row] * self.probabilities[column],
840 );
841 }
842 }
843 Ok(())
844 }
845}
846
847struct GaussHermiteRule {
848 nodes: Vec<f64>,
850 weights: Vec<f64>,
852}
853
854fn gauss_hermite_rule(index: usize) -> Result<GaussHermiteRule, EstimationError> {
855 let node_count = index
856 .checked_mul(2)
857 .and_then(|value| value.checked_sub(1))
858 .ok_or_else(|| {
859 EstimationError::InvalidInput(
860 "multinomial posterior Gauss-Hermite order overflowed usize".to_string(),
861 )
862 })?;
863 let mut jacobi = Array2::<f64>::zeros((node_count, node_count));
864 for diagonal in 0..node_count.saturating_sub(1) {
865 let value = (((diagonal + 1) as f64) * 0.5).sqrt();
867 jacobi[[diagonal, diagonal + 1]] = value;
868 jacobi[[diagonal + 1, diagonal]] = value;
869 }
870 let (eigenvalues, eigenvectors) = jacobi.eigh(faer::Side::Lower).map_err(|error| {
871 EstimationError::InvalidInput(format!(
872 "multinomial posterior Gauss-Hermite rule {node_count} eigendecomposition failed: {error}"
873 ))
874 })?;
875 let mut nodes = Vec::new();
876 let mut weights = Vec::new();
877 nodes.try_reserve_exact(node_count).map_err(|error| {
878 EstimationError::InvalidInput(format!(
879 "multinomial posterior could not allocate Gauss-Hermite nodes: {error}"
880 ))
881 })?;
882 weights.try_reserve_exact(node_count).map_err(|error| {
883 EstimationError::InvalidInput(format!(
884 "multinomial posterior could not allocate Gauss-Hermite weights: {error}"
885 ))
886 })?;
887 for column in 0..node_count {
888 nodes.push(std::f64::consts::SQRT_2 * eigenvalues[column]);
889 weights.push(eigenvectors[[0, column]] * eigenvectors[[0, column]]);
890 }
891 let weight_sum: f64 = weights.iter().sum();
892 if !(weight_sum.is_finite() && weight_sum > 0.0) {
893 return Err(EstimationError::InvalidInput(format!(
894 "multinomial posterior Gauss-Hermite rule {node_count} has invalid weight sum {weight_sum}"
895 )));
896 }
897 for weight in &mut weights {
898 *weight /= weight_sum;
899 }
900 Ok(GaussHermiteRule { nodes, weights })
901}
902
903fn binomial_as_f64(n: usize, k: usize) -> Result<f64, EstimationError> {
904 if k > n {
905 return Ok(0.0);
906 }
907 let k = k.min(n - k);
908 let mut value = 1.0_f64;
909 for step in 1..=k {
910 value *= (n - k + step) as f64 / step as f64;
911 if !value.is_finite() {
912 return Err(EstimationError::InvalidInput(format!(
913 "multinomial posterior Smolyak binomial coefficient C({n},{k}) overflowed f64"
914 )));
915 }
916 }
917 Ok(value)
918}
919
920fn softmax_with_reference(active_eta: &[f64]) -> Result<Vec<f64>, EstimationError> {
921 let mut probabilities = zeroed_vec(active_eta.len() + 1, "softmax result")?;
922 softmax_with_reference_into(active_eta, &mut probabilities)?;
923 Ok(probabilities)
924}
925
926fn softmax_with_reference_into(
927 active_eta: &[f64],
928 probabilities: &mut [f64],
929) -> Result<(), EstimationError> {
930 if probabilities.len() != active_eta.len() + 1 {
931 return Err(EstimationError::InvalidInput(format!(
932 "multinomial posterior softmax buffer length {} does not equal active-logit length {} + 1",
933 probabilities.len(),
934 active_eta.len()
935 )));
936 }
937 let maximum = active_eta.iter().copied().fold(0.0_f64, f64::max);
938 let reference = probabilities.len() - 1;
939 let mut denominator = (-maximum).exp();
940 probabilities[reference] = denominator;
941 for (class, &eta) in active_eta.iter().enumerate() {
942 let numerator = (eta - maximum).exp();
943 probabilities[class] = numerator;
944 denominator += numerator;
945 }
946 if !(denominator.is_finite() && denominator > 0.0) {
947 return Err(EstimationError::InvalidInput(format!(
948 "multinomial posterior softmax produced invalid denominator {denominator}"
949 )));
950 }
951 for probability in probabilities {
952 *probability /= denominator;
953 }
954 Ok(())
955}
956
957fn moments_from_raw(
958 raw_moments: Vec<f64>,
959 k: usize,
960 latent_rank: usize,
961 sparse_level: usize,
962 function_evaluations: usize,
963 max_level_difference: f64,
964 projection_bound: f64,
965) -> Result<MultinomialPosteriorMoments, EstimationError> {
966 let upper_offsets = upper_triangle_offsets(k)?;
967 let raw_error = max_level_difference + projection_bound;
968 let covariance_error = 3.0 * raw_error + raw_error * raw_error;
969
970 let mut means = raw_moments[..k].to_vec();
971 for (class, mean) in means.iter_mut().enumerate() {
972 if *mean < -raw_error || *mean > 1.0 + raw_error || !mean.is_finite() {
973 return Err(EstimationError::InvalidInput(format!(
974 "multinomial posterior integrated mean for class {class} is outside its certified probability envelope: {mean} (raw error {raw_error:.6e})"
975 )));
976 }
977 *mean = mean.clamp(0.0, 1.0);
978 }
979 let mean_sum: f64 = means.iter().sum();
980 if !(mean_sum.is_finite() && mean_sum > 0.0) {
981 return Err(EstimationError::InvalidInput(format!(
982 "multinomial posterior integrated class means have invalid sum {mean_sum}"
983 )));
984 }
985 let simplex_error = (mean_sum - 1.0).abs();
986 if simplex_error > (k as f64) * raw_error + covariance_roundoff_tolerance(1.0, k) {
987 return Err(EstimationError::InvalidInput(format!(
988 "multinomial posterior integrated class means violate the simplex: sum {mean_sum:.17e}, error {simplex_error:.6e}, raw moment error {raw_error:.6e}"
989 )));
990 }
991 for mean in &mut means {
992 *mean /= mean_sum;
993 }
994
995 let second_offset = k;
996 let mut covariance = Array2::<f64>::zeros((k, k));
997 for row in 0..k {
998 for column in row..k {
999 let packed = second_offset + upper_offsets[row] + column - row;
1000 let value = raw_moments[packed] - means[row] * means[column];
1001 covariance[[row, column]] = value;
1002 covariance[[column, row]] = value;
1003 }
1004 }
1005 covariance = project_covariance_to_simplex_tangent(&covariance);
1006 covariance = remove_covariance_roundoff(covariance, covariance_error)?;
1007 covariance = project_covariance_to_simplex_tangent(&covariance);
1008
1009 let mut standard_deviation = Array1::<f64>::zeros(k);
1010 for class in 0..k {
1011 let variance = covariance[[class, class]];
1012 if variance < -covariance_error || !variance.is_finite() {
1013 return Err(EstimationError::InvalidInput(format!(
1014 "multinomial posterior variance for class {class} is invalid: {variance:.6e} (covariance error envelope {covariance_error:.6e})"
1015 )));
1016 }
1017 standard_deviation[class] = variance.max(0.0).sqrt();
1018 }
1019
1020 Ok(MultinomialPosteriorMoments {
1021 class_mean: Array1::from_vec(means),
1022 class_covariance: covariance,
1023 class_standard_deviation: standard_deviation,
1024 latent_rank,
1025 sparse_level: Some(sparse_level),
1026 function_evaluations,
1027 max_raw_moment_level_difference: max_level_difference,
1028 covariance_range_projection_bound: projection_bound,
1029 })
1030}
1031
1032fn project_covariance_to_simplex_tangent(covariance: &Array2<f64>) -> Array2<f64> {
1033 let k = covariance.nrows();
1034 let inverse_k = 1.0 / k as f64;
1035 let row_means: Vec<f64> = (0..k)
1036 .map(|row| covariance.row(row).sum() * inverse_k)
1037 .collect();
1038 let column_means: Vec<f64> = (0..k)
1039 .map(|column| covariance.column(column).sum() * inverse_k)
1040 .collect();
1041 let grand_mean = row_means.iter().sum::<f64>() * inverse_k;
1042 Array2::from_shape_fn((k, k), |(row, column)| {
1043 covariance[[row, column]] - row_means[row] - column_means[column] + grand_mean
1044 })
1045}
1046
1047fn remove_covariance_roundoff(
1048 covariance: Array2<f64>,
1049 integration_error: f64,
1050) -> Result<Array2<f64>, EstimationError> {
1051 let symmetric = (&covariance + &covariance.t().to_owned()) * 0.5;
1052 let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
1053 EstimationError::InvalidInput(format!(
1054 "multinomial probability covariance eigendecomposition failed: {error}"
1055 ))
1056 })?;
1057 let scale = eigenvalues
1058 .iter()
1059 .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
1060 let allowed_negative =
1061 integration_error + covariance_roundoff_tolerance(scale, covariance.nrows());
1062 let minimum = eigenvalues
1063 .iter()
1064 .fold(f64::INFINITY, |value, &candidate| value.min(candidate));
1065 if minimum < -allowed_negative {
1066 let negative_limit = -allowed_negative;
1067 return Err(EstimationError::InvalidInput(format!(
1068 "multinomial posterior probability covariance is indefinite beyond the integration error: min eigenvalue {minimum:.6e}, allowed {negative_limit:.6e}"
1069 )));
1070 }
1071 let mut scaled_eigenvectors = eigenvectors.clone();
1072 for (column, &eigenvalue) in eigenvalues.iter().enumerate() {
1073 let scale = eigenvalue.max(0.0);
1074 scaled_eigenvectors
1075 .column_mut(column)
1076 .mapv_inplace(|value| value * scale);
1077 }
1078 let reconstructed = scaled_eigenvectors.dot(&eigenvectors.t());
1079 Ok((&reconstructed + &reconstructed.t().to_owned()) * 0.5)
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 fn control(absolute_tolerance: f64) -> MultinomialPosteriorIntegrationControl {
1087 MultinomialPosteriorIntegrationControl {
1088 absolute_tolerance,
1089 relative_tolerance: absolute_tolerance,
1090 minimum_sparse_level: 2,
1091 maximum_sparse_level: 8,
1092 maximum_function_evaluations: 2_000_000,
1093 }
1094 }
1095
1096 fn assert_close(actual: f64, expected: f64, tolerance: f64, label: &str) {
1097 assert!(
1098 (actual - expected).abs() <= tolerance,
1099 "{label}: actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.3e}"
1100 );
1101 }
1102
1103 #[test]
1104 fn binary_reduction_matches_controlled_logistic_normal_identity() {
1105 let active_mean = Array1::from_vec(vec![1.1]);
1106 let active_covariance = Array2::from_shape_vec((1, 1), vec![0.64]).unwrap();
1107 let result = integrate_logistic_normal_softmax_moments(
1108 active_mean.view(),
1109 active_covariance.view(),
1110 &control(1.0e-10),
1111 )
1112 .expect("binary posterior moments");
1113 let (expected_mean, expected_slope) =
1114 gam_solve::quadrature::logit_posterior_meanwith_deriv(1.1, 0.8).unwrap();
1115 let expected_variance = expected_mean - expected_slope - expected_mean * expected_mean;
1116
1117 assert_close(result.class_mean[0], expected_mean, 2.0e-14, "binary mean");
1118 assert_close(
1119 result.class_mean[1],
1120 1.0 - expected_mean,
1121 2.0e-14,
1122 "reference mean",
1123 );
1124 assert_close(
1125 result.class_covariance[[0, 0]],
1126 expected_variance,
1127 2.0e-14,
1128 "binary variance",
1129 );
1130 assert_close(
1131 result.class_covariance[[0, 1]],
1132 -expected_variance,
1133 2.0e-14,
1134 "binary covariance",
1135 );
1136 assert_eq!(result.latent_rank, 1);
1137 assert_eq!(result.sparse_level, None);
1138 }
1139
1140 #[test]
1141 fn zero_covariance_is_exact_softmax_point_mass() {
1142 let active_mean = Array1::from_vec(vec![0.7, -0.4]);
1143 let active_covariance = Array2::<f64>::zeros((2, 2));
1144 let result = integrate_logistic_normal_softmax_moments(
1145 active_mean.view(),
1146 active_covariance.view(),
1147 &control(1.0e-10),
1148 )
1149 .expect("point-mass posterior moments");
1150 let expected = softmax_with_reference(active_mean.as_slice().unwrap()).unwrap();
1151 for class in 0..3 {
1152 assert_close(
1153 result.class_mean[class],
1154 expected[class],
1155 1.0e-15,
1156 "point mean",
1157 );
1158 assert_eq!(result.class_standard_deviation[class], 0.0);
1159 for other in 0..3 {
1160 assert_eq!(result.class_covariance[[class, other]], 0.0);
1161 }
1162 }
1163 assert_eq!(result.latent_rank, 0);
1164 assert_eq!(result.sparse_level, None);
1165 }
1166
1167 #[test]
1168 fn exchangeable_full_logits_require_cross_covariance_and_integrate_to_uniform() {
1169 let variance = 0.7;
1174 let active_mean = Array1::zeros(2);
1175 let active_covariance = Array2::from_shape_vec(
1176 (2, 2),
1177 vec![2.0 * variance, variance, variance, 2.0 * variance],
1178 )
1179 .unwrap();
1180 let result = integrate_logistic_normal_softmax_moments(
1181 active_mean.view(),
1182 active_covariance.view(),
1183 &control(2.0e-7),
1184 )
1185 .expect("exchangeable posterior moments");
1186
1187 for class in 0..3 {
1188 assert_close(result.class_mean[class], 1.0 / 3.0, 8.0e-7, "uniform mean");
1189 }
1190 for class in 1..3 {
1191 assert_close(
1192 result.class_covariance[[class, class]],
1193 result.class_covariance[[0, 0]],
1194 2.0e-6,
1195 "exchangeable variance",
1196 );
1197 }
1198 for row in 0..3 {
1199 assert_close(
1200 result.class_covariance.row(row).sum(),
1201 0.0,
1202 2.0e-12,
1203 "simplex covariance row sum",
1204 );
1205 }
1206 assert_eq!(result.latent_rank, 2);
1207 assert!(result.sparse_level.is_some());
1208 }
1209
1210 #[test]
1211 fn rank_one_general_case_matches_independent_one_dimensional_gh_oracle() {
1212 let active_mean = Array1::from_vec(vec![0.45, -0.7]);
1213 let loading = [0.8_f64, -0.35_f64];
1214 let active_covariance =
1215 Array2::from_shape_fn((2, 2), |(row, column)| loading[row] * loading[column]);
1216 let result = integrate_logistic_normal_softmax_moments(
1217 active_mean.view(),
1218 active_covariance.view(),
1219 &control(5.0e-8),
1220 )
1221 .expect("rank-one posterior moments");
1222 assert_eq!(result.latent_rank, 1);
1223
1224 let oracle_rule = gauss_hermite_rule(21).unwrap(); let mut oracle_mean = [0.0_f64; 3];
1228 let mut oracle_second = [[0.0_f64; 3]; 3];
1229 for (&z, &weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter()) {
1230 let eta = [
1231 active_mean[0] + loading[0] * z,
1232 active_mean[1] + loading[1] * z,
1233 ];
1234 let probability = softmax_with_reference(&eta).unwrap();
1235 for row in 0..3 {
1236 oracle_mean[row] += weight * probability[row];
1237 for column in 0..3 {
1238 oracle_second[row][column] += weight * probability[row] * probability[column];
1239 }
1240 }
1241 }
1242 for row in 0..3 {
1243 assert_close(
1244 result.class_mean[row],
1245 oracle_mean[row],
1246 3.0e-7,
1247 "rank-one mean",
1248 );
1249 for column in 0..3 {
1250 let oracle_covariance =
1251 oracle_second[row][column] - oracle_mean[row] * oracle_mean[column];
1252 assert_close(
1253 result.class_covariance[[row, column]],
1254 oracle_covariance,
1255 8.0e-7,
1256 "rank-one covariance",
1257 );
1258 }
1259 }
1260 let allowed = 5.0e-8
1261 + 5.0e-8
1262 * result
1263 .class_mean
1264 .iter()
1265 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
1266 assert!(
1267 result.max_raw_moment_level_difference + result.covariance_range_projection_bound
1268 <= allowed * 1.01,
1269 "returned result must carry the level-difference certificate"
1270 );
1271 }
1272
1273 #[test]
1274 fn insufficient_sparse_level_is_a_typed_error_not_a_plugin_result() {
1275 let active_mean = Array1::from_vec(vec![1.2, -0.8]);
1276 let active_covariance = Array2::from_shape_vec((2, 2), vec![2.0, 0.9, 0.9, 1.5]).unwrap();
1277 let strict_control = MultinomialPosteriorIntegrationControl {
1278 absolute_tolerance: 1.0e-14,
1279 relative_tolerance: 1.0e-14,
1280 minimum_sparse_level: 1,
1281 maximum_sparse_level: 1,
1282 maximum_function_evaluations: 100_000,
1283 };
1284 let error = integrate_logistic_normal_softmax_moments(
1285 active_mean.view(),
1286 active_covariance.view(),
1287 &strict_control,
1288 )
1289 .expect_err("one sparse refinement cannot certify this nonlinear integral");
1290 assert!(
1291 error.to_string().contains("did not converge"),
1292 "unexpected error: {error}"
1293 );
1294 }
1295
1296 #[test]
1297 fn materially_indefinite_active_covariance_is_rejected() {
1298 let active_mean = Array1::from_vec(vec![0.0, 0.0]);
1299 let active_covariance = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 2.0, 1.0]).unwrap();
1300 let error = integrate_logistic_normal_softmax_moments(
1301 active_mean.view(),
1302 active_covariance.view(),
1303 &control(1.0e-7),
1304 )
1305 .expect_err("indefinite covariance must fail");
1306 assert!(error.to_string().contains("not positive semidefinite"));
1307 }
1308}