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