1use crate::model_types::EstimationError;
20use gam_linalg::faer_ndarray::FaerEigh;
21use gam_math::quadrature::gauss_hermite_rule as physicists_gauss_hermite_rule;
22use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
23use std::collections::BTreeMap;
24
25const PSD_BACKWARD_ERROR_MULTIPLIER: f64 = 16.0;
33
34const SUMMATION_ROUNDOFF_MULTIPLIER: f64 = 16.0;
36
37#[derive(Clone, Copy, Debug)]
43pub struct MultinomialPosteriorIntegrationControl {
44 pub absolute_tolerance: f64,
47 pub relative_tolerance: f64,
49 pub minimum_sparse_level: usize,
51 pub maximum_sparse_level: usize,
53 pub maximum_function_evaluations: usize,
55}
56
57impl Default for MultinomialPosteriorIntegrationControl {
58 fn default() -> Self {
59 let tolerance = f64::EPSILON.sqrt();
90 Self {
91 absolute_tolerance: tolerance,
92 relative_tolerance: tolerance,
93 minimum_sparse_level: 2,
94 maximum_sparse_level: 16,
171 maximum_function_evaluations: 2_000_000,
172 }
173 }
174}
175
176#[derive(Clone, Debug)]
179pub struct MultinomialPosteriorRowMoments {
180 pub class_mean: Array2<f64>,
181 pub class_standard_deviation: Array2<f64>,
182}
183
184pub fn integrate_multinomial_design_moments(
192 coefficients: ArrayView2<'_, f64>,
193 coefficient_covariance: ArrayView2<'_, f64>,
194 design: ArrayView2<'_, f64>,
195 control: &MultinomialPosteriorIntegrationControl,
196) -> Result<MultinomialPosteriorRowMoments, EstimationError> {
197 let (p, m) = coefficients.dim();
198 if p == 0 || m == 0 {
199 return Err(EstimationError::InvalidInput(format!(
200 "multinomial posterior prediction needs nonempty coefficients, got {p}x{m}"
201 )));
202 }
203 if design.ncols() != p {
204 return Err(EstimationError::InvalidInput(format!(
205 "multinomial posterior prediction design has {} columns, expected {p}",
206 design.ncols()
207 )));
208 }
209 let d = p.checked_mul(m).ok_or_else(|| {
210 EstimationError::InvalidInput(
211 "multinomial posterior prediction coefficient dimension overflowed usize".to_string(),
212 )
213 })?;
214 if coefficient_covariance.dim() != (d, d) {
215 return Err(EstimationError::InvalidInput(format!(
216 "multinomial posterior prediction covariance shape {:?} does not match (P*M, P*M) = ({d}, {d})",
217 coefficient_covariance.dim()
218 )));
219 }
220
221 let n = design.nrows();
222 let k = m + 1;
223 let mut class_mean = Array2::<f64>::zeros((n, k));
224 let mut class_standard_deviation = Array2::<f64>::zeros((n, k));
225 let mut active_mean = Array1::<f64>::zeros(m);
226 let mut active_covariance = Array2::<f64>::zeros((m, m));
227 let mut conditioned_three_class_rules = ConditionedThreeClassRuleLadder::default();
231 for row in 0..n {
232 let x = design.row(row);
233 for a in 0..m {
234 active_mean[a] = x.dot(&coefficients.column(a));
235 }
236 for a in 0..m {
237 for b in 0..m {
238 let mut value = 0.0_f64;
239 let a_base = a * p;
240 let b_base = b * p;
241 for i in 0..p {
242 let xi = x[i];
243 if xi == 0.0 {
244 continue;
245 }
246 let mut row_product = 0.0_f64;
247 for j in 0..p {
248 row_product += coefficient_covariance[[a_base + i, b_base + j]] * x[j];
249 }
250 value += xi * row_product;
251 }
252 active_covariance[[a, b]] = value;
253 }
254 }
255 let moments = integrate_logistic_normal_softmax_moments_with_rule_ladder(
256 active_mean.view(),
257 active_covariance.view(),
258 control,
259 &mut conditioned_three_class_rules,
260 )?;
261 class_mean.row_mut(row).assign(&moments.class_mean);
262 class_standard_deviation
263 .row_mut(row)
264 .assign(&moments.class_standard_deviation);
265 }
266 Ok(MultinomialPosteriorRowMoments {
267 class_mean,
268 class_standard_deviation,
269 })
270}
271
272impl MultinomialPosteriorIntegrationControl {
273 fn validate(&self) -> Result<(), EstimationError> {
274 if !(self.absolute_tolerance.is_finite() && self.absolute_tolerance >= 0.0) {
275 return Err(EstimationError::InvalidInput(format!(
276 "multinomial posterior integration absolute_tolerance must be finite and >= 0, got {}",
277 self.absolute_tolerance
278 )));
279 }
280 if !(self.relative_tolerance.is_finite() && self.relative_tolerance >= 0.0) {
281 return Err(EstimationError::InvalidInput(format!(
282 "multinomial posterior integration relative_tolerance must be finite and >= 0, got {}",
283 self.relative_tolerance
284 )));
285 }
286 if self.absolute_tolerance == 0.0 && self.relative_tolerance == 0.0 {
287 return Err(EstimationError::InvalidInput(
288 "multinomial posterior integration requires a positive absolute or relative tolerance"
289 .to_string(),
290 ));
291 }
292 if self.minimum_sparse_level == 0 {
293 return Err(EstimationError::InvalidInput(
294 "multinomial posterior integration minimum_sparse_level must be >= 1 so a level difference exists"
295 .to_string(),
296 ));
297 }
298 if self.maximum_sparse_level < self.minimum_sparse_level {
299 return Err(EstimationError::InvalidInput(format!(
300 "multinomial posterior integration maximum_sparse_level ({}) is below minimum_sparse_level ({})",
301 self.maximum_sparse_level, self.minimum_sparse_level
302 )));
303 }
304 if self.maximum_function_evaluations == 0 {
305 return Err(EstimationError::InvalidInput(
306 "multinomial posterior integration maximum_function_evaluations must be positive"
307 .to_string(),
308 ));
309 }
310 Ok(())
311 }
312}
313
314#[derive(Clone, Debug, PartialEq, Eq)]
322pub enum MultinomialPosteriorRule {
323 Exact,
326 ConditionedThreeClass(usize),
330 AnisotropicTensor(Vec<usize>),
333 IsotropicSparse(usize),
335}
336
337#[derive(Clone, Debug)]
343pub struct MultinomialPosteriorMoments {
344 pub class_mean: Array1<f64>,
346 pub class_covariance: Array2<f64>,
348 pub class_standard_deviation: Array1<f64>,
350 pub latent_rank: usize,
352 pub rule: MultinomialPosteriorRule,
354 pub function_evaluations: usize,
356 pub max_raw_moment_level_difference: f64,
360 pub covariance_range_projection_bound: f64,
364}
365
366pub fn integrate_logistic_normal_softmax_moments(
373 active_mean: ArrayView1<'_, f64>,
374 active_covariance: ArrayView2<'_, f64>,
375 control: &MultinomialPosteriorIntegrationControl,
376) -> Result<MultinomialPosteriorMoments, EstimationError> {
377 let mut conditioned_three_class_rules = ConditionedThreeClassRuleLadder::default();
378 integrate_logistic_normal_softmax_moments_with_rule_ladder(
379 active_mean,
380 active_covariance,
381 control,
382 &mut conditioned_three_class_rules,
383 )
384}
385
386fn integrate_logistic_normal_softmax_moments_with_rule_ladder(
387 active_mean: ArrayView1<'_, f64>,
388 active_covariance: ArrayView2<'_, f64>,
389 control: &MultinomialPosteriorIntegrationControl,
390 conditioned_three_class_rules: &mut ConditionedThreeClassRuleLadder,
391) -> Result<MultinomialPosteriorMoments, EstimationError> {
392 control.validate()?;
393 validate_inputs(active_mean, active_covariance)?;
394 let symmetric_covariance = symmetrized_covariance(active_covariance);
400 let active_covariance = symmetric_covariance.view();
401
402 let mean = active_mean.to_vec();
403 let m = mean.len();
404 if m == 1 {
405 return integrate_binary(mean[0], active_covariance[[0, 0]]);
406 }
407
408 let maximum_covariance_entry = active_covariance
409 .iter()
410 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
411 if maximum_covariance_entry == 0.0 {
412 return point_mass_moments(&mean);
413 }
414
415 let projected = project_active_covariance(active_covariance, control.absolute_tolerance)?;
416 if projected.factor.ncols() == 0 {
417 let mut out = point_mass_moments(&mean)?;
422 out.covariance_range_projection_bound = projected.projection_bound;
423 return Ok(out);
424 }
425 if m == 2 && projected.factor.ncols() == 2 {
426 return integrate_three_class_conditionally(
427 &mean,
428 &projected,
429 control,
430 conditioned_three_class_rules,
431 );
432 }
433
434 integrate_general(&mean, &projected, control)
435}
436
437fn validate_inputs(
438 active_mean: ArrayView1<'_, f64>,
439 active_covariance: ArrayView2<'_, f64>,
440) -> Result<(), EstimationError> {
441 let m = active_mean.len();
442 if m == 0 {
443 return Err(EstimationError::InvalidInput(
444 "multinomial posterior integration needs at least one active logit (K >= 2)"
445 .to_string(),
446 ));
447 }
448 if active_covariance.dim() != (m, m) {
449 return Err(EstimationError::InvalidInput(format!(
450 "multinomial posterior integration covariance shape {:?} does not match active mean length {m}",
451 active_covariance.dim()
452 )));
453 }
454 if let Some((index, value)) = active_mean
455 .iter()
456 .copied()
457 .enumerate()
458 .find(|(_, value)| !value.is_finite())
459 {
460 return Err(EstimationError::InvalidInput(format!(
461 "multinomial posterior integration active_mean[{index}] is non-finite: {value}"
462 )));
463 }
464 if let Some(((row, column), value)) = active_covariance
465 .indexed_iter()
466 .map(|(index, &value)| (index, value))
467 .find(|(_, value)| !value.is_finite())
468 {
469 return Err(EstimationError::InvalidInput(format!(
470 "multinomial posterior integration covariance[{row},{column}] is non-finite: {value}"
471 )));
472 }
473
474 let scale = active_covariance
475 .iter()
476 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
477 let symmetry_tolerance = f64::EPSILON.sqrt() * scale.max(1.0);
491 let mut maximum_asymmetry = 0.0_f64;
492 for row in 0..m {
493 for column in (row + 1)..m {
494 maximum_asymmetry = maximum_asymmetry
495 .max((active_covariance[[row, column]] - active_covariance[[column, row]]).abs());
496 }
497 }
498 if maximum_asymmetry > symmetry_tolerance {
499 return Err(EstimationError::InvalidInput(format!(
500 "multinomial posterior integration covariance is not symmetric: max asymmetry {maximum_asymmetry:.6e} exceeds structural tolerance {symmetry_tolerance:.6e} (scale {scale:.6e})"
501 )));
502 }
503 Ok(())
504}
505
506fn symmetrized_covariance(covariance: ArrayView2<'_, f64>) -> Array2<f64> {
512 let m = covariance.nrows();
513 let mut out = covariance.to_owned();
514 for row in 0..m {
515 for column in (row + 1)..m {
516 let average = 0.5 * (covariance[[row, column]] + covariance[[column, row]]);
517 out[[row, column]] = average;
518 out[[column, row]] = average;
519 }
520 }
521 out
522}
523
524fn covariance_roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
525 PSD_BACKWARD_ERROR_MULTIPLIER * f64::EPSILON * (dimension.max(1) as f64) * scale
526}
527
528fn integrate_binary(
529 active_mean: f64,
530 active_variance: f64,
531) -> Result<MultinomialPosteriorMoments, EstimationError> {
532 if active_variance < 0.0 {
533 return Err(EstimationError::InvalidInput(format!(
534 "binary logistic-normal variance must be non-negative, got {active_variance:.6e}"
535 )));
536 }
537 let sigma = active_variance.sqrt();
538 let (probability_mean, mean_logistic_slope) =
539 gam_solve::quadrature::logit_posterior_meanwith_deriv(active_mean, sigma)?;
540
541 let probability_second_moment = probability_mean - mean_logistic_slope;
546 let variance = (probability_second_moment - probability_mean * probability_mean).max(0.0);
547 let reference_mean = 1.0 - probability_mean;
548
549 let class_mean = Array1::from_vec(vec![probability_mean, reference_mean]);
550 let class_covariance =
551 Array2::from_shape_vec((2, 2), vec![variance, -variance, -variance, variance]).map_err(
552 |error| {
553 EstimationError::InvalidInput(format!(
554 "binary logistic-normal covariance construction failed: {error}"
555 ))
556 },
557 )?;
558 let standard_deviation = variance.sqrt();
559 Ok(MultinomialPosteriorMoments {
560 class_mean,
561 class_covariance,
562 class_standard_deviation: Array1::from_vec(vec![standard_deviation, standard_deviation]),
563 latent_rank: if active_variance > 0.0 { 1 } else { 0 },
564 rule: MultinomialPosteriorRule::Exact,
565 function_evaluations: 0,
566 max_raw_moment_level_difference: 0.0,
567 covariance_range_projection_bound: 0.0,
568 })
569}
570
571fn integrate_three_class_conditionally(
595 active_mean: &[f64],
596 projected: &ProjectedGaussian,
597 control: &MultinomialPosteriorIntegrationControl,
598 rules: &mut ConditionedThreeClassRuleLadder,
599) -> Result<MultinomialPosteriorMoments, EstimationError> {
600 let integrand = ThreeClassConditionalIntegrand::new(active_mean, projected)?;
601 let mut previous: Option<Vec<f64>> = None;
602 let mut total_evaluations = 0usize;
603 let mut rule_index = 1usize;
604 let mut refinement_depth = 0usize;
605 let mut last_difference = f64::INFINITY;
606 let mut last_deciding: Option<DecidingMoment> = None;
607
608 loop {
609 let node_count = rule_index
610 .checked_mul(2)
611 .and_then(|value| value.checked_sub(1))
612 .ok_or_else(|| {
613 EstimationError::InvalidInput(
614 "three-class conditional Gauss-Hermite order overflowed usize".to_string(),
615 )
616 })?;
617 let remaining = control
618 .maximum_function_evaluations
619 .saturating_sub(total_evaluations);
620 if node_count > remaining {
621 let deciding_report = match last_deciding {
622 Some(moment) => format!(
623 "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
624 moment.index,
625 moment.normalized_error,
626 moment.difference,
627 projected.projection_bound,
628 moment.tolerance,
629 ),
630 None => "no two conditional rules fit, so no raw moment was compared".to_string(),
631 };
632 return Err(EstimationError::InvalidInput(format!(
633 "multinomial logistic-normal quadrature did not converge: the next one-dimensional conditioned three-class rule needs {node_count} evaluations, against {remaining} remaining; final max raw-moment difference {last_difference:.6e}, evaluations {total_evaluations}/{}; {deciding_report}",
634 control.maximum_function_evaluations
635 )));
636 }
637
638 let rule = rules.rule(refinement_depth, rule_index)?;
639 let current = integrand.raw_moments(
640 rule,
641 &mut total_evaluations,
642 control.maximum_function_evaluations,
643 control.absolute_tolerance,
644 )?;
645 if let Some(previous_moments) = previous.as_ref() {
646 let mut certified = true;
647 let mut maximum_difference = 0.0_f64;
648 let mut deciding: Option<DecidingMoment> = None;
649 for (index, (&new_value, &old_value)) in
650 current.iter().zip(previous_moments.iter()).enumerate()
651 {
652 let difference = (new_value - old_value).abs();
653 maximum_difference = maximum_difference.max(difference);
654 let tolerance = control.absolute_tolerance
655 + control.relative_tolerance * new_value.abs().max(old_value.abs());
656 let controlled_error = difference + projected.projection_bound;
657 if controlled_error > tolerance {
658 certified = false;
659 }
660 let normalized_error = if tolerance > 0.0 {
661 controlled_error / tolerance
662 } else if controlled_error > 0.0 {
663 f64::INFINITY
664 } else {
665 0.0
666 };
667 if deciding
668 .as_ref()
669 .map(|current| normalized_error > current.normalized_error)
670 .unwrap_or(true)
671 {
672 deciding = Some(DecidingMoment {
673 index,
674 normalized_error,
675 difference,
676 tolerance,
677 });
678 }
679 }
680 last_difference = maximum_difference;
681 last_deciding = deciding;
682 if certified {
683 return moments_from_raw(
684 current,
685 3,
686 2,
687 MultinomialPosteriorRule::ConditionedThreeClass(node_count),
688 total_evaluations,
689 maximum_difference,
690 projected.projection_bound,
691 );
692 }
693 }
694 previous = Some(current);
695 rule_index = rule_index.checked_mul(2).ok_or_else(|| {
696 EstimationError::InvalidInput(
697 "three-class conditional Gauss-Hermite refinement overflowed usize".to_string(),
698 )
699 })?;
700 refinement_depth = refinement_depth.checked_add(1).ok_or_else(|| {
701 EstimationError::InvalidInput(
702 "three-class conditional Gauss-Hermite refinement depth overflowed usize"
703 .to_string(),
704 )
705 })?;
706 }
707}
708
709#[derive(Default)]
717struct ConditionedThreeClassRuleLadder {
718 rules: Vec<GaussHermiteRule>,
719}
720
721impl ConditionedThreeClassRuleLadder {
722 fn rule(
723 &mut self,
724 refinement_depth: usize,
725 rule_index: usize,
726 ) -> Result<&GaussHermiteRule, EstimationError> {
727 if self.rules.len() == refinement_depth {
728 self.rules.push(gauss_hermite_rule(rule_index)?);
729 }
730 self.rules.get(refinement_depth).ok_or_else(|| {
731 EstimationError::InvalidInput(format!(
732 "conditioned three-class rule ladder is missing refinement depth {refinement_depth}"
733 ))
734 })
735 }
736}
737
738struct ThreeClassConditionalIntegrand<'a> {
741 active_mean: &'a [f64],
742 conditioned_class: usize,
743 outer_class: usize,
744 outer_standard_deviation: f64,
745 conditional_regression: f64,
746 conditional_standard_deviation: f64,
747 upper_offsets: Vec<usize>,
748}
749
750impl<'a> ThreeClassConditionalIntegrand<'a> {
751 fn new(active_mean: &'a [f64], projected: &ProjectedGaussian) -> Result<Self, EstimationError> {
752 if active_mean.len() != 2 || projected.factor.dim() != (2, 2) {
753 return Err(EstimationError::InvalidInput(format!(
754 "conditioned three-class integration requires two active logits and a 2x2 retained factor, got {} logits and factor {:?}",
755 active_mean.len(),
756 projected.factor.dim()
757 )));
758 }
759 let row_variance = |row: usize| {
760 projected
761 .factor
762 .row(row)
763 .iter()
764 .map(|value| value * value)
765 .sum::<f64>()
766 };
767 let variances = [row_variance(0), row_variance(1)];
768 let outer_class = if variances[0] <= variances[1] { 0 } else { 1 };
773 let conditioned_class = 1 - outer_class;
774 let outer_variance = variances[outer_class];
775 if !(outer_variance.is_finite() && outer_variance > 0.0) {
776 return Err(EstimationError::InvalidInput(format!(
777 "conditioned three-class outer variance must be finite and positive, got {outer_variance}"
778 )));
779 }
780 let covariance = projected
781 .factor
782 .row(conditioned_class)
783 .iter()
784 .zip(projected.factor.row(outer_class).iter())
785 .map(|(left, right)| left * right)
786 .sum::<f64>();
787 let conditional_regression = covariance / outer_variance;
788 let conditional_variance = projected
792 .factor
793 .row(conditioned_class)
794 .iter()
795 .zip(projected.factor.row(outer_class).iter())
796 .map(|(conditioned, outer)| {
797 let residual = conditioned - conditional_regression * outer;
798 residual * residual
799 })
800 .sum::<f64>();
801 if !(conditional_variance.is_finite() && conditional_variance >= 0.0) {
802 return Err(EstimationError::InvalidInput(format!(
803 "conditioned three-class residual variance is invalid: {conditional_variance}"
804 )));
805 }
806 Ok(Self {
807 active_mean,
808 conditioned_class,
809 outer_class,
810 outer_standard_deviation: outer_variance.sqrt(),
811 conditional_regression,
812 conditional_standard_deviation: conditional_variance.sqrt(),
813 upper_offsets: upper_triangle_offsets(3)?,
814 })
815 }
816
817 fn raw_moments(
818 &self,
819 rule: &GaussHermiteRule,
820 total_evaluations: &mut usize,
821 maximum_function_evaluations: usize,
822 absolute_tolerance: f64,
823 ) -> Result<Vec<f64>, EstimationError> {
824 let mut accumulator = QuadratureAccumulator::new(packed_moment_count(3)?)?;
825 for (&standard_normal, &weight) in rule.nodes.iter().zip(rule.weights.iter()) {
826 if *total_evaluations >= maximum_function_evaluations {
827 return Err(EstimationError::InvalidInput(format!(
828 "multinomial conditioned three-class quadrature exhausted its function-evaluation budget ({maximum_function_evaluations}) before convergence"
829 )));
830 }
831 *total_evaluations += 1;
832
833 let outer_mean = self.active_mean[self.outer_class];
834 let outer_eta = outer_mean + self.outer_standard_deviation * standard_normal;
835 let conditioned_mean = self.active_mean[self.conditioned_class]
836 + self.conditional_regression * (outer_eta - outer_mean);
837 let scalar_location = conditioned_mean - gam_linalg::utils::stable_softplus(outer_eta);
838 let (selected_mean, selected_slope) =
839 gam_solve::quadrature::logit_posterior_meanwith_deriv(
840 scalar_location,
841 self.conditional_standard_deviation,
842 )
843 .map_err(|error| {
844 EstimationError::InvalidInput(format!(
845 "conditioned three-class scalar logistic-normal evaluation failed: {error}"
846 ))
847 })?;
848 let outer_share = (-gam_linalg::utils::stable_softplus(-outer_eta)).exp();
849 let reference_share = 1.0 - outer_share;
850 let selected_second = selected_mean - selected_slope;
851 let remainder_second = 1.0 - selected_mean - selected_slope;
852
853 let mut means = [0.0_f64; 3];
854 means[self.conditioned_class] = selected_mean;
855 means[self.outer_class] = outer_share * (1.0 - selected_mean);
856 means[2] = reference_share * (1.0 - selected_mean);
857
858 let mut seconds = [[0.0_f64; 3]; 3];
859 seconds[self.conditioned_class][self.conditioned_class] = selected_second;
860 seconds[self.conditioned_class][self.outer_class] = outer_share * selected_slope;
861 seconds[self.outer_class][self.conditioned_class] =
862 seconds[self.conditioned_class][self.outer_class];
863 seconds[self.conditioned_class][2] = reference_share * selected_slope;
864 seconds[2][self.conditioned_class] = seconds[self.conditioned_class][2];
865 seconds[self.outer_class][self.outer_class] =
866 outer_share * outer_share * remainder_second;
867 seconds[self.outer_class][2] = outer_share * reference_share * remainder_second;
868 seconds[2][self.outer_class] = seconds[self.outer_class][2];
869 seconds[2][2] = reference_share * reference_share * remainder_second;
870
871 accumulator.add_weight(weight);
872 for (class, mean) in means.into_iter().enumerate() {
873 accumulator.add_moment(class, weight * mean);
874 }
875 let second_offset = 3;
876 for row in 0..3 {
877 for column in row..3 {
878 let packed = second_offset + self.upper_offsets[row] + column - row;
879 accumulator.add_moment(packed, weight * seconds[row][column]);
880 }
881 }
882 }
883 let (mut raw_moments, mass, absolute_weight_sum) = accumulator.finish();
884 normalize_by_mass(
885 &mut raw_moments,
886 mass,
887 absolute_weight_sum,
888 absolute_tolerance,
889 &format!(
890 "conditioned three-class rule with {} nodes",
891 rule.nodes.len()
892 ),
893 )?;
894 Ok(raw_moments)
895 }
896}
897
898fn point_mass_moments(active_mean: &[f64]) -> Result<MultinomialPosteriorMoments, EstimationError> {
899 let class_mean = Array1::from_vec(softmax_with_reference(active_mean)?);
900 let k = class_mean.len();
901 Ok(MultinomialPosteriorMoments {
902 class_mean,
903 class_covariance: Array2::zeros((k, k)),
904 class_standard_deviation: Array1::zeros(k),
905 latent_rank: 0,
906 rule: MultinomialPosteriorRule::Exact,
907 function_evaluations: 1,
908 max_raw_moment_level_difference: 0.0,
909 covariance_range_projection_bound: 0.0,
910 })
911}
912
913struct ProjectedGaussian {
914 factor: Array2<f64>,
916 projection_bound: f64,
917 standard_deviations: Vec<f64>,
922}
923
924fn project_active_covariance(
925 covariance: ArrayView2<'_, f64>,
926 absolute_tolerance: f64,
927) -> Result<ProjectedGaussian, EstimationError> {
928 let m = covariance.nrows();
929 let symmetric = (&covariance.to_owned() + &covariance.t().to_owned()) * 0.5;
930 let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
931 EstimationError::InvalidInput(format!(
932 "multinomial posterior covariance eigendecomposition failed: {error}"
933 ))
934 })?;
935 let eigenvalue_scale = eigenvalues
936 .iter()
937 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
938 let tolerance = covariance_roundoff_tolerance(eigenvalue_scale, m);
939 let minimum_eigenvalue = eigenvalues
940 .iter()
941 .fold(f64::INFINITY, |minimum, &value| minimum.min(value));
942 if minimum_eigenvalue < -tolerance {
943 return Err(EstimationError::InvalidInput(format!(
944 "multinomial posterior active-logit covariance is not positive semidefinite: minimum eigenvalue {minimum_eigenvalue:.6e} is below -{tolerance:.6e} (scale {eigenvalue_scale:.6e})"
945 )));
946 }
947
948 let small_positive_trace: f64 = eigenvalues
949 .iter()
950 .copied()
951 .filter(|value| *value > 0.0 && *value <= tolerance)
952 .sum();
953 let candidate_projection_bound = small_positive_trace.sqrt();
957 let discard_small_positive = candidate_projection_bound <= absolute_tolerance;
958
959 let retained: Vec<(usize, f64)> = eigenvalues
960 .iter()
961 .copied()
962 .enumerate()
963 .filter(|(_, value)| *value > 0.0 && (!discard_small_positive || *value > tolerance))
964 .collect();
965 let projection_bound = if discard_small_positive {
966 candidate_projection_bound
967 } else {
968 0.0
969 };
970 let mut factor = Array2::<f64>::zeros((m, retained.len()));
971 let mut standard_deviations = Vec::new();
972 standard_deviations
973 .try_reserve_exact(retained.len())
974 .map_err(|error| {
975 EstimationError::InvalidInput(format!(
976 "multinomial posterior could not allocate retained standard deviations: {error}"
977 ))
978 })?;
979 for (output_column, (eigenvector_column, eigenvalue)) in retained.into_iter().enumerate() {
980 let scale = eigenvalue.sqrt();
981 standard_deviations.push(scale);
982 for row in 0..m {
983 factor[[row, output_column]] = eigenvectors[[row, eigenvector_column]] * scale;
984 }
985 }
986 Ok(ProjectedGaussian {
987 factor,
988 projection_bound,
989 standard_deviations,
990 })
991}
992
993struct RowIntegrand<'a> {
995 active_mean: &'a [f64],
996 projected: &'a ProjectedGaussian,
997 upper_offsets: Vec<usize>,
998 moment_count: usize,
999 k: usize,
1000}
1001
1002impl<'a> RowIntegrand<'a> {
1003 fn new(
1004 active_mean: &'a [f64],
1005 projected: &'a ProjectedGaussian,
1006 ) -> Result<Self, EstimationError> {
1007 let k = active_mean.len() + 1;
1008 Ok(Self {
1009 active_mean,
1010 projected,
1011 upper_offsets: upper_triangle_offsets(k)?,
1012 moment_count: packed_moment_count(k)?,
1013 k,
1014 })
1015 }
1016
1017 fn tensor_moments(
1029 &self,
1030 rules: &mut BTreeMap<usize, GaussHermiteRule>,
1031 orders: &[usize],
1032 total_evaluations: &mut usize,
1033 maximum_function_evaluations: usize,
1034 absolute_tolerance: f64,
1035 ) -> Result<Vec<f64>, EstimationError> {
1036 for &order in orders {
1037 if !rules.contains_key(&order) {
1038 rules.insert(order, gauss_hermite_rule(order)?);
1039 }
1040 }
1041 let axes: Vec<&GaussHermiteRule> = orders.iter().map(|order| &rules[order]).collect();
1042 let mut workspace = QuadratureWorkspace::new(
1043 self.active_mean,
1044 self.projected,
1045 &[],
1046 &self.upper_offsets,
1047 self.moment_count,
1048 total_evaluations,
1049 maximum_function_evaluations,
1050 )?;
1051 workspace.stream_axes(0, &axes, 1.0)?;
1052 let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
1053 let node_counts: Vec<usize> = orders.iter().map(|order| 2 * order - 1).collect();
1054 normalize_by_mass(
1055 &mut raw_moments,
1056 mass,
1057 absolute_weight_sum,
1058 absolute_tolerance,
1059 &format!("tensor rule with node counts {node_counts:?}"),
1060 )?;
1061 Ok(raw_moments)
1062 }
1063
1064 fn directional_rule_index(
1074 &self,
1075 rules: &mut BTreeMap<usize, GaussHermiteRule>,
1076 direction: usize,
1077 control: &MultinomialPosteriorIntegrationControl,
1078 total_evaluations: &mut usize,
1079 ) -> Result<usize, EstimationError> {
1080 let rank = self.projected.factor.ncols();
1081 let mut orders = vec![1usize; rank];
1082 let mut previous: Option<Vec<f64>> = None;
1083 let mut index = 1usize;
1084 loop {
1085 orders[direction] = index;
1086 let current = self.tensor_moments(
1087 rules,
1088 &orders,
1089 total_evaluations,
1090 control.maximum_function_evaluations,
1091 control.absolute_tolerance,
1092 )?;
1093 if let Some(previous_moments) = previous.as_ref() {
1094 let resolved =
1095 current
1096 .iter()
1097 .zip(previous_moments.iter())
1098 .all(|(new_value, old_value)| {
1099 let tolerance = control.absolute_tolerance
1100 + control.relative_tolerance * new_value.abs().max(old_value.abs());
1101 (new_value - old_value).abs() <= tolerance
1102 });
1103 if resolved {
1104 return Ok(index);
1105 }
1106 }
1107 previous = Some(current);
1108 let doubled = index.checked_mul(2).ok_or_else(|| {
1109 EstimationError::InvalidInput(
1110 "multinomial posterior directional rule index overflowed usize".to_string(),
1111 )
1112 })?;
1113 if tensor_node_count(&vec![doubled; rank])
1119 .map(|count| count > control.maximum_function_evaluations)
1120 .unwrap_or(true)
1121 {
1122 return Ok(index);
1123 }
1124 index = doubled;
1125 }
1126 }
1127}
1128
1129fn tensor_node_count(orders: &[usize]) -> Result<usize, EstimationError> {
1131 let mut count = 1usize;
1132 for &order in orders {
1133 let nodes = order
1134 .checked_mul(2)
1135 .and_then(|value| value.checked_sub(1))
1136 .ok_or_else(|| {
1137 EstimationError::InvalidInput(
1138 "multinomial posterior tensor rule order overflowed usize".to_string(),
1139 )
1140 })?;
1141 count = count.checked_mul(nodes).ok_or_else(|| {
1142 EstimationError::InvalidInput(
1143 "multinomial posterior tensor node count overflowed usize".to_string(),
1144 )
1145 })?;
1146 }
1147 Ok(count)
1148}
1149
1150fn normalize_by_mass(
1153 raw_moments: &mut [f64],
1154 mass: f64,
1155 absolute_weight_sum: f64,
1156 absolute_tolerance: f64,
1157 context: &str,
1158) -> Result<(), EstimationError> {
1159 if !(mass.is_finite() && mass > 0.0 && absolute_weight_sum.is_finite()) {
1160 return Err(EstimationError::InvalidInput(format!(
1161 "multinomial posterior {context} produced invalid total weight {mass} (absolute sum {absolute_weight_sum})"
1162 )));
1163 }
1164 let mass_error = (mass - 1.0).abs();
1165 let summation_envelope =
1166 SUMMATION_ROUNDOFF_MULTIPLIER * f64::EPSILON * absolute_weight_sum.max(1.0);
1167 if mass_error > absolute_tolerance + summation_envelope {
1168 return Err(EstimationError::InvalidInput(format!(
1169 "multinomial posterior {context} failed constant-function exactness: total weight {mass:.17e}, error {mass_error:.6e}, allowed {:.6e}",
1170 absolute_tolerance + summation_envelope
1171 )));
1172 }
1173 for value in raw_moments.iter_mut() {
1174 *value /= mass;
1175 }
1176 Ok(())
1177}
1178
1179fn integrate_general(
1202 active_mean: &[f64],
1203 projected: &ProjectedGaussian,
1204 control: &MultinomialPosteriorIntegrationControl,
1205) -> Result<MultinomialPosteriorMoments, EstimationError> {
1206 let sparse_refusal = match integrate_isotropic_sparse(active_mean, projected, control, 0) {
1207 Ok(moments) => return Ok(moments),
1208 Err(refusal) => refusal,
1209 };
1210
1211 let integrand = RowIntegrand::new(active_mean, projected)?;
1212 let rank = projected.factor.ncols();
1213 let mut rules = BTreeMap::<usize, GaussHermiteRule>::new();
1214 let mut total_evaluations = 0usize;
1215
1216 let mut orders = vec![1usize; rank];
1217 for direction in 0..rank {
1218 orders[direction] = integrand.directional_rule_index(
1219 &mut rules,
1220 direction,
1221 control,
1222 &mut total_evaluations,
1223 )?;
1224 }
1225
1226 let refined: Vec<usize> = orders.iter().map(|order| order.saturating_mul(2)).collect();
1227 let certifying_pair_cost =
1228 tensor_node_count(&orders)?.saturating_add(tensor_node_count(&refined)?);
1229 let remaining = control
1230 .maximum_function_evaluations
1231 .saturating_sub(total_evaluations);
1232 if certifying_pair_cost > remaining {
1233 return Err(EstimationError::InvalidInput(format!(
1237 "{sparse_refusal}; the tensor rule sized for this posterior would need {certifying_pair_cost} evaluations to certify, against {remaining} remaining"
1238 )));
1239 }
1240
1241 integrate_anisotropic_tensor(&integrand, &mut rules, orders, control, total_evaluations)
1242}
1243
1244fn integrate_anisotropic_tensor(
1249 integrand: &RowIntegrand<'_>,
1250 rules: &mut BTreeMap<usize, GaussHermiteRule>,
1251 mut orders: Vec<usize>,
1252 control: &MultinomialPosteriorIntegrationControl,
1253 mut total_evaluations: usize,
1254) -> Result<MultinomialPosteriorMoments, EstimationError> {
1255 let rank = orders.len();
1256 let projection_bound = integrand.projected.projection_bound;
1257 let mut coarse = integrand.tensor_moments(
1258 rules,
1259 &orders,
1260 &mut total_evaluations,
1261 control.maximum_function_evaluations,
1262 control.absolute_tolerance,
1263 )?;
1264 loop {
1265 let refined: Vec<usize> = orders.iter().map(|order| order * 2).collect();
1266 let fine = integrand.tensor_moments(
1267 rules,
1268 &refined,
1269 &mut total_evaluations,
1270 control.maximum_function_evaluations,
1271 control.absolute_tolerance,
1272 )?;
1273
1274 let mut certified = true;
1275 let mut maximum_difference = 0.0_f64;
1276 let mut deciding: Option<DecidingMoment> = None;
1277 for (index, (&new_value, &old_value)) in fine.iter().zip(coarse.iter()).enumerate() {
1278 let difference = (new_value - old_value).abs();
1279 maximum_difference = maximum_difference.max(difference);
1280 let tolerance = control.absolute_tolerance
1281 + control.relative_tolerance * new_value.abs().max(old_value.abs());
1282 let controlled_error = difference + projection_bound;
1283 if controlled_error > tolerance {
1284 certified = false;
1285 }
1286 let normalized = if tolerance > 0.0 {
1287 controlled_error / tolerance
1288 } else if controlled_error > 0.0 {
1289 f64::INFINITY
1290 } else {
1291 0.0
1292 };
1293 let supersedes = match deciding.as_ref() {
1294 Some(current_worst) => normalized > current_worst.normalized_error,
1295 None => true,
1296 };
1297 if supersedes {
1298 deciding = Some(DecidingMoment {
1299 index,
1300 normalized_error: normalized,
1301 difference,
1302 tolerance,
1303 });
1304 }
1305 }
1306
1307 let node_counts: Vec<usize> = refined.iter().map(|order| 2 * order - 1).collect();
1308 if certified {
1309 return moments_from_raw(
1310 fine,
1311 integrand.k,
1312 rank,
1313 MultinomialPosteriorRule::AnisotropicTensor(node_counts),
1314 total_evaluations,
1315 maximum_difference,
1316 projection_bound,
1317 );
1318 }
1319
1320 orders = refined;
1321 coarse = fine;
1322 let next: Vec<usize> = orders.iter().map(|order| order * 2).collect();
1323 let next_cost = tensor_node_count(&next)?;
1324 if next_cost
1325 > control
1326 .maximum_function_evaluations
1327 .saturating_sub(total_evaluations)
1328 {
1329 let standard_deviations: Vec<String> = integrand
1330 .projected
1331 .standard_deviations
1332 .iter()
1333 .map(|value| format!("{value:.6e}"))
1334 .collect();
1335 let deciding_report = match deciding.as_ref() {
1336 Some(moment) => format!(
1337 "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
1338 moment.index,
1339 moment.normalized_error,
1340 moment.difference,
1341 projection_bound,
1342 moment.tolerance,
1343 ),
1344 None => "no raw moment was compared".to_string(),
1345 };
1346 return Err(EstimationError::InvalidInput(format!(
1347 "multinomial logistic-normal quadrature did not converge: tensor rule with node counts {node_counts:?} over posterior standard deviations [{}] left a raw-moment difference {maximum_difference:.6e}, and doubling it again would need {next_cost} of the {} evaluations still allowed; evaluations {total_evaluations}/{}; {deciding_report}",
1348 standard_deviations.join(", "),
1349 control
1350 .maximum_function_evaluations
1351 .saturating_sub(total_evaluations),
1352 control.maximum_function_evaluations,
1353 )));
1354 }
1355 }
1356}
1357
1358fn integrate_isotropic_sparse(
1359 active_mean: &[f64],
1360 projected: &ProjectedGaussian,
1361 control: &MultinomialPosteriorIntegrationControl,
1362 initial_evaluations: usize,
1363) -> Result<MultinomialPosteriorMoments, EstimationError> {
1364 let rank = projected.factor.ncols();
1365 let k = active_mean.len() + 1;
1366 let mut rules = Vec::<GaussHermiteRule>::new();
1367 let mut previous: Option<Vec<f64>> = None;
1368 let mut total_evaluations = initial_evaluations;
1369 let mut last_max_difference = f64::INFINITY;
1370 let mut last_max_normalized_error = f64::INFINITY;
1371 let mut last_deciding: Option<DecidingMoment> = None;
1372
1373 let mut last_level_attempted = 0usize;
1374 for level in 0..=control.maximum_sparse_level {
1375 last_level_attempted = level;
1376 let required_rule_count = level.checked_add(1).ok_or_else(|| {
1377 EstimationError::InvalidInput(
1378 "multinomial posterior sparse level overflowed usize".to_string(),
1379 )
1380 })?;
1381 while rules.len() < required_rule_count {
1382 let rule_index = rules.len() + 1;
1383 rules.push(gauss_hermite_rule(rule_index)?);
1384 }
1385
1386 let evaluation = evaluate_smolyak_level(
1387 active_mean,
1388 projected,
1389 &rules,
1390 level,
1391 k,
1392 &mut total_evaluations,
1393 control.maximum_function_evaluations,
1394 control.absolute_tolerance,
1395 )?;
1396 let current = evaluation.raw_moments;
1397
1398 if let Some(previous_moments) = previous.as_ref() {
1399 let mut certified = level >= control.minimum_sparse_level;
1400 let mut maximum_difference = 0.0_f64;
1401 let mut maximum_normalized_error = 0.0_f64;
1402 let mut deciding: Option<DecidingMoment> = None;
1418 for (index, (&new_value, &old_value)) in
1419 current.iter().zip(previous_moments.iter()).enumerate()
1420 {
1421 let difference = (new_value - old_value).abs();
1422 maximum_difference = maximum_difference.max(difference);
1423 let tolerance = control.absolute_tolerance
1424 + control.relative_tolerance * new_value.abs().max(old_value.abs());
1425 let controlled_error = difference + projected.projection_bound;
1426 if controlled_error > tolerance {
1427 certified = false;
1428 }
1429 let normalized = if tolerance > 0.0 {
1437 controlled_error / tolerance
1438 } else if controlled_error > 0.0 {
1439 f64::INFINITY
1440 } else {
1441 0.0
1442 };
1443 let supersedes = match deciding.as_ref() {
1444 Some(current_worst) => normalized > current_worst.normalized_error,
1445 None => true,
1446 };
1447 if supersedes {
1448 deciding = Some(DecidingMoment {
1449 index,
1450 normalized_error: normalized,
1451 difference,
1452 tolerance,
1453 });
1454 }
1455 maximum_normalized_error = maximum_normalized_error.max(normalized);
1456 }
1457 last_max_difference = maximum_difference;
1458 last_max_normalized_error = maximum_normalized_error;
1459 last_deciding = deciding;
1460
1461 if certified {
1462 return moments_from_raw(
1463 current,
1464 k,
1465 rank,
1466 MultinomialPosteriorRule::IsotropicSparse(level),
1467 total_evaluations,
1468 maximum_difference,
1469 projected.projection_bound,
1470 );
1471 }
1472 }
1473 previous = Some(current);
1474 }
1475
1476 let deciding_report = match last_deciding.as_ref() {
1484 Some(moment) => format!(
1485 "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
1486 moment.index,
1487 moment.normalized_error,
1488 moment.difference,
1489 projected.projection_bound,
1490 moment.tolerance,
1491 ),
1492 None => "no two levels were compared, so no coordinate refused".to_string(),
1496 };
1497 Err(EstimationError::InvalidInput(format!(
1498 "multinomial logistic-normal quadrature did not converge: reached Smolyak level {last_level_attempted} and exhausted the evaluation budget; final max raw-moment level difference {last_max_difference:.6e}, max normalized error {last_max_normalized_error:.6e}, projection bound {:.6e}, evaluations {total_evaluations}/{}; {deciding_report}",
1499 projected.projection_bound, control.maximum_function_evaluations
1500 )))
1501}
1502
1503#[derive(Clone, Copy, Debug)]
1511struct DecidingMoment {
1512 index: usize,
1513 normalized_error: f64,
1514 difference: f64,
1515 tolerance: f64,
1516}
1517
1518struct SmolyakEvaluation {
1519 raw_moments: Vec<f64>,
1520}
1521
1522fn evaluate_smolyak_level(
1523 active_mean: &[f64],
1524 projected: &ProjectedGaussian,
1525 rules: &[GaussHermiteRule],
1526 level: usize,
1527 k: usize,
1528 total_evaluations: &mut usize,
1529 maximum_function_evaluations: usize,
1530 absolute_tolerance: f64,
1531) -> Result<SmolyakEvaluation, EstimationError> {
1532 let rank = projected.factor.ncols();
1533 let q = rank.checked_add(level).ok_or_else(|| {
1534 EstimationError::InvalidInput(
1535 "multinomial posterior Smolyak index overflowed usize".to_string(),
1536 )
1537 })?;
1538 let lower_total = q.saturating_sub(rank.saturating_sub(1)).max(rank);
1539 let moment_count = packed_moment_count(k)?;
1540 let upper_offsets = upper_triangle_offsets(k)?;
1541 let mut workspace = QuadratureWorkspace::new(
1542 active_mean,
1543 projected,
1544 rules,
1545 &upper_offsets,
1546 moment_count,
1547 total_evaluations,
1548 maximum_function_evaluations,
1549 )?;
1550 let mut indices = vec![1usize; rank];
1551
1552 for total in lower_total..=q {
1553 let alternating_power = q - total;
1554 let mut coefficient = binomial_as_f64(rank - 1, alternating_power)?;
1555 if alternating_power % 2 == 1 {
1556 coefficient = -coefficient;
1557 }
1558 workspace.stream_compositions(0, total, &mut indices, coefficient)?;
1559 }
1560
1561 let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
1562 normalize_by_mass(
1563 &mut raw_moments,
1564 mass,
1565 absolute_weight_sum,
1566 absolute_tolerance,
1567 &format!("Smolyak level {level}"),
1568 )?;
1569 Ok(SmolyakEvaluation { raw_moments })
1570}
1571
1572fn packed_moment_count(k: usize) -> Result<usize, EstimationError> {
1573 let triangular = k
1574 .checked_add(1)
1575 .and_then(|next| k.checked_mul(next))
1576 .map(|product| product / 2)
1577 .ok_or_else(|| {
1578 EstimationError::InvalidInput(
1579 "multinomial posterior moment dimension overflowed usize".to_string(),
1580 )
1581 })?;
1582 k.checked_add(triangular).ok_or_else(|| {
1583 EstimationError::InvalidInput(
1584 "multinomial posterior packed moment count overflowed usize".to_string(),
1585 )
1586 })
1587}
1588
1589fn upper_triangle_offsets(k: usize) -> Result<Vec<usize>, EstimationError> {
1590 let mut offsets = Vec::new();
1591 offsets.try_reserve_exact(k).map_err(|error| {
1592 EstimationError::InvalidInput(format!(
1593 "multinomial posterior could not allocate upper-triangle offsets: {error}"
1594 ))
1595 })?;
1596 let mut cursor = 0usize;
1597 for row in 0..k {
1598 offsets.push(cursor);
1599 cursor = cursor.checked_add(k - row).ok_or_else(|| {
1600 EstimationError::InvalidInput(
1601 "multinomial posterior upper-triangle offset overflowed usize".to_string(),
1602 )
1603 })?;
1604 }
1605 Ok(offsets)
1606}
1607
1608fn zeroed_vec(length: usize, label: &str) -> Result<Vec<f64>, EstimationError> {
1609 let mut values = Vec::new();
1610 values.try_reserve_exact(length).map_err(|error| {
1611 EstimationError::InvalidInput(format!(
1612 "multinomial posterior could not allocate {label} (length {length}): {error}"
1613 ))
1614 })?;
1615 values.resize(length, 0.0);
1616 Ok(values)
1617}
1618
1619struct CompensatedSum {
1620 sum: f64,
1621 correction: f64,
1622}
1623
1624impl CompensatedSum {
1625 fn new() -> Self {
1626 Self {
1627 sum: 0.0,
1628 correction: 0.0,
1629 }
1630 }
1631
1632 fn add(&mut self, value: f64) {
1633 let combined = self.sum + value;
1634 if self.sum.abs() >= value.abs() {
1635 self.correction += (self.sum - combined) + value;
1636 } else {
1637 self.correction += (value - combined) + self.sum;
1638 }
1639 self.sum = combined;
1640 }
1641
1642 fn value(&self) -> f64 {
1643 self.sum + self.correction
1644 }
1645}
1646
1647struct QuadratureAccumulator {
1648 sums: Vec<f64>,
1649 corrections: Vec<f64>,
1650 mass: CompensatedSum,
1651 absolute_weight_sum: f64,
1652}
1653
1654impl QuadratureAccumulator {
1655 fn new(moment_count: usize) -> Result<Self, EstimationError> {
1656 Ok(Self {
1657 sums: zeroed_vec(moment_count, "quadrature sums")?,
1658 corrections: zeroed_vec(moment_count, "quadrature corrections")?,
1659 mass: CompensatedSum::new(),
1660 absolute_weight_sum: 0.0,
1661 })
1662 }
1663
1664 fn add_moment(&mut self, index: usize, value: f64) {
1665 let combined = self.sums[index] + value;
1666 if self.sums[index].abs() >= value.abs() {
1667 self.corrections[index] += (self.sums[index] - combined) + value;
1668 } else {
1669 self.corrections[index] += (value - combined) + self.sums[index];
1670 }
1671 self.sums[index] = combined;
1672 }
1673
1674 fn add_weight(&mut self, weight: f64) {
1675 self.mass.add(weight);
1676 self.absolute_weight_sum += weight.abs();
1677 }
1678
1679 fn finish(mut self) -> (Vec<f64>, f64, f64) {
1680 for (sum, correction) in self.sums.iter_mut().zip(self.corrections.iter()) {
1681 *sum += *correction;
1682 }
1683 (self.sums, self.mass.value(), self.absolute_weight_sum)
1684 }
1685}
1686
1687struct QuadratureWorkspace<'a, 'b> {
1688 active_mean: &'a [f64],
1689 projected: &'a ProjectedGaussian,
1690 rules: &'a [GaussHermiteRule],
1691 upper_offsets: &'a [usize],
1692 z: Vec<f64>,
1693 active_eta: Vec<f64>,
1694 probabilities: Vec<f64>,
1695 accumulator: QuadratureAccumulator,
1696 total_evaluations: &'b mut usize,
1697 maximum_function_evaluations: usize,
1698}
1699
1700impl<'a, 'b> QuadratureWorkspace<'a, 'b> {
1701 fn new(
1702 active_mean: &'a [f64],
1703 projected: &'a ProjectedGaussian,
1704 rules: &'a [GaussHermiteRule],
1705 upper_offsets: &'a [usize],
1706 moment_count: usize,
1707 total_evaluations: &'b mut usize,
1708 maximum_function_evaluations: usize,
1709 ) -> Result<Self, EstimationError> {
1710 let rank = projected.factor.ncols();
1711 let m = active_mean.len();
1712 Ok(Self {
1713 active_mean,
1714 projected,
1715 rules,
1716 upper_offsets,
1717 z: zeroed_vec(rank, "standard-normal quadrature coordinate")?,
1718 active_eta: zeroed_vec(m, "active-logit quadrature buffer")?,
1719 probabilities: zeroed_vec(m + 1, "softmax quadrature buffer")?,
1720 accumulator: QuadratureAccumulator::new(moment_count)?,
1721 total_evaluations,
1722 maximum_function_evaluations,
1723 })
1724 }
1725
1726 fn stream_compositions(
1727 &mut self,
1728 position: usize,
1729 remaining: usize,
1730 indices: &mut [usize],
1731 coefficient: f64,
1732 ) -> Result<(), EstimationError> {
1733 let dimensions_left = indices.len() - position;
1734 if dimensions_left == 1 {
1735 if remaining == 0 {
1736 return Ok(());
1737 }
1738 indices[position] = remaining;
1739 return self.stream_tensor(0, indices, coefficient);
1740 }
1741 let maximum_here = remaining.saturating_sub(dimensions_left - 1);
1742 for index in 1..=maximum_here {
1743 indices[position] = index;
1744 self.stream_compositions(position + 1, remaining - index, indices, coefficient)?;
1745 }
1746 Ok(())
1747 }
1748
1749 fn stream_tensor(
1750 &mut self,
1751 axis: usize,
1752 indices: &[usize],
1753 weight: f64,
1754 ) -> Result<(), EstimationError> {
1755 if axis == indices.len() {
1756 return self.accumulate_node(weight);
1757 }
1758 let rule_index = indices[axis] - 1;
1759 let node_count = self.rules[rule_index].nodes.len();
1760 for node_index in 0..node_count {
1761 let node = self.rules[rule_index].nodes[node_index];
1762 let node_weight = self.rules[rule_index].weights[node_index];
1763 self.z[axis] = node;
1764 self.stream_tensor(axis + 1, indices, weight * node_weight)?;
1765 }
1766 Ok(())
1767 }
1768
1769 fn stream_axes(
1775 &mut self,
1776 axis: usize,
1777 axes: &[&GaussHermiteRule],
1778 weight: f64,
1779 ) -> Result<(), EstimationError> {
1780 if axis == axes.len() {
1781 return self.accumulate_node(weight);
1782 }
1783 let node_count = axes[axis].nodes.len();
1784 for node_index in 0..node_count {
1785 let node = axes[axis].nodes[node_index];
1786 let node_weight = axes[axis].weights[node_index];
1787 self.z[axis] = node;
1788 self.stream_axes(axis + 1, axes, weight * node_weight)?;
1789 }
1790 Ok(())
1791 }
1792
1793 fn accumulate_node(&mut self, weight: f64) -> Result<(), EstimationError> {
1794 if *self.total_evaluations >= self.maximum_function_evaluations {
1795 return Err(EstimationError::InvalidInput(format!(
1796 "multinomial logistic-normal quadrature exhausted its function-evaluation budget ({}) before convergence",
1797 self.maximum_function_evaluations
1798 )));
1799 }
1800 *self.total_evaluations += 1;
1801
1802 for row in 0..self.active_mean.len() {
1803 let mut value = self.active_mean[row];
1804 for column in 0..self.z.len() {
1805 value += self.projected.factor[[row, column]] * self.z[column];
1806 }
1807 self.active_eta[row] = value;
1808 }
1809 softmax_with_reference_into(&self.active_eta, &mut self.probabilities)?;
1810
1811 let k = self.probabilities.len();
1812 self.accumulator.add_weight(weight);
1813 for class in 0..k {
1814 self.accumulator
1815 .add_moment(class, weight * self.probabilities[class]);
1816 }
1817 let second_offset = k;
1818 for row in 0..k {
1819 for column in row..k {
1820 let packed = second_offset + self.upper_offsets[row] + column - row;
1821 self.accumulator.add_moment(
1822 packed,
1823 weight * self.probabilities[row] * self.probabilities[column],
1824 );
1825 }
1826 }
1827 Ok(())
1828 }
1829}
1830
1831struct GaussHermiteRule {
1832 nodes: Vec<f64>,
1834 weights: Vec<f64>,
1836}
1837
1838fn gauss_hermite_rule(index: usize) -> Result<GaussHermiteRule, EstimationError> {
1839 let node_count = index
1840 .checked_mul(2)
1841 .and_then(|value| value.checked_sub(1))
1842 .ok_or_else(|| {
1843 EstimationError::InvalidInput(
1844 "multinomial posterior Gauss-Hermite order overflowed usize".to_string(),
1845 )
1846 })?;
1847 let physicists = physicists_gauss_hermite_rule(node_count).map_err(|error| {
1848 EstimationError::InvalidInput(format!(
1849 "multinomial posterior Gauss-Hermite rule {node_count} construction failed: {error}"
1850 ))
1851 })?;
1852 let nodes = physicists
1853 .nodes
1854 .into_iter()
1855 .map(|node| std::f64::consts::SQRT_2 * node)
1856 .collect::<Vec<_>>();
1857 let mut weights = physicists.weights;
1858 let weight_sum: f64 = weights.iter().sum();
1859 if !(weight_sum.is_finite() && weight_sum > 0.0) {
1860 return Err(EstimationError::InvalidInput(format!(
1861 "multinomial posterior Gauss-Hermite rule {node_count} has invalid weight sum {weight_sum}"
1862 )));
1863 }
1864 for weight in &mut weights {
1865 *weight /= weight_sum;
1866 }
1867 Ok(GaussHermiteRule { nodes, weights })
1868}
1869
1870fn binomial_as_f64(n: usize, k: usize) -> Result<f64, EstimationError> {
1871 if k > n {
1872 return Ok(0.0);
1873 }
1874 let k = k.min(n - k);
1875 let mut value = 1.0_f64;
1876 for step in 1..=k {
1877 value *= (n - k + step) as f64 / step as f64;
1878 if !value.is_finite() {
1879 return Err(EstimationError::InvalidInput(format!(
1880 "multinomial posterior Smolyak binomial coefficient C({n},{k}) overflowed f64"
1881 )));
1882 }
1883 }
1884 Ok(value)
1885}
1886
1887pub(crate) fn softmax_with_reference(active_eta: &[f64]) -> Result<Vec<f64>, EstimationError> {
1896 let mut probabilities = zeroed_vec(active_eta.len() + 1, "softmax result")?;
1897 softmax_with_reference_into(active_eta, &mut probabilities)?;
1898 Ok(probabilities)
1899}
1900
1901fn softmax_with_reference_into(
1902 active_eta: &[f64],
1903 probabilities: &mut [f64],
1904) -> Result<(), EstimationError> {
1905 if probabilities.len() != active_eta.len() + 1 {
1906 return Err(EstimationError::InvalidInput(format!(
1907 "multinomial posterior softmax buffer length {} does not equal active-logit length {} + 1",
1908 probabilities.len(),
1909 active_eta.len()
1910 )));
1911 }
1912 let maximum = active_eta.iter().copied().fold(0.0_f64, f64::max);
1913 let reference = probabilities.len() - 1;
1914 let mut denominator = (-maximum).exp();
1915 probabilities[reference] = denominator;
1916 for (class, &eta) in active_eta.iter().enumerate() {
1917 let numerator = (eta - maximum).exp();
1918 probabilities[class] = numerator;
1919 denominator += numerator;
1920 }
1921 if !(denominator.is_finite() && denominator > 0.0) {
1922 return Err(EstimationError::InvalidInput(format!(
1923 "multinomial posterior softmax produced invalid denominator {denominator}"
1924 )));
1925 }
1926 for probability in probabilities {
1927 *probability /= denominator;
1928 }
1929 Ok(())
1930}
1931
1932fn moments_from_raw(
1933 raw_moments: Vec<f64>,
1934 k: usize,
1935 latent_rank: usize,
1936 rule: MultinomialPosteriorRule,
1937 function_evaluations: usize,
1938 max_level_difference: f64,
1939 projection_bound: f64,
1940) -> Result<MultinomialPosteriorMoments, EstimationError> {
1941 let upper_offsets = upper_triangle_offsets(k)?;
1942 let raw_error = max_level_difference + projection_bound;
1943 let covariance_error = 3.0 * raw_error + raw_error * raw_error;
1944
1945 let mut means = raw_moments[..k].to_vec();
1946 for (class, mean) in means.iter_mut().enumerate() {
1947 if *mean < -raw_error || *mean > 1.0 + raw_error || !mean.is_finite() {
1948 return Err(EstimationError::InvalidInput(format!(
1949 "multinomial posterior integrated mean for class {class} is outside its certified probability envelope: {mean} (raw error {raw_error:.6e})"
1950 )));
1951 }
1952 *mean = mean.clamp(0.0, 1.0);
1953 }
1954 let mean_sum: f64 = means.iter().sum();
1955 if !(mean_sum.is_finite() && mean_sum > 0.0) {
1956 return Err(EstimationError::InvalidInput(format!(
1957 "multinomial posterior integrated class means have invalid sum {mean_sum}"
1958 )));
1959 }
1960 let simplex_error = (mean_sum - 1.0).abs();
1961 if simplex_error > (k as f64) * raw_error + covariance_roundoff_tolerance(1.0, k) {
1962 return Err(EstimationError::InvalidInput(format!(
1963 "multinomial posterior integrated class means violate the simplex: sum {mean_sum:.17e}, error {simplex_error:.6e}, raw moment error {raw_error:.6e}"
1964 )));
1965 }
1966 for mean in &mut means {
1967 *mean /= mean_sum;
1968 }
1969
1970 let second_offset = k;
1971 let mut covariance = Array2::<f64>::zeros((k, k));
1972 for row in 0..k {
1973 for column in row..k {
1974 let packed = second_offset + upper_offsets[row] + column - row;
1975 let value = raw_moments[packed] - means[row] * means[column];
1976 covariance[[row, column]] = value;
1977 covariance[[column, row]] = value;
1978 }
1979 }
1980 covariance = project_covariance_to_simplex_tangent(&covariance);
1981 covariance = remove_covariance_roundoff(covariance, covariance_error)?;
1982 covariance = project_covariance_to_simplex_tangent(&covariance);
1983
1984 let mut standard_deviation = Array1::<f64>::zeros(k);
1985 for class in 0..k {
1986 let variance = covariance[[class, class]];
1987 if variance < -covariance_error || !variance.is_finite() {
1988 return Err(EstimationError::InvalidInput(format!(
1989 "multinomial posterior variance for class {class} is invalid: {variance:.6e} (covariance error envelope {covariance_error:.6e})"
1990 )));
1991 }
1992 standard_deviation[class] = variance.max(0.0).sqrt();
1993 }
1994
1995 Ok(MultinomialPosteriorMoments {
1996 class_mean: Array1::from_vec(means),
1997 class_covariance: covariance,
1998 class_standard_deviation: standard_deviation,
1999 latent_rank,
2000 rule,
2001 function_evaluations,
2002 max_raw_moment_level_difference: max_level_difference,
2003 covariance_range_projection_bound: projection_bound,
2004 })
2005}
2006
2007fn project_covariance_to_simplex_tangent(covariance: &Array2<f64>) -> Array2<f64> {
2008 let k = covariance.nrows();
2009 let inverse_k = 1.0 / k as f64;
2010 let row_means: Vec<f64> = (0..k)
2011 .map(|row| covariance.row(row).sum() * inverse_k)
2012 .collect();
2013 let column_means: Vec<f64> = (0..k)
2014 .map(|column| covariance.column(column).sum() * inverse_k)
2015 .collect();
2016 let grand_mean = row_means.iter().sum::<f64>() * inverse_k;
2017 Array2::from_shape_fn((k, k), |(row, column)| {
2018 covariance[[row, column]] - row_means[row] - column_means[column] + grand_mean
2019 })
2020}
2021
2022fn remove_covariance_roundoff(
2023 covariance: Array2<f64>,
2024 integration_error: f64,
2025) -> Result<Array2<f64>, EstimationError> {
2026 let symmetric = (&covariance + &covariance.t().to_owned()) * 0.5;
2027 let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
2028 EstimationError::InvalidInput(format!(
2029 "multinomial probability covariance eigendecomposition failed: {error}"
2030 ))
2031 })?;
2032 let scale = eigenvalues
2033 .iter()
2034 .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
2035 let allowed_negative =
2036 integration_error + covariance_roundoff_tolerance(scale, covariance.nrows());
2037 let minimum = eigenvalues
2038 .iter()
2039 .fold(f64::INFINITY, |value, &candidate| value.min(candidate));
2040 if minimum < -allowed_negative {
2041 let negative_limit = -allowed_negative;
2042 return Err(EstimationError::InvalidInput(format!(
2043 "multinomial posterior probability covariance is indefinite beyond the integration error: min eigenvalue {minimum:.6e}, allowed {negative_limit:.6e}"
2044 )));
2045 }
2046 let mut scaled_eigenvectors = eigenvectors.clone();
2047 for (column, &eigenvalue) in eigenvalues.iter().enumerate() {
2048 let scale = eigenvalue.max(0.0);
2049 scaled_eigenvectors
2050 .column_mut(column)
2051 .mapv_inplace(|value| value * scale);
2052 }
2053 let reconstructed = scaled_eigenvectors.dot(&eigenvectors.t());
2054 Ok((&reconstructed + &reconstructed.t().to_owned()) * 0.5)
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059 use super::*;
2060
2061 fn control(absolute_tolerance: f64) -> MultinomialPosteriorIntegrationControl {
2062 MultinomialPosteriorIntegrationControl {
2063 absolute_tolerance,
2064 relative_tolerance: absolute_tolerance,
2065 minimum_sparse_level: 2,
2066 maximum_sparse_level: 8,
2067 maximum_function_evaluations: 2_000_000,
2068 }
2069 }
2070
2071 fn assert_close(actual: f64, expected: f64, tolerance: f64, label: &str) {
2072 assert!(
2073 (actual - expected).abs() <= tolerance,
2074 "{label}: actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.3e}"
2075 );
2076 }
2077
2078 #[test]
2088 fn a_refusal_names_the_moment_that_decided_it_2612() {
2089 let active_mean = Array1::from_vec(vec![0.7, -1.3, 2.1]);
2090 let active_covariance =
2091 Array2::from_shape_vec((3, 3), vec![2.5, 0.4, -0.3, 0.4, 3.1, 0.6, -0.3, 0.6, 2.2])
2092 .expect("covariance shape");
2093 let control = MultinomialPosteriorIntegrationControl {
2096 absolute_tolerance: 1.0e-300,
2097 relative_tolerance: 1.0e-300,
2098 minimum_sparse_level: 2,
2099 maximum_sparse_level: 2,
2100 maximum_function_evaluations: 2_000_000,
2101 };
2102 let error = integrate_logistic_normal_softmax_moments(
2103 active_mean.view(),
2104 active_covariance.view(),
2105 &control,
2106 )
2107 .expect_err("a 1e-300 tolerance at level 2 cannot certify");
2108 let message = error.to_string();
2109 assert!(
2110 message.contains("worst raw moment"),
2111 "the refusal must identify the deciding coordinate: {message}"
2112 );
2113 assert!(
2114 message.contains("/ tolerance"),
2115 "the refusal must show the normalized error's denominator, so the ratio \
2116 can be checked rather than trusted: {message}"
2117 );
2118 }
2119
2120 #[test]
2130 fn a_zero_absolute_tolerance_is_admissible_so_the_zero_denominator_is_reachable_2612() {
2131 let control = MultinomialPosteriorIntegrationControl {
2132 absolute_tolerance: 0.0,
2133 relative_tolerance: 1.0e-8,
2134 minimum_sparse_level: 2,
2135 maximum_sparse_level: 4,
2136 maximum_function_evaluations: 2_000_000,
2137 };
2138 control
2139 .validate()
2140 .expect("a zero absolute tolerance with a positive relative one is admissible");
2141 }
2142
2143 #[test]
2144 fn binary_reduction_matches_controlled_logistic_normal_identity() {
2145 let active_mean = Array1::from_vec(vec![1.1]);
2146 let active_covariance = Array2::from_shape_vec((1, 1), vec![0.64]).unwrap();
2147 let result = integrate_logistic_normal_softmax_moments(
2148 active_mean.view(),
2149 active_covariance.view(),
2150 &control(1.0e-10),
2151 )
2152 .expect("binary posterior moments");
2153 let (expected_mean, expected_slope) =
2154 gam_solve::quadrature::logit_posterior_meanwith_deriv(1.1, 0.8).unwrap();
2155 let expected_variance = expected_mean - expected_slope - expected_mean * expected_mean;
2156
2157 assert_close(result.class_mean[0], expected_mean, 2.0e-14, "binary mean");
2158 assert_close(
2159 result.class_mean[1],
2160 1.0 - expected_mean,
2161 2.0e-14,
2162 "reference mean",
2163 );
2164 assert_close(
2165 result.class_covariance[[0, 0]],
2166 expected_variance,
2167 2.0e-14,
2168 "binary variance",
2169 );
2170 assert_close(
2171 result.class_covariance[[0, 1]],
2172 -expected_variance,
2173 2.0e-14,
2174 "binary covariance",
2175 );
2176 assert_eq!(result.latent_rank, 1);
2177 assert_eq!(result.rule, MultinomialPosteriorRule::Exact);
2178 }
2179
2180 #[test]
2181 fn zero_covariance_is_exact_softmax_point_mass() {
2182 let active_mean = Array1::from_vec(vec![0.7, -0.4]);
2183 let active_covariance = Array2::<f64>::zeros((2, 2));
2184 let result = integrate_logistic_normal_softmax_moments(
2185 active_mean.view(),
2186 active_covariance.view(),
2187 &control(1.0e-10),
2188 )
2189 .expect("point-mass posterior moments");
2190 let expected = softmax_with_reference(active_mean.as_slice().unwrap()).unwrap();
2191 for class in 0..3 {
2192 assert_close(
2193 result.class_mean[class],
2194 expected[class],
2195 1.0e-15,
2196 "point mean",
2197 );
2198 assert_eq!(result.class_standard_deviation[class], 0.0);
2199 for other in 0..3 {
2200 assert_eq!(result.class_covariance[[class, other]], 0.0);
2201 }
2202 }
2203 assert_eq!(result.latent_rank, 0);
2204 assert_eq!(result.rule, MultinomialPosteriorRule::Exact);
2205 }
2206
2207 #[test]
2208 fn exchangeable_full_logits_require_cross_covariance_and_integrate_to_uniform() {
2209 let variance = 0.7;
2214 let active_mean = Array1::zeros(2);
2215 let active_covariance = Array2::from_shape_vec(
2216 (2, 2),
2217 vec![2.0 * variance, variance, variance, 2.0 * variance],
2218 )
2219 .unwrap();
2220 let result = integrate_logistic_normal_softmax_moments(
2221 active_mean.view(),
2222 active_covariance.view(),
2223 &control(2.0e-7),
2224 )
2225 .expect("exchangeable posterior moments");
2226
2227 for class in 0..3 {
2228 assert_close(result.class_mean[class], 1.0 / 3.0, 8.0e-7, "uniform mean");
2229 }
2230 for class in 1..3 {
2231 assert_close(
2232 result.class_covariance[[class, class]],
2233 result.class_covariance[[0, 0]],
2234 2.0e-6,
2235 "exchangeable variance",
2236 );
2237 }
2238 for row in 0..3 {
2239 assert_close(
2240 result.class_covariance.row(row).sum(),
2241 0.0,
2242 2.0e-12,
2243 "simplex covariance row sum",
2244 );
2245 }
2246 assert_eq!(result.latent_rank, 2);
2247 assert_ne!(result.rule, MultinomialPosteriorRule::Exact);
2248 }
2249
2250 #[test]
2251 fn rank_one_general_case_matches_independent_one_dimensional_gh_oracle() {
2252 let active_mean = Array1::from_vec(vec![0.45, -0.7]);
2253 let loading = [0.8_f64, -0.35_f64];
2254 let active_covariance =
2255 Array2::from_shape_fn((2, 2), |(row, column)| loading[row] * loading[column]);
2256 let result = integrate_logistic_normal_softmax_moments(
2257 active_mean.view(),
2258 active_covariance.view(),
2259 &control(5.0e-8),
2260 )
2261 .expect("rank-one posterior moments");
2262 assert_eq!(result.latent_rank, 1);
2263
2264 let oracle_rule = gauss_hermite_rule(21).unwrap(); let mut oracle_mean = [0.0_f64; 3];
2268 let mut oracle_second = [[0.0_f64; 3]; 3];
2269 for (&z, &weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter()) {
2270 let eta = [
2271 active_mean[0] + loading[0] * z,
2272 active_mean[1] + loading[1] * z,
2273 ];
2274 let probability = softmax_with_reference(&eta).unwrap();
2275 for row in 0..3 {
2276 oracle_mean[row] += weight * probability[row];
2277 for column in 0..3 {
2278 oracle_second[row][column] += weight * probability[row] * probability[column];
2279 }
2280 }
2281 }
2282 for row in 0..3 {
2283 assert_close(
2284 result.class_mean[row],
2285 oracle_mean[row],
2286 3.0e-7,
2287 "rank-one mean",
2288 );
2289 for column in 0..3 {
2290 let oracle_covariance =
2291 oracle_second[row][column] - oracle_mean[row] * oracle_mean[column];
2292 assert_close(
2293 result.class_covariance[[row, column]],
2294 oracle_covariance,
2295 8.0e-7,
2296 "rank-one covariance",
2297 );
2298 }
2299 }
2300 let allowed = 5.0e-8
2301 + 5.0e-8
2302 * result
2303 .class_mean
2304 .iter()
2305 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
2306 assert!(
2307 result.max_raw_moment_level_difference + result.covariance_range_projection_bound
2308 <= allowed * 1.01,
2309 "returned result must carry the level-difference certificate"
2310 );
2311 }
2312
2313 #[test]
2314 fn insufficient_sparse_level_is_a_typed_error_not_a_plugin_result() {
2315 let active_mean = Array1::from_vec(vec![1.2, -0.8]);
2316 let active_covariance = Array2::from_shape_vec((2, 2), vec![2.0, 0.9, 0.9, 1.5]).unwrap();
2317 let strict_control = MultinomialPosteriorIntegrationControl {
2318 absolute_tolerance: 1.0e-14,
2319 relative_tolerance: 1.0e-14,
2320 minimum_sparse_level: 1,
2321 maximum_sparse_level: 1,
2322 maximum_function_evaluations: 100_000,
2323 };
2324 let symmetric = symmetrized_covariance(active_covariance.view());
2329 let projected =
2330 project_active_covariance(symmetric.view(), strict_control.absolute_tolerance)
2331 .expect("project the active covariance");
2332 let error = integrate_isotropic_sparse(
2333 active_mean.as_slice().expect("contiguous active mean"),
2334 &projected,
2335 &strict_control,
2336 0,
2337 )
2338 .expect_err("one sparse refinement cannot certify this nonlinear integral");
2339 assert!(
2340 error.to_string().contains("did not converge"),
2341 "unexpected error: {error}"
2342 );
2343 }
2344
2345 #[test]
2346 fn materially_indefinite_active_covariance_is_rejected() {
2347 let active_mean = Array1::from_vec(vec![0.0, 0.0]);
2348 let active_covariance = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 2.0, 1.0]).unwrap();
2349 let error = integrate_logistic_normal_softmax_moments(
2350 active_mean.view(),
2351 active_covariance.view(),
2352 &control(1.0e-7),
2353 )
2354 .expect_err("indefinite covariance must fail");
2355 assert!(error.to_string().contains("not positive semidefinite"));
2356 }
2357
2358 fn wide_two_direction_covariance() -> Array2<f64> {
2363 let eigenvalues = [1.0_f64, 25.0_f64];
2364 let (sine, cosine) = 0.6_f64.sin_cos();
2365 let basis = [[cosine, -sine], [sine, cosine]];
2366 Array2::from_shape_fn((2, 2), |(row, column)| {
2367 (0..2)
2368 .map(|index| basis[row][index] * eigenvalues[index] * basis[column][index])
2369 .sum()
2370 })
2371 }
2372
2373 #[test]
2401 fn a_wide_two_direction_posterior_is_reduced_before_quadrature_2612() {
2402 let active_mean = Array1::from_vec(vec![2.0, -1.0]);
2403 let active_covariance = wide_two_direction_covariance();
2404 let control = MultinomialPosteriorIntegrationControl::default();
2405
2406 let symmetric = symmetrized_covariance(active_covariance.view());
2408 let projected = project_active_covariance(symmetric.view(), control.absolute_tolerance)
2409 .expect("project the active covariance");
2410 assert_eq!(projected.factor.ncols(), 2, "this row must retain rank two");
2411 let sparse_error = integrate_isotropic_sparse(
2412 active_mean.as_slice().expect("contiguous active mean"),
2413 &projected,
2414 &control,
2415 0,
2416 )
2417 .expect_err("the isotropic sparse rule must not certify this wide posterior");
2418 assert!(
2419 sparse_error.to_string().contains("did not converge"),
2420 "unexpected sparse-rule error: {sparse_error}"
2421 );
2422
2423 let result = integrate_logistic_normal_softmax_moments(
2426 active_mean.view(),
2427 active_covariance.view(),
2428 &control,
2429 )
2430 .expect("the wide posterior must be integrable");
2431 assert!(matches!(
2432 result.rule,
2433 MultinomialPosteriorRule::ConditionedThreeClass(_)
2434 ));
2435
2436 let oracle_rule = gauss_hermite_rule(151).expect("oracle rule");
2442 let mut oracle_mean = vec![0.0_f64; 3];
2443 let mut oracle_second = [[0.0_f64; 3]; 3];
2444 let mut mass = 0.0_f64;
2445 for (&first_node, &first_weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter())
2446 {
2447 for (&second_node, &second_weight) in
2448 oracle_rule.nodes.iter().zip(oracle_rule.weights.iter())
2449 {
2450 let weight = first_weight * second_weight;
2451 mass += weight;
2452 let eta = [
2453 active_mean[0]
2454 + projected.factor[[0, 0]] * first_node
2455 + projected.factor[[0, 1]] * second_node,
2456 active_mean[1]
2457 + projected.factor[[1, 0]] * first_node
2458 + projected.factor[[1, 1]] * second_node,
2459 ];
2460 let probability = softmax_with_reference(&eta).expect("oracle softmax");
2461 for class in 0..3 {
2462 oracle_mean[class] += weight * probability[class];
2463 for other in 0..3 {
2464 oracle_second[class][other] +=
2465 weight * probability[class] * probability[other];
2466 }
2467 }
2468 }
2469 }
2470 for value in &mut oracle_mean {
2471 *value /= mass;
2472 }
2473 for row in &mut oracle_second {
2474 for value in row {
2475 *value /= mass;
2476 }
2477 }
2478 for class in 0..3 {
2479 assert_close(
2480 result.class_mean[class],
2481 oracle_mean[class],
2482 1.0e-7,
2483 "wide posterior class mean",
2484 );
2485 for other in 0..3 {
2486 let oracle_covariance =
2487 oracle_second[class][other] - oracle_mean[class] * oracle_mean[other];
2488 assert_close(
2489 result.class_covariance[[class, other]],
2490 oracle_covariance,
2491 3.0e-7,
2492 "wide posterior class covariance",
2493 );
2494 }
2495 }
2496 }
2497
2498 #[test]
2502 fn conditioned_three_class_rule_ladder_is_reused_across_rows_2612() {
2503 let active_mean = Array1::from_vec(vec![1.1, -0.6]);
2504 let active_covariance =
2505 Array2::from_shape_vec((2, 2), vec![3.0, 0.7, 0.7, 1.8]).expect("covariance");
2506 let control = control(2.0e-9);
2507 let symmetric = symmetrized_covariance(active_covariance.view());
2508 let projected = project_active_covariance(symmetric.view(), control.absolute_tolerance)
2509 .expect("project covariance");
2510 let mut rules = ConditionedThreeClassRuleLadder::default();
2511
2512 let first = integrate_three_class_conditionally(
2513 active_mean.as_slice().expect("contiguous mean"),
2514 &projected,
2515 &control,
2516 &mut rules,
2517 )
2518 .expect("first prediction row");
2519 let constructions_after_first_row = rules.rules.len();
2520 assert!(
2521 constructions_after_first_row >= 2,
2522 "certification must compare at least two rules"
2523 );
2524
2525 let second = integrate_three_class_conditionally(
2526 active_mean.as_slice().expect("contiguous mean"),
2527 &projected,
2528 &control,
2529 &mut rules,
2530 )
2531 .expect("identical second prediction row");
2532 assert_eq!(
2533 rules.rules.len(),
2534 constructions_after_first_row,
2535 "an identical row must reuse every constructed rule"
2536 );
2537 assert_eq!(first.rule, second.rule);
2538 assert_eq!(first.function_evaluations, second.function_evaluations);
2539 assert_eq!(
2540 first.max_raw_moment_level_difference.to_bits(),
2541 second.max_raw_moment_level_difference.to_bits()
2542 );
2543 for (&left, &right) in first.class_mean.iter().zip(second.class_mean.iter()) {
2544 assert_eq!(left.to_bits(), right.to_bits());
2545 }
2546 for (&left, &right) in first
2547 .class_covariance
2548 .iter()
2549 .zip(second.class_covariance.iter())
2550 {
2551 assert_eq!(left.to_bits(), right.to_bits());
2552 }
2553 }
2554
2555 #[test]
2560 fn conditioned_three_class_moments_are_invariant_to_class_permutation_2612() {
2561 let active_mean = Array1::from_vec(vec![1.4, -0.9]);
2562 let active_covariance =
2563 Array2::from_shape_vec((2, 2), vec![7.0, -1.3, -1.3, 2.5]).expect("covariance");
2564 let control = control(2.0e-9);
2565 let original = integrate_logistic_normal_softmax_moments(
2566 active_mean.view(),
2567 active_covariance.view(),
2568 &control,
2569 )
2570 .expect("original conditioned moments");
2571 assert!(matches!(
2572 &original.rule,
2573 MultinomialPosteriorRule::ConditionedThreeClass(_)
2574 ));
2575
2576 let permutation = [2usize, 1usize, 0usize];
2580 let old_full_mean = [active_mean[0], active_mean[1], 0.0];
2581 let new_active_mean = Array1::from_vec(vec![
2582 old_full_mean[permutation[0]] - old_full_mean[permutation[2]],
2583 old_full_mean[permutation[1]] - old_full_mean[permutation[2]],
2584 ]);
2585 let old_active_coefficient = |class: usize| match class {
2586 0 => [1.0, 0.0],
2587 1 => [0.0, 1.0],
2588 2 => [0.0, 0.0],
2589 _ => unreachable!("three classes"),
2590 };
2591 let reference_coefficient = old_active_coefficient(permutation[2]);
2592 let transformation = [
2593 {
2594 let class = old_active_coefficient(permutation[0]);
2595 [
2596 class[0] - reference_coefficient[0],
2597 class[1] - reference_coefficient[1],
2598 ]
2599 },
2600 {
2601 let class = old_active_coefficient(permutation[1]);
2602 [
2603 class[0] - reference_coefficient[0],
2604 class[1] - reference_coefficient[1],
2605 ]
2606 },
2607 ];
2608 let new_active_covariance = Array2::from_shape_fn((2, 2), |(row, column)| {
2609 let mut value = 0.0;
2610 for left in 0..2 {
2611 for right in 0..2 {
2612 value += transformation[row][left]
2613 * active_covariance[[left, right]]
2614 * transformation[column][right];
2615 }
2616 }
2617 value
2618 });
2619 let permuted = integrate_logistic_normal_softmax_moments(
2620 new_active_mean.view(),
2621 new_active_covariance.view(),
2622 &control,
2623 )
2624 .expect("permuted conditioned moments");
2625
2626 for new_class in 0..3 {
2627 let old_class = permutation[new_class];
2628 assert_close(
2629 permuted.class_mean[new_class],
2630 original.class_mean[old_class],
2631 2.0e-8,
2632 "permuted class mean",
2633 );
2634 for new_other in 0..3 {
2635 let old_other = permutation[new_other];
2636 assert_close(
2637 permuted.class_covariance[[new_class, new_other]],
2638 original.class_covariance[[old_class, old_other]],
2639 5.0e-8,
2640 "permuted class covariance",
2641 );
2642 }
2643 }
2644 for row in 0..3 {
2645 assert_close(
2646 permuted.class_covariance.row(row).sum(),
2647 0.0,
2648 3.0e-14,
2649 "conditioned simplex covariance row sum",
2650 );
2651 }
2652 }
2653
2654 #[test]
2660 fn conditioned_three_class_rule_fits_below_the_corresponding_tensor_cost_2612() {
2661 let active_mean = Array1::from_vec(vec![2.0, -1.0]);
2662 let active_covariance = wide_two_direction_covariance();
2663 let baseline = integrate_logistic_normal_softmax_moments(
2664 active_mean.view(),
2665 active_covariance.view(),
2666 &MultinomialPosteriorIntegrationControl::default(),
2667 )
2668 .expect("baseline conditioned moments");
2669 let nodes = match baseline.rule {
2670 MultinomialPosteriorRule::ConditionedThreeClass(nodes) => nodes,
2671 other => panic!("expected conditioned rule, got {other:?}"),
2672 };
2673 assert!(nodes > 1, "wide posterior must require a refinement");
2674 let square_cost = nodes.checked_mul(nodes).expect("tensor cost");
2675 let tight_control = MultinomialPosteriorIntegrationControl {
2676 maximum_function_evaluations: square_cost - 1,
2677 ..MultinomialPosteriorIntegrationControl::default()
2678 };
2679 let conditioned = integrate_logistic_normal_softmax_moments(
2680 active_mean.view(),
2681 active_covariance.view(),
2682 &tight_control,
2683 )
2684 .expect("conditioned rule must fit below corresponding tensor cost");
2685 assert!(
2686 conditioned.function_evaluations < square_cost,
2687 "conditioned work {} must be below {nodes}²={square_cost}",
2688 conditioned.function_evaluations
2689 );
2690 for class in 0..3 {
2691 assert_close(
2692 conditioned.class_mean[class],
2693 baseline.class_mean[class],
2694 3.0e-8,
2695 "tight-budget conditioned mean",
2696 );
2697 }
2698 }
2699}