1use crate::block_layout::block_count::validate_block_count;
64use crate::custom_family::{
65 AdditiveBlockJacobian, BlockEffectiveJacobian, BlockWorkingSet, CustomFamily,
66 ExactNewtonJointGradientEvaluation, ExactNewtonJointHessianWorkspace, FamilyEvaluation,
67 FamilyLinearizationState, JointHessianSourcePreference, ParameterBlockSpec,
68 ParameterBlockState, PenaltyMatrix,
69};
70use crate::vector_response::{
71 MultinomialLogitLikelihood, VectorLikelihood, validate_multinomial_simplex,
72};
73use gam_linalg::faer_ndarray::{fast_ab, fast_atb};
74use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, SymmetricMatrix};
75use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
76use gam_math::nested_dual::JetField;
77use gam_problem::{HyperOperator, PseudoLogdetMode};
78use gam_solve::pirls::dense_block_xtwx;
79use ndarray::{Array1, Array2, Array3, ArrayView2};
80use rayon::prelude::*;
81use std::sync::{Arc, Mutex};
82
83#[inline]
84fn multinomial_stable_shift(eta: &[f64]) -> f64 {
85 eta.iter().copied().fold(0.0_f64, f64::max)
86}
87
88#[inline(always)]
95pub(crate) fn multinomial_logit_probabilities_into(
96 eta: &[f64],
97 probabilities: &mut [f64],
98) -> (f64, f64) {
99 assert_eq!(probabilities.len(), eta.len() + 1);
100 let shift = multinomial_stable_shift(eta);
101 let active_classes = eta.len();
102 let reference_mass = (-shift).exp();
103 let mut denominator = reference_mass;
104 for (axis, &logit) in eta.iter().enumerate() {
105 let mass = (logit - shift).exp();
106 probabilities[axis] = mass;
107 denominator += mass;
108 }
109 let inverse_denominator = denominator.recip();
110 for probability in &mut probabilities[..active_classes] {
111 *probability *= inverse_denominator;
112 }
113 probabilities[active_classes] = reference_mass * inverse_denominator;
114 (shift, denominator.ln())
115}
116
117#[derive(Clone, Copy, Debug)]
126pub struct MultinomialLogitRowProgram<'row> {
127 eta: &'row [f64],
128 response: &'row [f64],
129 weight: f64,
130}
131
132impl<'row> MultinomialLogitRowProgram<'row> {
133 pub fn new(eta: &'row [f64], response: &'row [f64], weight: f64) -> Result<Self, String> {
137 let active_classes = eta.len();
138 if active_classes == 0 {
139 return Err("MultinomialLogitRowProgram requires at least one active class".into());
140 }
141 if response.len() != active_classes + 1 {
142 return Err(format!(
143 "MultinomialLogitRowProgram response length {} must equal active classes + reference = {}",
144 response.len(),
145 active_classes + 1,
146 ));
147 }
148 if !weight.is_finite() || weight < 0.0 {
149 return Err(format!(
150 "MultinomialLogitRowProgram weight must be finite and non-negative, got {weight}"
151 ));
152 }
153 if let Some((axis, value)) = eta
154 .iter()
155 .copied()
156 .enumerate()
157 .find(|(_, value)| !value.is_finite())
158 {
159 return Err(format!(
160 "MultinomialLogitRowProgram eta[{axis}] must be finite, got {value}"
161 ));
162 }
163 if let Some((class, value)) = response
164 .iter()
165 .copied()
166 .enumerate()
167 .find(|(_, value)| !value.is_finite() || *value < 0.0)
168 {
169 return Err(format!(
170 "MultinomialLogitRowProgram response[{class}] must be finite and non-negative, got {value}"
171 ));
172 }
173 let response_mass: f64 = response.iter().sum();
174 let simplex_tolerance = 1.0e-10 * (1.0 + response.len() as f64);
175 if (response_mass - 1.0).abs() > simplex_tolerance {
176 return Err(format!(
177 "MultinomialLogitRowProgram response must sum to one, got {response_mass}"
178 ));
179 }
180 Ok(Self {
181 eta,
182 response,
183 weight,
184 })
185 }
186
187 fn require_row(row: usize) -> Result<(), String> {
188 if row != 0 {
189 return Err(format!(
190 "MultinomialLogitRowProgram holds exactly one row; got row {row}"
191 ));
192 }
193 Ok(())
194 }
195
196 #[inline]
200 fn stable_shift(&self) -> f64 {
201 multinomial_stable_shift(self.eta)
202 }
203
204 fn eval_expression<S: JetField>(&self, primaries: &[S], constant: impl Fn(f64) -> S) -> S {
215 assert_eq!(primaries.len(), self.eta.len());
216 if self.weight == 0.0 {
217 return constant(0.0);
218 }
219 let shift = self.stable_shift();
220 let mut denominator = constant((-shift).exp());
221 let mut centered_response = constant(0.0);
222 for (axis, primary) in primaries.iter().enumerate() {
223 let centered = primary.add(&constant(-shift));
224 let exponential_value = centered.value().exp();
225 let exponential = centered.compose_unary([
226 exponential_value,
227 exponential_value,
228 exponential_value,
229 exponential_value,
230 exponential_value,
231 ]);
232 denominator = denominator.add(&exponential);
233 let response = self.response[axis];
234 if response != 0.0 {
235 centered_response = centered_response.add(¢ered.scale(response));
236 }
237 }
238 let denominator_value = denominator.value();
239 let reciprocal = 1.0 / denominator_value;
240 let log_denominator = denominator.compose_unary([
241 denominator_value.ln(),
242 reciprocal,
243 -reciprocal * reciprocal,
244 2.0 * reciprocal * reciprocal * reciprocal,
245 -6.0 * reciprocal * reciprocal * reciprocal * reciprocal,
246 ]);
247 let reference_response = self.response[self.eta.len()];
248 let nll = log_denominator.sub(¢ered_response);
249 let nll = if reference_response == 0.0 {
250 nll
251 } else {
252 nll.add(&constant(reference_response * shift))
253 };
254 nll.scale(self.weight)
255 }
256
257 #[inline]
259 pub(crate) fn negative_log_likelihood(&self) -> f64 {
260 self.eval_expression(self.eta, |value| value)
261 }
262
263 #[inline(always)]
268 pub(crate) fn probabilities_into(&self, probabilities: &mut [f64]) -> (f64, f64) {
269 assert_eq!(probabilities.len(), self.response.len());
270 multinomial_logit_probabilities_into(self.eta, probabilities)
271 }
272
273 #[inline]
276 fn negative_log_likelihood_from_normalization(
277 &self,
278 shift: f64,
279 log_centered_denominator: f64,
280 ) -> f64 {
281 if self.weight == 0.0 {
282 return 0.0;
283 }
284 let mut centered_response = 0.0_f64;
285 for (axis, &response) in self.response[..self.eta.len()].iter().enumerate() {
286 if response != 0.0 {
287 centered_response += response * (self.eta[axis] - shift);
288 }
289 }
290 let reference_response = self.response[self.eta.len()];
291 let reference_term = if reference_response == 0.0 {
292 0.0
293 } else {
294 reference_response * shift
295 };
296 self.weight * (log_centered_denominator - centered_response + reference_term)
297 }
298
299 #[inline(always)]
304 pub(crate) fn value_gradient_into(
305 &self,
306 probabilities: &mut [f64],
307 gradient: &mut [f64],
308 ) -> f64 {
309 let active_classes = self.eta.len();
310 assert_eq!(gradient.len(), active_classes);
311 let (shift, log_centered_denominator) = self.probabilities_into(probabilities);
312 for axis in 0..active_classes {
313 gradient[axis] = self.weight * (probabilities[axis] - self.response[axis]);
314 }
315 self.negative_log_likelihood_from_normalization(shift, log_centered_denominator)
316 }
317
318 pub(crate) fn hessian_diagonal_into(&self, probabilities: &mut [f64], diagonal: &mut [f64]) {
321 let active_classes = self.eta.len();
322 assert_eq!(diagonal.len(), active_classes);
323 self.probabilities_into(probabilities);
324 for axis in 0..active_classes {
325 let probability = probabilities[axis];
326 diagonal[axis] = self.weight * probability * (1.0 - probability);
327 }
328 }
329
330 pub(crate) fn value_gradient_hessian_into(
343 &self,
344 probabilities: &mut [f64],
345 gradient: &mut [f64],
346 hessian: &mut [f64],
347 ) -> f64 {
348 match self.eta.len() {
349 1 => self.value_gradient_hessian_shaped::<1>(probabilities, gradient, hessian),
350 2 => self.value_gradient_hessian_shaped::<2>(probabilities, gradient, hessian),
351 3 => self.value_gradient_hessian_shaped::<3>(probabilities, gradient, hessian),
352 4 => self.value_gradient_hessian_shaped::<4>(probabilities, gradient, hessian),
353 _ => self.value_gradient_hessian_shaped::<0>(probabilities, gradient, hessian),
354 }
355 }
356
357 #[inline(always)]
362 fn value_gradient_hessian_shaped<const M_HINT: usize>(
363 &self,
364 probabilities: &mut [f64],
365 gradient: &mut [f64],
366 hessian: &mut [f64],
367 ) -> f64 {
368 let active_classes = if M_HINT == 0 {
369 self.eta.len()
370 } else {
371 assert_eq!(self.eta.len(), M_HINT);
372 M_HINT
373 };
374 assert_eq!(gradient.len(), active_classes);
375 assert_eq!(hessian.len(), active_classes * active_classes);
376 let value = self.value_gradient_into(probabilities, gradient);
377 for row in 0..active_classes {
378 let probability_row = probabilities[row];
379 for column in 0..active_classes {
380 let probability_column = probabilities[column];
381 hessian[row * active_classes + column] = self.weight
382 * if row == column {
383 probability_row * (1.0 - probability_column)
384 } else {
385 -probability_row * probability_column
386 };
387 }
388 }
389 value
390 }
391}
392
393impl<const M: usize> gam_math::jet_tower::RowProgram<M> for MultinomialLogitRowProgram<'_> {
394 fn n_rows(&self) -> usize {
395 1
396 }
397
398 fn primaries(&self, row: usize) -> Result<[f64; M], String> {
399 Self::require_row(row)?;
400 self.eta.try_into().map_err(|_| {
401 format!(
402 "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
403 self.eta.len()
404 )
405 })
406 }
407
408 fn eval<S: JetScalar<M>>(&self, row: usize, p: &[S; M]) -> Result<S, String> {
409 Self::require_row(row)?;
410 if self.eta.len() != M {
411 return Err(format!(
412 "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
413 self.eta.len()
414 ));
415 }
416 Ok(self.eval_expression(p, S::constant))
417 }
418}
419
420#[derive(Clone, Copy)]
432struct FisherDirection {
433 u: f64,
434 v: f64,
435}
436
437#[derive(Clone, Copy)]
449struct PerturbedMass<S> {
450 probability: f64,
451 direction_u: f64,
452 weight: f64,
453 mass: S,
454}
455
456trait FisherPerturbation: JetScalar<0> {
457 type Channels: Copy;
458 const CONTIGUOUS_FULL: bool;
459 const WEIGHT_IN_CHANNELS: bool;
465
466 fn seed(direction: FisherDirection) -> Self;
467 fn coefficient(&self) -> f64;
468 fn from_channels(base: f64, channels: Self::Channels) -> Self;
469 fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels;
473 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
474 where
475 F: Fn(usize) -> PerturbedMass<Self>;
476}
477
478impl FisherPerturbation for OneSeed<0> {
479 type Channels = f64;
480 const CONTIGUOUS_FULL: bool = true;
481 const WEIGHT_IN_CHANNELS: bool = true;
482
483 #[inline(always)]
484 fn seed(direction: FisherDirection) -> Self {
485 Self {
486 base: <Order2<0> as JetScalar<0>>::constant(0.0),
487 eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
488 }
489 }
490
491 #[inline(always)]
492 fn coefficient(&self) -> f64 {
493 gam_math::nested_dual::JetField::value(&self.eps)
494 }
495
496 #[inline(always)]
497 fn from_channels(base: f64, channels: Self::Channels) -> Self {
498 Self {
499 base: <Order2<0> as JetScalar<0>>::constant(base),
500 eps: <Order2<0> as JetScalar<0>>::constant(channels),
501 }
502 }
503
504 #[inline(always)]
505 fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels {
506 perturbed.probability
510 * (perturbed.direction_u + gam_math::nested_dual::JetField::value(&inverse.eps))
511 * perturbed.weight
512 }
513
514 #[inline(always)]
515 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
516 where
517 F: Fn(usize) -> PerturbedMass<Self>,
518 {
519 let mut eps_coefficient = 0.0;
520 for a in 0..m {
521 eps_coefficient += gam_math::nested_dual::JetField::value(&perturbed_mass(a).mass.eps);
522 }
523 Self {
524 base: <Order2<0> as JetScalar<0>>::constant(1.0),
525 eps: <Order2<0> as JetScalar<0>>::constant(eps_coefficient),
526 }
527 }
528}
529
530impl FisherPerturbation for TwoSeed<0> {
531 type Channels = [f64; 3];
532 const CONTIGUOUS_FULL: bool = false;
533 const WEIGHT_IN_CHANNELS: bool = false;
534
535 #[inline(always)]
536 fn seed(direction: FisherDirection) -> Self {
537 Self {
538 base: <Order2<0> as JetScalar<0>>::constant(0.0),
539 eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
540 del: <Order2<0> as JetScalar<0>>::constant(direction.v),
541 eps_del: <Order2<0> as JetScalar<0>>::constant(0.0),
542 }
543 }
544
545 #[inline(always)]
546 fn coefficient(&self) -> f64 {
547 gam_math::nested_dual::JetField::value(&self.eps_del)
548 }
549
550 #[inline(always)]
551 fn from_channels(base: f64, channels: Self::Channels) -> Self {
552 Self {
553 base: <Order2<0> as JetScalar<0>>::constant(base),
554 eps: <Order2<0> as JetScalar<0>>::constant(channels[0]),
555 del: <Order2<0> as JetScalar<0>>::constant(channels[1]),
556 eps_del: <Order2<0> as JetScalar<0>>::constant(channels[2]),
557 }
558 }
559
560 #[inline(always)]
561 fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels {
562 let normalized = gam_math::nested_dual::JetField::mul(&perturbed.mass, inverse);
566 [
567 gam_math::nested_dual::JetField::value(&normalized.eps),
568 gam_math::nested_dual::JetField::value(&normalized.del),
569 gam_math::nested_dual::JetField::value(&normalized.eps_del),
570 ]
571 }
572
573 #[inline(always)]
574 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
575 where
576 F: Fn(usize) -> PerturbedMass<Self>,
577 {
578 let mut denominator = Self::constant(1.0);
579 for a in 0..m {
580 let perturbed = perturbed_mass(a);
581 denominator = gam_math::nested_dual::JetField::add(
582 &denominator,
583 &gam_math::nested_dual::JetField::sub(
584 &perturbed.mass,
585 &Self::constant(perturbed.probability),
586 ),
587 );
588 }
589 denominator
590 }
591}
592
593#[inline(always)]
594fn fisher_entry<S: FisherPerturbation>(
595 probability_a: S,
596 probability_b: S,
597 diagonal: bool,
598 output_weight: f64,
599) -> f64 {
600 let negative_product = gam_math::nested_dual::JetField::neg(
601 &gam_math::nested_dual::JetField::mul(&probability_a, &probability_b),
602 );
603 let entry = if diagonal {
604 gam_math::nested_dual::JetField::add(&probability_a, &negative_product)
605 } else {
606 negative_product
607 };
608 gam_math::nested_dual::JetField::scale(&entry, output_weight).coefficient()
609}
610
611#[inline(always)]
612fn write_static_fisher<S: FisherPerturbation, F: Fn(usize) -> f64, const M: usize>(
613 probability: &F,
614 normalized: &[S::Channels],
615 fisher: &mut [f64],
616 output_weight: f64,
617) {
618 for a in 0..M {
619 let pa = S::from_channels(probability(a), normalized[a]);
620 fisher[a * M + a] = fisher_entry(pa, pa, true, output_weight);
621 for b in (a + 1)..M {
622 let pb = S::from_channels(probability(b), normalized[b]);
623 let coefficient = fisher_entry(pa, pb, false, output_weight);
624 fisher[a * M + b] = coefficient;
625 fisher[b * M + a] = coefficient;
626 }
627 }
628}
629
630#[derive(Clone, Copy, Eq, PartialEq)]
631enum FisherOutputSchedule {
632 SymmetricTriangle,
633 ContiguousFull,
634}
635
636const AVX2_WITHOUT_AVX512: bool = cfg!(all(target_arch = "x86_64", target_feature = "avx2"))
637 && !cfg!(all(target_arch = "x86_64", target_feature = "avx512f"));
638
639#[inline(always)]
646fn fisher_output_schedule<S: FisherPerturbation>(m: usize) -> FisherOutputSchedule {
647 if S::CONTIGUOUS_FULL && (m >= 64 || (m == 32 && AVX2_WITHOUT_AVX512)) {
648 FisherOutputSchedule::ContiguousFull
649 } else {
650 FisherOutputSchedule::SymmetricTriangle
651 }
652}
653
654#[inline(always)]
674fn softmax_fisher_perturbation<S: FisherPerturbation>(
675 m: usize,
676 weight: f64,
677 probability: impl Fn(usize) -> f64,
678 direction_u: impl Fn(usize) -> f64,
679 direction_v: impl Fn(usize) -> f64,
680 normalized: &mut [S::Channels],
681 fisher: &mut [f64],
682) {
683 assert_eq!(normalized.len(), m);
684 assert_eq!(fisher.len(), m * m);
685 let (channel_weight, output_weight) = if S::WEIGHT_IN_CHANNELS {
688 (weight, 1.0)
689 } else {
690 (1.0, weight)
691 };
692 let perturbed_mass = |a| {
693 let pa = probability(a);
694 let direction_u = direction_u(a);
695 let delta = S::seed(FisherDirection {
696 u: direction_u,
697 v: direction_v(a),
698 });
699 let mass = gam_math::nested_dual::JetField::scale(
700 &gam_math::nested_dual::JetField::compose_unary(&delta, [1.0; 5]),
701 pa,
702 );
703 PerturbedMass {
704 probability: pa,
705 direction_u,
706 weight: channel_weight,
707 mass,
708 }
709 };
710 let denominator = S::denominator(m, &perturbed_mass);
711 let inverse =
712 gam_math::nested_dual::JetField::compose_unary(&denominator, [1.0, -1.0, 2.0, -6.0, 24.0]);
713 for (a, channels) in normalized.iter_mut().enumerate() {
714 *channels = S::channels(&perturbed_mass(a), &inverse);
715 }
716 let lifted = |a| S::from_channels(probability(a), normalized[a]);
717 if m == 2 {
718 let p0 = lifted(0);
719 let p1 = lifted(1);
720 fisher[0] = fisher_entry(p0, p0, true, output_weight);
721 let off = fisher_entry(p0, p1, false, output_weight);
722 fisher[1] = off;
723 fisher[2] = off;
724 fisher[3] = fisher_entry(p1, p1, true, output_weight);
725 return;
726 }
727 if m == 3 {
728 let p0 = lifted(0);
729 let p1 = lifted(1);
730 let p2 = lifted(2);
731 fisher[0] = fisher_entry(p0, p0, true, output_weight);
732 let off01 = fisher_entry(p0, p1, false, output_weight);
733 fisher[1] = off01;
734 fisher[3] = off01;
735 let off02 = fisher_entry(p0, p2, false, output_weight);
736 fisher[2] = off02;
737 fisher[6] = off02;
738 fisher[4] = fisher_entry(p1, p1, true, output_weight);
739 let off12 = fisher_entry(p1, p2, false, output_weight);
740 fisher[5] = off12;
741 fisher[7] = off12;
742 fisher[8] = fisher_entry(p2, p2, true, output_weight);
743 return;
744 }
745 if m == 8 {
746 write_static_fisher::<S, _, 8>(&probability, normalized, fisher, output_weight);
747 return;
748 }
749 let output_schedule = fisher_output_schedule::<S>(m);
750 if m == 32 && output_schedule == FisherOutputSchedule::SymmetricTriangle {
751 write_static_fisher::<S, _, 32>(&probability, normalized, fisher, output_weight);
752 return;
753 }
754 if output_schedule == FisherOutputSchedule::ContiguousFull {
755 for a in 0..m {
756 let pa = lifted(a);
757 let row_start = a * m;
758 for b in 0..m {
759 fisher[row_start + b] = fisher_entry(pa, lifted(b), false, output_weight);
760 }
761 fisher[row_start + a] = fisher_entry(pa, pa, true, output_weight);
762 }
763 return;
764 }
765 for a in 0..m {
766 let pa = lifted(a);
767 fisher[a * m + a] = fisher_entry(pa, pa, true, output_weight);
768 for b in (a + 1)..m {
769 let coefficient = fisher_entry(pa, lifted(b), false, output_weight);
770 fisher[a * m + b] = coefficient;
771 fisher[b * m + a] = coefficient;
772 }
773 }
774}
775
776pub(crate) fn measured_penalty_rank(s: &Array2<f64>) -> Result<usize, String> {
785 Ok(s.nrows() - measured_penalty_nullspace(s)?.ncols())
786}
787
788pub(crate) fn measured_penalty_nullspace(s: &Array2<f64>) -> Result<Array2<f64>, String> {
802 Ok(
806 gam_solve::estimate::reml::jeffreys_subspace::jeffreys_subspace_from_penalty(s.view())?
807 .columns,
808 )
809}
810
811pub(crate) fn under_identified_subspace(
828 a: &Array2<f64>,
829 metric: &Array2<f64>,
830) -> Result<Array2<f64>, String> {
831 gam_solve::estimate::reml::jeffreys_subspace::under_identified_subspace_in_metric(
832 a.view(),
833 metric.view(),
834 )
835}
836
837pub(crate) fn centered_class_coefficient_metric(m: usize, k: usize, p: usize) -> Array2<f64> {
849 let class_metric = centered_class_metric(m, k).mapv(|value| value / k as f64);
872 let dim = m * p;
873 let mut metric = Array2::<f64>::zeros((dim, dim));
874 for a in 0..m {
875 for b in 0..m {
876 let value = class_metric[[a, b]];
877 if value == 0.0 {
878 continue;
879 }
880 for i in 0..p {
881 metric[[a * p + i, b * p + i]] = value;
882 }
883 }
884 }
885 metric
886}
887
888pub(crate) fn centered_class_metric(m: usize, k: usize) -> Array2<f64> {
893 let inv_k = 1.0 / k as f64;
894 let mut metric = Array2::<f64>::from_elem((m, m), -inv_k);
895 for a in 0..m {
896 metric[[a, a]] += 1.0;
897 }
898 metric
899}
900
901struct MultinomialClassChannelJacobian {
935 inner: AdditiveBlockJacobian,
936}
937
938impl MultinomialClassChannelJacobian {
939 fn new(inner: AdditiveBlockJacobian) -> Self {
940 Self { inner }
941 }
942}
943
944impl BlockEffectiveJacobian for MultinomialClassChannelJacobian {
945 fn effective_jacobian_rows(
946 &self,
947 state: &FamilyLinearizationState<'_>,
948 rows: std::ops::Range<usize>,
949 ) -> Result<Array2<f64>, String> {
950 self.inner.effective_jacobian_rows(state, rows)
951 }
952
953 fn n_outputs(&self) -> usize {
954 self.inner.n_outputs()
955 }
956
957 fn locks_raw_width_reduction(&self) -> bool {
958 true
959 }
960}
961
962#[derive(Clone, Debug)]
980pub struct MultinomialFamily {
981 pub y_one_hot: Array2<f64>,
988 pub weights: Array1<f64>,
990 pub total_classes: usize,
993 pub design: Arc<Array2<f64>>,
997 pub penalties: Arc<Vec<PenaltyMatrix>>,
1008 likelihood: MultinomialLogitLikelihood,
1011 axis_derivative_cache: Arc<Mutex<Option<AxisDerivativeCache>>>,
1033 joint_jeffreys_term_strength: f64,
1045 initial_log_lambda: f64,
1051 joint_initial_log_lambdas: Option<Vec<f64>>,
1059 joint_jeffreys_span: Option<Arc<Array2<f64>>>,
1074}
1075
1076#[derive(Clone, Debug)]
1080struct AxisDerivativeCache {
1081 eta_key: EtaFingerprint,
1083 derivatives: Vec<Array2<f64>>,
1086}
1087
1088#[derive(Clone, Debug, PartialEq, Eq)]
1093struct EtaFingerprint {
1094 rows: usize,
1095 cols: usize,
1096 hash: u64,
1097}
1098
1099impl EtaFingerprint {
1100 fn of(eta: ArrayView2<'_, f64>) -> Self {
1101 use std::hash::{Hash, Hasher};
1102 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1103 let (rows, cols) = eta.dim();
1104 rows.hash(&mut hasher);
1105 cols.hash(&mut hasher);
1106 for &v in eta.iter() {
1107 v.to_bits().hash(&mut hasher);
1108 }
1109 EtaFingerprint {
1110 rows,
1111 cols,
1112 hash: hasher.finish(),
1113 }
1114 }
1115}
1116
1117impl MultinomialFamily {
1118 pub const fn active_classes(&self) -> usize {
1120 self.total_classes - 1
1121 }
1122
1123 pub fn new(
1128 y_one_hot: Array2<f64>,
1129 weights: Array1<f64>,
1130 total_classes: usize,
1131 design: Arc<Array2<f64>>,
1132 penalties: Arc<Vec<PenaltyMatrix>>,
1133 ) -> Result<Self, String> {
1134 if total_classes < 2 {
1135 return Err(format!(
1136 "MultinomialFamily requires K ≥ 2 classes (got {total_classes})"
1137 ));
1138 }
1139 let (n, k) = y_one_hot.dim();
1140 if k != total_classes {
1141 return Err(format!(
1142 "MultinomialFamily: y_one_hot has {k} columns but total_classes = {total_classes}"
1143 ));
1144 }
1145 if weights.len() != n {
1146 return Err(format!(
1147 "MultinomialFamily: weights length {} != N = {n}",
1148 weights.len()
1149 ));
1150 }
1151 for (i, &v) in weights.iter().enumerate() {
1152 if !(v.is_finite() && v >= 0.0) {
1153 return Err(format!(
1154 "MultinomialFamily: weights[{i}] must be finite and non-negative (got {v})"
1155 ));
1156 }
1157 }
1158 if design.nrows() != n {
1159 return Err(format!(
1160 "MultinomialFamily: design has {} rows, expected {n}",
1161 design.nrows()
1162 ));
1163 }
1164 let p = design.ncols();
1165 for (t, penalty) in penalties.iter().enumerate() {
1166 if penalty.shape() != (p, p) {
1167 return Err(format!(
1168 "MultinomialFamily: penalties[{t}] shape {:?} != (P, P) = ({p}, {p})",
1169 penalty.shape()
1170 ));
1171 }
1172 for ((i, j), &v) in penalty.to_dense().indexed_iter() {
1173 if !v.is_finite() {
1174 return Err(format!(
1175 "MultinomialFamily: penalties[{t}][{i},{j}] must be finite (got {v})"
1176 ));
1177 }
1178 }
1179 }
1180 validate_multinomial_simplex(y_one_hot.view(), "MultinomialFamily")
1181 .map_err(|e| e.to_string())?;
1182 for ((i, j), &v) in design.indexed_iter() {
1183 if !v.is_finite() {
1184 return Err(format!(
1185 "MultinomialFamily: design[{i},{j}] must be finite (got {v})"
1186 ));
1187 }
1188 }
1189
1190 let likelihood = MultinomialLogitLikelihood::with_classes(total_classes)
1193 .map_err(|e| format!("MultinomialFamily: {e}"))?
1194 .with_row_weights(weights.clone())
1195 .map_err(|e| format!("MultinomialFamily: {e}"))?;
1196
1197 Ok(Self {
1198 y_one_hot,
1199 weights,
1200 total_classes,
1201 design,
1202 penalties,
1203 likelihood,
1204 axis_derivative_cache: Arc::new(Mutex::new(None)),
1205 joint_jeffreys_term_strength: 1.0,
1206 initial_log_lambda: 0.0,
1207 joint_initial_log_lambdas: None,
1208 joint_jeffreys_span: None,
1209 })
1210 }
1211
1212 pub fn with_joint_jeffreys_span(mut self, span: Option<Arc<Array2<f64>>>) -> Self {
1218 self.joint_jeffreys_span = span;
1219 self
1220 }
1221
1222 pub fn with_joint_jeffreys_term(mut self, enabled: bool) -> Self {
1225 self.joint_jeffreys_term_strength = f64::from(enabled);
1226 self
1227 }
1228
1229 pub fn with_initial_log_lambda(mut self, log_lambda: f64) -> Self {
1234 self.initial_log_lambda = log_lambda;
1235 self
1236 }
1237
1238 pub fn with_joint_initial_log_lambdas(mut self, seeds: Vec<f64>) -> Self {
1245 self.joint_initial_log_lambdas = Some(seeds);
1246 self
1247 }
1248
1249 fn joint_seed(&self, spec_index: usize) -> f64 {
1252 self.joint_initial_log_lambdas
1253 .as_ref()
1254 .and_then(|seeds| seeds.get(spec_index))
1255 .copied()
1256 .unwrap_or(self.initial_log_lambda)
1257 }
1258
1259 fn validate_joint_seed_len(&self, expected: usize, carrier: &str) -> Result<(), String> {
1262 match self.joint_initial_log_lambdas.as_ref() {
1263 Some(seeds) if seeds.len() != expected => Err(format!(
1264 "multinomial {carrier} carrier: joint_initial_log_lambdas has {} entries, \
1265 expected {expected} (one per joint spec, term-major)",
1266 seeds.len()
1267 )),
1268 _ => Ok(()),
1269 }
1270 }
1271
1272 pub fn build_block_specs(&self) -> Vec<ParameterBlockSpec> {
1289 let m = self.active_classes();
1290 (0..m)
1291 .map(|a| {
1292 let priority = 100u8.saturating_add(u8::try_from(m - a).unwrap_or(u8::MAX));
1293 let mut spec = ParameterBlockSpec {
1320 name: format!("class_{a}"),
1321 design: DesignMatrix::Dense(DenseDesignMatrix::from(self.design.clone())),
1322 offset: Array1::<f64>::zeros(self.design.nrows()),
1323 penalties: Vec::new(),
1324 nullspace_dims: Vec::new(),
1325 initial_log_lambdas: Array1::<f64>::zeros(0),
1326 initial_beta: None,
1327 gauge_priority: priority,
1328 jacobian_callback: None,
1329 stacked_design: None,
1330 stacked_offset: None,
1331 };
1332 spec.jacobian_callback = Some(Arc::new(MultinomialClassChannelJacobian::new(
1333 AdditiveBlockJacobian {
1334 design: (*self.design).clone(),
1335 own_output: a,
1336 n_family_outputs: m,
1337 },
1338 )));
1339 spec
1340 })
1341 .collect()
1342 }
1343
1344 pub fn beta_flat_dim(&self) -> usize {
1346 self.active_classes() * self.design.ncols()
1347 }
1348
1349 fn check_spec_coefficient_width(
1360 &self,
1361 specs: &[ParameterBlockSpec],
1362 what: &str,
1363 ) -> Result<(), String> {
1364 if specs.is_empty() {
1365 return Ok(());
1366 }
1367 let spec_width: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
1368 let flat_dim = self.beta_flat_dim();
1369 if spec_width != flat_dim {
1370 return Err(format!(
1371 "MultinomialFamily {what}: {} block specs carry {spec_width} coefficients but the \
1372 family's flat layout is {} classes x {} columns = {flat_dim}",
1373 specs.len(),
1374 self.active_classes(),
1375 self.design.ncols()
1376 ));
1377 }
1378 Ok(())
1379 }
1380
1381 pub fn centered_joint_penalty_specs(
1401 &self,
1402 ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1403 let m = self.active_classes();
1404 let k = self.total_classes;
1405 let p = self.design.ncols();
1406 let metric = centered_class_metric(m, k);
1407 let raw_total = m * p;
1408 self.validate_joint_seed_len(self.penalties.len(), "shared centered")?;
1409 self.penalties
1410 .iter()
1411 .enumerate()
1412 .map(|(t, pen)| {
1413 let s_t = pen.to_dense();
1414 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1415 for a in 0..m {
1416 for b in 0..m {
1417 let scale = metric[[a, b]];
1418 for i in 0..p {
1419 for j in 0..p {
1420 matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1421 }
1422 }
1423 }
1424 }
1425 let rank_s = measured_penalty_rank(&s_t)
1430 .map_err(|e| format!("multinomial centered penalty term {t}: {e}"))?;
1431 Ok(gam_problem::JointPenaltySpec {
1432 label: Some(format!("multinomial_term_{t}")),
1433 matrix,
1434 initial_log_lambda: self.joint_seed(t),
1435 nullspace_dim: raw_total - m * rank_s,
1436 group: Some(t),
1439 })
1440 })
1441 .collect()
1442 }
1443
1444 pub fn joint_smoothing_dimension(&self) -> usize {
1490 if self.total_classes <= 2 {
1491 self.penalties.len()
1492 } else {
1493 self.penalties.len().saturating_mul(self.total_classes)
1494 }
1495 }
1496
1497 pub fn equivariant_class_penalty_specs(
1498 &self,
1499 ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1500 let m = self.active_classes();
1501 let k = self.total_classes;
1502 let p = self.design.ncols();
1503 if k <= 2 {
1504 return self.centered_joint_penalty_specs();
1505 }
1506 let raw_total = m * p;
1507 self.validate_joint_seed_len(self.penalties.len() * k, "equivariant per-class")?;
1508 let mut specs = Vec::with_capacity(self.penalties.len() * k);
1509 for (t, pen) in self.penalties.iter().enumerate() {
1510 let s_t = pen.to_dense();
1511 let rank_s = measured_penalty_rank(&s_t)
1517 .map_err(|e| format!("multinomial equivariant penalty term {t}: {e}"))?;
1518 let nullspace_dim = raw_total - rank_s;
1519 for c in 0..k {
1520 let row: Vec<f64> = (0..m)
1522 .map(|b| {
1523 let indicator = if c == b { 1.0 } else { 0.0 };
1524 indicator - 1.0 / (k as f64)
1525 })
1526 .collect();
1527 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1528 for a in 0..m {
1529 for b in 0..m {
1530 let scale = row[a] * row[b];
1531 if scale == 0.0 {
1532 continue;
1533 }
1534 for i in 0..p {
1535 for j in 0..p {
1536 matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1537 }
1538 }
1539 }
1540 }
1541 specs.push(gam_problem::JointPenaltySpec {
1542 label: Some(format!("multinomial_term_{t}_class_{c}")),
1543 matrix,
1544 initial_log_lambda: self.joint_seed(t * k + c),
1545 nullspace_dim,
1546 group: Some(t),
1551 });
1552 }
1553 }
1554 Ok(specs)
1555 }
1556
1557 fn specs_match_workspace_shape(&self, specs: &[ParameterBlockSpec]) -> bool {
1593 let n = self.weights.len();
1594 let p = self.design.ncols();
1595 specs.len() == self.active_classes()
1596 && specs.iter().all(|spec| {
1597 spec.design.nrows() == n
1598 && spec.design.ncols() == p
1599 && spec.offset.len() == n
1600 && spec.stacked_design.is_none()
1601 && spec.stacked_offset.is_none()
1602 })
1603 }
1604
1605 fn collect_eta_matrix(
1608 &self,
1609 block_states: &[ParameterBlockState],
1610 ) -> Result<Array2<f64>, String> {
1611 let m = self.active_classes();
1612 validate_block_count::<String>("MultinomialFamily", m, block_states.len())?;
1613 let n = self.weights.len();
1614 let mut eta = Array2::<f64>::zeros((n, m));
1615 let eta_values = eta
1616 .as_slice_mut()
1617 .expect("fresh multinomial logits are contiguous");
1618 for (a, state) in block_states.iter().enumerate() {
1619 if state.eta.len() != n {
1620 return Err(format!(
1621 "MultinomialFamily block {a} eta length {} != N = {n}",
1622 state.eta.len()
1623 ));
1624 }
1625 let state_eta = state.eta.as_standard_layout();
1626 let state_values = state_eta
1627 .as_slice()
1628 .expect("standard-layout coefficient-block logits are contiguous");
1629 for row in 0..n {
1630 eta_values[row * m + a] = state_values[row];
1631 }
1632 }
1633 Ok(eta)
1634 }
1635
1636 fn evaluate_row_kernels(
1641 &self,
1642 eta: ArrayView2<'_, f64>,
1643 ) -> Result<(f64, Array3<f64>, Array2<f64>), String> {
1644 let (log_lik, grad_eta_logl, fisher) = self
1645 .likelihood
1646 .value_gradient_hessian(eta, self.y_one_hot.view())
1647 .map_err(|error| error.to_string())?;
1648 Ok((log_lik, fisher, grad_eta_logl))
1649 }
1650
1651 fn assemble_block_diagonal_working_sets(
1659 &self,
1660 fisher: &Array3<f64>,
1661 grad_eta_logl: &Array2<f64>,
1662 ) -> Result<Vec<BlockWorkingSet>, String> {
1663 let n = self.weights.len();
1664 let p = self.design.ncols();
1665 let m = self.active_classes();
1666 let design = self.design.as_standard_layout();
1667 let design_values = design
1668 .as_slice()
1669 .expect("standard-layout multinomial design is contiguous");
1670 let fisher = fisher.as_standard_layout();
1671 let fisher_values = fisher
1672 .as_slice()
1673 .expect("standard-layout multinomial Fisher blocks are contiguous");
1674 let grad_eta_logl = grad_eta_logl.as_standard_layout();
1675 let grad_eta_values = grad_eta_logl
1676 .as_slice()
1677 .expect("standard-layout multinomial eta gradient is contiguous");
1678
1679 let mut sets = Vec::with_capacity(m);
1680 for a in 0..m {
1681 let mut grad = Array1::<f64>::zeros(p);
1683 let grad_values = grad
1684 .as_slice_mut()
1685 .expect("fresh block gradient is contiguous");
1686 for i in 0..p {
1687 let mut acc = 0.0_f64;
1688 for row in 0..n {
1689 acc += design_values[row * p + i] * (-grad_eta_values[row * m + a]);
1690 }
1691 grad_values[i] = acc;
1692 }
1693 let mut hess = Array2::<f64>::zeros((p, p));
1695 let hess_values = hess
1696 .as_slice_mut()
1697 .expect("fresh block Hessian is contiguous");
1698 for row in 0..n {
1699 let w_aa = fisher_values[(row * m + a) * m + a];
1700 if w_aa == 0.0 {
1701 continue;
1702 }
1703 let design_row = &design_values[row * p..(row + 1) * p];
1704 for i in 0..p {
1705 let xi = design_row[i];
1706 if xi == 0.0 {
1707 continue;
1708 }
1709 let scaled = w_aa * xi;
1710 for j in 0..p {
1711 hess_values[i * p + j] += scaled * design_row[j];
1712 }
1713 }
1714 }
1715 for i in 0..p {
1717 for j in (i + 1)..p {
1718 let ij = i * p + j;
1719 let ji = j * p + i;
1720 let avg = 0.5 * (hess_values[ij] + hess_values[ji]);
1721 hess_values[ij] = avg;
1722 hess_values[ji] = avg;
1723 }
1724 }
1725 sets.push(BlockWorkingSet::ExactNewton {
1726 gradient: grad,
1727 hessian: SymmetricMatrix::Dense(hess),
1728 });
1729 }
1730 Ok(sets)
1731 }
1732
1733 fn assemble_joint_hessian(&self, fisher: &Array3<f64>) -> Result<Array2<f64>, String> {
1737 dense_block_xtwx(self.design.view(), fisher.view(), None)
1738 .map_err(|e| format!("MultinomialFamily joint Hessian assembly: {e}"))
1739 }
1740
1741 fn assemble_joint_gradient(&self, grad_eta_logl: &Array2<f64>) -> Array1<f64> {
1745 let n = self.weights.len();
1746 let p = self.design.ncols();
1747 let m = self.active_classes();
1748 let design = self.design.as_standard_layout();
1749 let design_values = design
1750 .as_slice()
1751 .expect("standard-layout multinomial design is contiguous");
1752 let grad_eta_logl = grad_eta_logl.as_standard_layout();
1753 let grad_eta_values = grad_eta_logl
1754 .as_slice()
1755 .expect("standard-layout multinomial eta gradient is contiguous");
1756 let mut out = Array1::<f64>::zeros(m * p);
1757 let out_values = out
1758 .as_slice_mut()
1759 .expect("fresh joint gradient is contiguous");
1760 for a in 0..m {
1761 for i in 0..p {
1762 let mut acc = 0.0_f64;
1763 for row in 0..n {
1764 acc += design_values[row * p + i] * grad_eta_values[row * m + a];
1765 }
1766 out_values[a * p + i] = acc;
1767 }
1768 }
1769 out
1770 }
1771
1772 fn joint_loglik_and_gradient_from_probs(
1785 &self,
1786 eta: ArrayView2<'_, f64>,
1787 probs_full: ArrayView2<'_, f64>,
1788 ) -> Result<(f64, Array1<f64>), String> {
1789 let n = self.weights.len();
1790 let p = self.design.ncols();
1791 let m = self.active_classes();
1792 let k = self.total_classes;
1793 assert_eq!(eta.dim(), (n, m));
1794 assert_eq!(probs_full.dim(), (n, k));
1795 let eta = eta.as_standard_layout();
1796 let eta_values = eta
1797 .as_slice()
1798 .expect("standard-layout multinomial logits are contiguous");
1799 let probs_full = probs_full.as_standard_layout();
1800 let probability_values = probs_full
1801 .as_slice()
1802 .expect("standard-layout multinomial probabilities are contiguous");
1803 let response = self.y_one_hot.as_standard_layout();
1804 let response_values = response
1805 .as_slice()
1806 .expect("standard-layout multinomial response is contiguous");
1807 let design = self.design.as_standard_layout();
1808 let design_values = design
1809 .as_slice()
1810 .expect("standard-layout multinomial design is contiguous");
1811 let mut log_lik = 0.0_f64;
1812 let mut eta_row = vec![0.0_f64; m];
1813 let mut response_row = vec![0.0_f64; k];
1814 for row in 0..n {
1815 let w = self.weights[row];
1816 if w == 0.0 {
1817 continue;
1818 }
1819 eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
1820 response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
1821 let program = MultinomialLogitRowProgram::new(&eta_row, &response_row, w)
1822 .map_err(|error| format!("invalid frozen multinomial row {row}: {error}"))?;
1823 log_lik -= program.negative_log_likelihood();
1824 }
1825 let mut grad = Array1::<f64>::zeros(m * p);
1826 let grad_values = grad
1827 .as_slice_mut()
1828 .expect("fresh joint gradient is contiguous");
1829 for a in 0..m {
1830 for i in 0..p {
1831 let mut acc = 0.0_f64;
1832 for row in 0..n {
1833 let resid = self.weights[row]
1834 * (response_values[row * k + a] - probability_values[row * k + a]);
1835 acc += design_values[row * p + i] * resid;
1836 }
1837 grad_values[a * p + i] = acc;
1838 }
1839 }
1840 Ok((log_lik, grad))
1841 }
1842
1843 fn d_eta_from_d_beta(&self, d_beta_flat: &Array1<f64>) -> Result<Array2<f64>, String> {
1847 let p = self.design.ncols();
1848 let m = self.active_classes();
1849 let n = self.design.nrows();
1850 if d_beta_flat.len() != m * p {
1851 return Err(format!(
1852 "MultinomialFamily direction length {} != (K-1)·P = {}",
1853 d_beta_flat.len(),
1854 m * p
1855 ));
1856 }
1857 let design = self.design.as_standard_layout();
1858 let design_values = design
1859 .as_slice()
1860 .expect("standard-layout multinomial design is contiguous");
1861 let d_beta = d_beta_flat.as_standard_layout();
1862 let d_beta_values = d_beta
1863 .as_slice()
1864 .expect("standard-layout multinomial direction is contiguous");
1865 let mut d_eta = Array2::<f64>::zeros((n, m));
1866 let d_eta_values = d_eta
1867 .as_slice_mut()
1868 .expect("fresh multinomial eta direction is contiguous");
1869 for a in 0..m {
1870 for row in 0..n {
1871 let mut acc = 0.0_f64;
1872 for i in 0..p {
1873 acc += design_values[row * p + i] * d_beta_values[a * p + i];
1874 }
1875 d_eta_values[row * m + a] = acc;
1876 }
1877 }
1878 Ok(d_eta)
1879 }
1880
1881 fn row_probabilities(&self, eta: ArrayView2<'_, f64>) -> Array2<f64> {
1884 self.likelihood.probabilities(eta)
1885 }
1886
1887 fn hessian_matvec_into_with_probs(
1911 &self,
1912 probs_full: ArrayView2<'_, f64>,
1913 v: &Array1<f64>,
1914 out: &mut Array1<f64>,
1915 ) -> Result<(), String> {
1916 let p = self.design.ncols();
1917 let m = self.active_classes();
1918 let n = self.weights.len();
1919 let total = m * p;
1920 if v.len() != total {
1921 return Err(format!(
1922 "MultinomialHessianWorkspace::hessian_matvec: v len {} != (K-1)·P = {total}",
1923 v.len()
1924 ));
1925 }
1926 if out.len() != total {
1927 return Err(format!(
1928 "MultinomialHessianWorkspace::hessian_matvec: out len {} != (K-1)·P = {total}",
1929 out.len()
1930 ));
1931 }
1932 out.fill(0.0);
1933 let design = self.design.as_standard_layout();
1934 let design_values = design
1935 .as_slice()
1936 .expect("standard-layout multinomial design is contiguous");
1937 let probs_full = probs_full.as_standard_layout();
1938 let probability_values = probs_full
1939 .as_slice()
1940 .expect("standard-layout multinomial probabilities are contiguous");
1941 let v = v.as_standard_layout();
1942 let v_values = v
1943 .as_slice()
1944 .expect("standard-layout Hessian direction is contiguous");
1945 let out_values = out
1946 .as_slice_mut()
1947 .expect("standard-layout Hessian output is contiguous");
1948 let mut xv = vec![0.0_f64; m];
1949 for row in 0..n {
1950 let w = self.weights[row];
1951 if w == 0.0 {
1952 continue;
1953 }
1954 let mut s = 0.0_f64;
1957 for b in 0..m {
1958 let mut acc = 0.0_f64;
1959 for j in 0..p {
1960 acc += design_values[row * p + j] * v_values[b * p + j];
1961 }
1962 xv[b] = acc;
1963 s += probability_values[row * self.total_classes + b] * acc;
1964 }
1965 for a in 0..m {
1967 let r = w * probability_values[row * self.total_classes + a] * (xv[a] - s);
1968 if r == 0.0 {
1969 continue;
1970 }
1971 let base = a * p;
1972 for i in 0..p {
1973 out_values[base + i] += design_values[row * p + i] * r;
1974 }
1975 }
1976 }
1977 Ok(())
1978 }
1979
1980 fn hessian_diagonal_with_probs(&self, probs_full: ArrayView2<'_, f64>) -> Array1<f64> {
1997 let p = self.design.ncols();
1998 let m = self.active_classes();
1999 let n = self.weights.len();
2000 let dim = m * p;
2001 let design = self.design.view();
2002 gam_problem::outer_subsample::RowSet::All.par_reduce_fold(
2003 n,
2004 || Array1::<f64>::zeros(dim),
2005 |mut acc, row, _| {
2006 let w = self.weights[row];
2007 if w == 0.0 {
2008 return acc;
2009 }
2010 for a in 0..m {
2011 let pa = probs_full[[row, a]];
2012 let waa = w * pa * (1.0 - pa);
2013 if waa == 0.0 {
2014 continue;
2015 }
2016 let base = a * p;
2017 for i in 0..p {
2018 let xi = design[[row, i]];
2019 acc[base + i] += waa * xi * xi;
2020 }
2021 }
2022 acc
2023 },
2024 |mut a, b| {
2025 a += &b;
2026 a
2027 },
2028 )
2029 }
2030
2031 fn directional_fisher_jet(
2054 &self,
2055 eta: ArrayView2<'_, f64>,
2056 d_beta_flat: &Array1<f64>,
2057 ) -> Result<Array3<f64>, String> {
2058 let p = self.design.ncols();
2059 let m = self.active_classes();
2060 if d_beta_flat.len() != m * p {
2061 return Err(format!(
2062 "MultinomialFamily direction length {} != (K-1)·P = {}",
2063 d_beta_flat.len(),
2064 m * p
2065 ));
2066 }
2067 let probs_full = self.row_probabilities(eta);
2068 Ok(self.directional_fisher_jet_rows(probs_full.view(), d_beta_flat))
2069 }
2070
2071 fn directional_fisher_jet_rows(
2083 &self,
2084 probs_full: ArrayView2<'_, f64>,
2085 direction: &Array1<f64>,
2086 ) -> Array3<f64> {
2087 let n = self.weights.len();
2088 let p = self.design.ncols();
2089 let m = self.active_classes();
2090 let design = self.design.as_standard_layout();
2091 let design_values = design
2092 .as_slice()
2093 .expect("standard-layout multinomial design is contiguous");
2094 let direction = direction.as_standard_layout();
2095 let direction_values = direction
2096 .as_slice()
2097 .expect("owned coefficient direction is contiguous");
2098 let probs = probs_full.as_standard_layout();
2099 let probs_values = probs
2100 .as_slice()
2101 .expect("standard-layout multinomial probabilities are contiguous");
2102 let probability_columns = probs.ncols();
2103 let mut out = Array3::<f64>::zeros((n, m, m));
2104 let mut d_eta = vec![0.0_f64; m];
2105 let mut normalized = vec![0.0; m];
2106 let out_flat = out
2107 .as_slice_mut()
2108 .expect("owned Fisher jet must be contiguous");
2109 for row in 0..n {
2110 let w = self.weights[row];
2111 if w == 0.0 {
2112 continue;
2113 }
2114 for a in 0..m {
2115 let base = a * p;
2116 let mut eta_dir = 0.0_f64;
2117 for i in 0..p {
2118 eta_dir += design_values[row * p + i] * direction_values[base + i];
2119 }
2120 d_eta[a] = eta_dir;
2121 }
2122 let row_start = row * m * m;
2123 softmax_fisher_perturbation::<OneSeed<0>>(
2124 m,
2125 w,
2126 |a| probs_values[row * probability_columns + a],
2127 |a| d_eta[a],
2128 |_| 0.0,
2129 &mut normalized,
2130 &mut out_flat[row_start..row_start + m * m],
2131 );
2132 }
2133 out
2134 }
2135
2136 fn second_directional_fisher_jet_rows(
2143 &self,
2144 probs_full: ArrayView2<'_, f64>,
2145 u: &Array1<f64>,
2146 v: &Array1<f64>,
2147 ) -> Array3<f64> {
2148 let n = self.weights.len();
2149 let p = self.design.ncols();
2150 let m = self.active_classes();
2151 let design = self.design.as_standard_layout();
2152 let design_values = design
2153 .as_slice()
2154 .expect("standard-layout multinomial design is contiguous");
2155 let u = u.as_standard_layout();
2156 let u_values = u
2157 .as_slice()
2158 .expect("owned first coefficient direction is contiguous");
2159 let v = v.as_standard_layout();
2160 let v_values = v
2161 .as_slice()
2162 .expect("owned second coefficient direction is contiguous");
2163 let probs = probs_full.as_standard_layout();
2164 let probs_values = probs
2165 .as_slice()
2166 .expect("standard-layout multinomial probabilities are contiguous");
2167 let probability_columns = probs.ncols();
2168 let mut out = Array3::<f64>::zeros((n, m, m));
2169 let mut d_eta_u = vec![0.0_f64; m];
2170 let mut d_eta_v = vec![0.0_f64; m];
2171 let mut normalized = vec![[0.0; 3]; m];
2172 let out_flat = out
2173 .as_slice_mut()
2174 .expect("owned Fisher jet must be contiguous");
2175 for row in 0..n {
2176 let w = self.weights[row];
2177 if w == 0.0 {
2178 continue;
2179 }
2180 for a in 0..m {
2181 let base = a * p;
2182 let mut eta_u = 0.0_f64;
2183 let mut eta_v = 0.0_f64;
2184 for i in 0..p {
2185 let x = design_values[row * p + i];
2186 eta_u += x * u_values[base + i];
2187 eta_v += x * v_values[base + i];
2188 }
2189 d_eta_u[a] = eta_u;
2190 d_eta_v[a] = eta_v;
2191 }
2192 let row_start = row * m * m;
2193 softmax_fisher_perturbation::<TwoSeed<0>>(
2194 m,
2195 w,
2196 |a| probs_values[row * probability_columns + a],
2197 |a| d_eta_u[a],
2198 |a| d_eta_v[a],
2199 &mut normalized,
2200 &mut out_flat[row_start..row_start + m * m],
2201 );
2202 }
2203 out
2204 }
2205
2206 fn directional_hyper_operator(
2212 &self,
2213 probs_full: ArrayView2<'_, f64>,
2214 direction: &Array1<f64>,
2215 projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
2216 ) -> Result<MultinomialDirectionalHyperOperator, String> {
2217 let dim = self.beta_flat_dim();
2218 if direction.len() != dim {
2219 return Err(format!(
2220 "MultinomialFamily matrix-free direction length {} != (K-1)·P = {dim}",
2221 direction.len()
2222 ));
2223 }
2224 Ok(MultinomialDirectionalHyperOperator {
2225 design: Arc::clone(&self.design),
2226 jet: self.directional_fisher_jet_rows(probs_full, direction),
2227 m: self.active_classes(),
2228 p: self.design.ncols(),
2229 projection_cache,
2230 })
2231 }
2232
2233 fn second_directional_hyper_operator(
2236 &self,
2237 probs_full: ArrayView2<'_, f64>,
2238 u: &Array1<f64>,
2239 v: &Array1<f64>,
2240 projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
2241 ) -> Result<MultinomialDirectionalHyperOperator, String> {
2242 let dim = self.beta_flat_dim();
2243 if u.len() != dim || v.len() != dim {
2244 return Err(format!(
2245 "MultinomialFamily matrix-free second-directional pair lengths {} and {} != (K-1)·P = {dim}",
2246 u.len(),
2247 v.len()
2248 ));
2249 }
2250 Ok(MultinomialDirectionalHyperOperator {
2251 design: Arc::clone(&self.design),
2252 jet: self.second_directional_fisher_jet_rows(probs_full, u, v),
2253 m: self.active_classes(),
2254 p: self.design.ncols(),
2255 projection_cache,
2256 })
2257 }
2258
2259 fn second_directional_fisher_jet(
2275 &self,
2276 eta: ArrayView2<'_, f64>,
2277 d_beta_u: &Array1<f64>,
2278 d_beta_v: &Array1<f64>,
2279 ) -> Result<Array3<f64>, String> {
2280 let p = self.design.ncols();
2281 let m = self.active_classes();
2282 let dim = m * p;
2283 if d_beta_u.len() != dim || d_beta_v.len() != dim {
2284 return Err(format!(
2285 "MultinomialFamily second-directional pair lengths {} and {} != (K-1)·P = {dim}",
2286 d_beta_u.len(),
2287 d_beta_v.len()
2288 ));
2289 }
2290 let probs_full = self.row_probabilities(eta);
2291 Ok(self.second_directional_fisher_jet_rows(probs_full.view(), d_beta_u, d_beta_v))
2292 }
2293
2294 fn contracted_fisher_trace_hessian(
2318 &self,
2319 eta: ArrayView2<'_, f64>,
2320 trace_weight: &Array2<f64>,
2321 ) -> Result<Array2<f64>, String> {
2322 let n = self.weights.len();
2323 let p = self.design.ncols();
2324 let m = self.active_classes();
2325 let dim = m * p;
2326 if trace_weight.dim() != (dim, dim) {
2327 return Err(format!(
2328 "multinomial contracted Fisher trace Hessian weight shape {:?} != ({dim}, {dim})",
2329 trace_weight.dim()
2330 ));
2331 }
2332 if trace_weight.iter().any(|value| !value.is_finite()) {
2333 return Err(
2334 "multinomial contracted Fisher trace Hessian weight is non-finite".to_string(),
2335 );
2336 }
2337 let probabilities = self.row_probabilities(eta);
2338 let design = self.design.view();
2339 let mut eta_hessian = Array3::<f64>::zeros((n, m, m));
2340 let mut coefficient_contraction = vec![0.0_f64; m * m];
2341 let mut normalized = vec![[0.0; 3]; m];
2342 let mut fisher_second = vec![0.0_f64; m * m];
2343 for row in 0..n {
2344 let row_weight = self.weights[row];
2345 if row_weight == 0.0 {
2346 continue;
2347 }
2348 coefficient_contraction.fill(0.0);
2349 for c in 0..m {
2350 let coefficient_row = c * p;
2351 for d in 0..m {
2352 let coefficient_column = d * p;
2353 let mut contraction = 0.0_f64;
2354 for i in 0..p {
2355 let x_i = design[[row, i]];
2356 if x_i == 0.0 {
2357 continue;
2358 }
2359 for j in 0..p {
2360 contraction += x_i
2361 * trace_weight[[coefficient_row + i, coefficient_column + j]]
2362 * design[[row, j]];
2363 }
2364 }
2365 coefficient_contraction[c * m + d] = contraction;
2366 }
2367 }
2368 for a in 0..m {
2369 for b in a..m {
2370 normalized.fill([0.0; 3]);
2371 fisher_second.fill(0.0);
2372 softmax_fisher_perturbation::<TwoSeed<0>>(
2373 m,
2374 row_weight,
2375 |class| probabilities[[row, class]],
2376 |class| if class == a { 1.0 } else { 0.0 },
2377 |class| if class == b { 1.0 } else { 0.0 },
2378 &mut normalized,
2379 &mut fisher_second,
2380 );
2381 let value = coefficient_contraction
2382 .iter()
2383 .zip(fisher_second.iter())
2384 .map(|(&coefficient, &second)| coefficient * second)
2385 .sum::<f64>();
2386 eta_hessian[[row, a, b]] = value;
2387 eta_hessian[[row, b, a]] = value;
2388 }
2389 }
2390 }
2391 dense_block_xtwx(self.design.view(), eta_hessian.view(), None)
2392 .map_err(|error| format!("multinomial contracted Fisher trace Hessian: {error}"))
2393 }
2394
2395 fn assemble_all_axis_derivatives_from_row_kernel(
2420 &self,
2421 mut fill_row_kernel: impl FnMut(usize, usize, &mut [f64]),
2422 ) -> Vec<Array2<f64>> {
2423 let n = self.weights.len();
2424 let p = self.design.ncols();
2425 let m = self.active_classes();
2426 let dim = m * p;
2427 let p_squared = p * p;
2428 let kernel_columns = m * m * p_squared;
2429 let design = self
2430 .design
2431 .as_slice()
2432 .expect("multinomial design is contiguous");
2433 let mut row_quadratics = Array2::<f64>::zeros((n, kernel_columns));
2434 let mut axes = Vec::with_capacity(dim);
2435 let mut row_kernel = vec![0.0_f64; m * m];
2436
2437 for moving_class in 0..m {
2438 row_quadratics.fill(0.0);
2439 let quadratics = row_quadratics
2440 .as_slice_mut()
2441 .expect("row-quadratic workspace is contiguous");
2442 for row in 0..n {
2443 if self.weights[row] == 0.0 {
2444 continue;
2445 }
2446 fill_row_kernel(row, moving_class, &mut row_kernel);
2447 let x = &design[row * p..(row + 1) * p];
2448 let output = &mut quadratics[row * kernel_columns..(row + 1) * kernel_columns];
2449 for (class_pair, &kernel) in row_kernel.iter().enumerate() {
2450 let block = &mut output[class_pair * p_squared..(class_pair + 1) * p_squared];
2451 for (i, &x_i) in x.iter().enumerate() {
2452 let block_row = &mut block[i * p..(i + 1) * p];
2453 let scale = kernel * x_i;
2454 for (entry, &x_j) in block_row.iter_mut().zip(x) {
2455 *entry = scale * x_j;
2456 }
2457 }
2458 }
2459 }
2460
2461 let moments = fast_atb(self.design.as_ref(), &row_quadratics);
2462 let moments = moments
2463 .as_slice()
2464 .expect("third-moment GEMM output is contiguous");
2465 for moving_column in 0..p {
2466 let moment_row =
2467 &moments[moving_column * kernel_columns..(moving_column + 1) * kernel_columns];
2468 let mut matrix = vec![0.0_f64; dim * dim];
2469 for c in 0..m {
2470 for d in 0..m {
2471 let class_pair = c * m + d;
2472 let block =
2473 &moment_row[class_pair * p_squared..(class_pair + 1) * p_squared];
2474 for i in 0..p {
2475 let output_start = (c * p + i) * dim + d * p;
2476 matrix[output_start..output_start + p]
2477 .copy_from_slice(&block[i * p..(i + 1) * p]);
2478 }
2479 }
2480 }
2481 for i in 0..dim {
2482 for j in (i + 1)..dim {
2483 let upper = i * dim + j;
2484 let lower = j * dim + i;
2485 let average = 0.5 * (matrix[upper] + matrix[lower]);
2486 matrix[upper] = average;
2487 matrix[lower] = average;
2488 }
2489 }
2490 axes.push(
2491 Array2::<f64>::from_shape_vec((dim, dim), matrix)
2492 .expect("axis derivative buffer is dim·dim"),
2493 );
2494 }
2495 }
2496 axes
2497 }
2498
2499 fn assemble_all_axis_directional_derivatives(
2527 &self,
2528 eta: ArrayView2<'_, f64>,
2529 ) -> Vec<Array2<f64>> {
2530 let m = self.active_classes();
2531 let probs_full = self.row_probabilities(eta);
2532 let mut normalized = vec![0.0; m];
2533 self.assemble_all_axis_derivatives_from_row_kernel(|row, moving_class, row_kernel| {
2534 softmax_fisher_perturbation::<OneSeed<0>>(
2535 m,
2536 self.weights[row],
2537 |class| probs_full[[row, class]],
2538 |class| if class == moving_class { 1.0 } else { 0.0 },
2539 |_| 0.0,
2540 &mut normalized,
2541 row_kernel,
2542 );
2543 })
2544 }
2545
2546 fn assemble_all_axis_second_directional_derivatives(
2579 &self,
2580 eta: ArrayView2<'_, f64>,
2581 d_beta_u: &Array1<f64>,
2582 ) -> Result<Vec<Array2<f64>>, String> {
2583 let m = self.active_classes();
2584 let probs_full = self.row_probabilities(eta);
2585 let d_eta_u = self.d_eta_from_d_beta(d_beta_u)?;
2586 let mut normalized = vec![[0.0; 3]; m];
2587 Ok(
2588 self.assemble_all_axis_derivatives_from_row_kernel(|row, moving_class, row_kernel| {
2589 softmax_fisher_perturbation::<TwoSeed<0>>(
2590 m,
2591 self.weights[row],
2592 |class| probs_full[[row, class]],
2593 |class| d_eta_u[[row, class]],
2594 |class| if class == moving_class { 1.0 } else { 0.0 },
2595 &mut normalized,
2596 row_kernel,
2597 );
2598 }),
2599 )
2600 }
2601
2602 fn canonical_axis_index(&self, d_beta_flat: &Array1<f64>) -> Option<usize> {
2605 let mut axis: Option<usize> = None;
2606 for (k, &v) in d_beta_flat.iter().enumerate() {
2607 if v == 0.0 {
2608 continue;
2609 }
2610 if v != 1.0 || axis.is_some() {
2611 return None;
2612 }
2613 axis = Some(k);
2614 }
2615 axis
2616 }
2617
2618 fn cached_axis_directional_derivative(
2625 &self,
2626 eta: ArrayView2<'_, f64>,
2627 axis: usize,
2628 ) -> Array2<f64> {
2629 let key = EtaFingerprint::of(eta);
2630 {
2631 let guard = self
2632 .axis_derivative_cache
2633 .lock()
2634 .expect("axis derivative cache mutex poisoned");
2635 if let Some(cache) = guard.as_ref()
2636 && cache.eta_key == key
2637 {
2638 return cache.derivatives[axis].clone();
2639 }
2640 }
2641 let derivatives = self.assemble_all_axis_directional_derivatives(eta);
2646 let result = derivatives[axis].clone();
2647 let mut guard = self
2648 .axis_derivative_cache
2649 .lock()
2650 .expect("axis derivative cache mutex poisoned");
2651 *guard = Some(AxisDerivativeCache {
2652 eta_key: key,
2653 derivatives,
2654 });
2655 result
2656 }
2657}
2658
2659impl CustomFamily for MultinomialFamily {
2660 fn joint_jeffreys_term_required(&self) -> bool {
2661 self.joint_jeffreys_term_strength > 0.0
2662 }
2663
2664 fn joint_jeffreys_term_strength(&self) -> f64 {
2665 self.joint_jeffreys_term_strength
2666 }
2667
2668 fn jeffreys_span_basis(&self) -> Result<Option<Array2<f64>>, String> {
2669 let Some(span) = self.joint_jeffreys_span.as_ref() else {
2670 return Ok(None);
2671 };
2672 let expected = self.beta_flat_dim();
2673 if span.nrows() != expected {
2674 return Err(format!(
2675 "multinomial measured Jeffreys span is {:?}, expected ({expected}, m)",
2676 span.dim()
2677 ));
2678 }
2679 Ok(Some(span.as_ref().clone()))
2680 }
2681
2682 fn coefficient_mode_homotopy_member(&self, progress: f64) -> Result<Option<Self>, String> {
2683 if !progress.is_finite() || !(0.0..=1.0).contains(&progress) {
2684 return Err(format!(
2685 "multinomial Jeffreys homotopy progress must lie in [0, 1], got {progress}"
2686 ));
2687 }
2688 if self.joint_jeffreys_term_strength == 0.0 {
2689 return Ok(None);
2690 }
2691 let mut member = self.clone();
2692 member.joint_jeffreys_term_strength = progress * self.joint_jeffreys_term_strength;
2693 Ok(Some(member))
2694 }
2695
2696 fn joint_penalty_specs(&self) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
2697 self.equivariant_class_penalty_specs()
2708 }
2709
2710 fn jeffreys_span_aggregate_penalty(&self) -> Result<Option<Array2<f64>>, String> {
2734 if self.penalties.is_empty() {
2735 return Ok(None);
2738 }
2739 let p = self.design.ncols();
2740 let m = self.active_classes();
2741 let mut term_sum = Array2::<f64>::zeros((p, p));
2742 for penalty in self.penalties.iter() {
2743 let dense = penalty.to_dense();
2744 if dense.dim() != (p, p) {
2745 return Err(format!(
2746 "multinomial Jeffreys span aggregate: penalty component is {:?}, expected \
2747 ({p}, {p})",
2748 dense.dim()
2749 ));
2750 }
2751 term_sum += &dense;
2752 }
2753 let metric = centered_class_metric(m, self.total_classes);
2754 let mut aggregate = Array2::<f64>::zeros((m * p, m * p));
2755 for a in 0..m {
2756 for b in 0..m {
2757 let scale = metric[[a, b]];
2758 if scale == 0.0 {
2759 continue;
2760 }
2761 for i in 0..p {
2762 for j in 0..p {
2763 aggregate[[a * p + i, b * p + j]] = scale * term_sum[[i, j]];
2764 }
2765 }
2766 }
2767 }
2768 Ok(Some(aggregate))
2769 }
2770
2771 fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
2772 true
2774 }
2775
2776 fn inner_coefficient_objective_is_globally_convex(&self) -> bool {
2777 self.joint_jeffreys_term_strength == 0.0
2783 }
2784
2785 fn pseudo_logdet_mode(&self) -> PseudoLogdetMode {
2786 PseudoLogdetMode::PositiveDefinite
2793 }
2794
2795 fn has_explicit_joint_hessian(&self) -> bool {
2796 true
2797 }
2798
2799 fn requires_joint_outer_hyper_path(&self) -> bool {
2800 true
2803 }
2804
2805 fn levenberg_on_ill_conditioning(&self) -> bool {
2806 true
2832 }
2833
2834 fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2835 self.specs_match_workspace_shape(specs)
2836 }
2837
2838 fn inner_joint_workspace_gradient_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2839 self.specs_match_workspace_shape(specs)
2840 }
2841
2842 fn inner_joint_workspace_log_likelihood_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2843 self.specs_match_workspace_shape(specs)
2844 }
2845
2846 fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
2847 crate::custom_family::joint_coupled_coefficient_hessian_cost(
2850 self.weights.len() as u64,
2851 specs,
2852 )
2853 }
2854
2855 fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
2856 let eta = self.collect_eta_matrix(block_states)?;
2857 let (log_lik, fisher, grad_eta_logl) = self.evaluate_row_kernels(eta.view())?;
2858 let working_sets = self.assemble_block_diagonal_working_sets(&fisher, &grad_eta_logl)?;
2859 Ok(FamilyEvaluation {
2860 log_likelihood: log_lik,
2861 blockworking_sets: working_sets,
2862 })
2863 }
2864
2865 fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
2866 let eta = self.collect_eta_matrix(block_states)?;
2867 self.likelihood
2868 .log_lik(eta.view(), self.y_one_hot.view())
2869 .map_err(|error| error.to_string())
2870 }
2871
2872 fn exact_newton_joint_hessian(
2873 &self,
2874 block_states: &[ParameterBlockState],
2875 ) -> Result<Option<Array2<f64>>, String> {
2876 let eta = self.collect_eta_matrix(block_states)?;
2877 let (_, fisher, _) = self.evaluate_row_kernels(eta.view())?;
2878 let hessian = self.assemble_joint_hessian(&fisher)?;
2879 Ok(Some(hessian))
2880 }
2881
2882 fn exact_newton_joint_gradient_evaluation(
2883 &self,
2884 block_states: &[ParameterBlockState],
2885 specs: &[ParameterBlockSpec],
2886 ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2887 self.check_spec_coefficient_width(specs, "joint gradient")?;
2888 let eta = self.collect_eta_matrix(block_states)?;
2889 let (log_lik, grad_eta_logl) = self
2890 .likelihood
2891 .value_gradient(eta.view(), self.y_one_hot.view())
2892 .map_err(|error| error.to_string())?;
2893 let gradient = self.assemble_joint_gradient(&grad_eta_logl);
2894 Ok(Some(ExactNewtonJointGradientEvaluation {
2895 log_likelihood: log_lik,
2896 gradient,
2897 }))
2898 }
2899
2900 fn exact_newton_joint_hessian_workspace(
2901 &self,
2902 block_states: &[ParameterBlockState],
2903 specs: &[ParameterBlockSpec],
2904 ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2905 self.check_spec_coefficient_width(specs, "joint Hessian workspace")?;
2906 let eta = self.collect_eta_matrix(block_states)?;
2912 let probs = self.row_probabilities(eta.view());
2913 Ok(Some(Arc::new(MultinomialHessianWorkspace {
2914 family: self.clone(),
2915 block_states: block_states.to_vec(),
2916 eta,
2917 probs,
2918 projection_cache: Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
2919 })))
2920 }
2921
2922 fn exact_newton_joint_hessian_directional_derivative(
2923 &self,
2924 block_states: &[ParameterBlockState],
2925 d_beta_flat: &Array1<f64>,
2926 ) -> Result<Option<Array2<f64>>, String> {
2927 let eta = self.collect_eta_matrix(block_states)?;
2928 if d_beta_flat.len() != self.beta_flat_dim() {
2929 return Err(format!(
2930 "MultinomialFamily direction length {} != (K-1)·P = {}",
2931 d_beta_flat.len(),
2932 self.beta_flat_dim()
2933 ));
2934 }
2935 if let Some(axis) = self.canonical_axis_index(d_beta_flat) {
2942 return Ok(Some(
2943 self.cached_axis_directional_derivative(eta.view(), axis),
2944 ));
2945 }
2946 let dh_fisher = self.directional_fisher_jet(eta.view(), d_beta_flat)?;
2949 let dh = dense_block_xtwx(self.design.view(), dh_fisher.view(), None)
2950 .map_err(|e| format!("MultinomialFamily directional H assembly: {e}"))?;
2951 Ok(Some(dh))
2952 }
2953
2954 fn joint_jeffreys_information_directional_derivative_all_axes_with_specs(
2955 &self,
2956 block_states: &[ParameterBlockState],
2957 specs: &[ParameterBlockSpec],
2958 ) -> Result<Option<Vec<Array2<f64>>>, String> {
2959 let eta = self.collect_eta_matrix(block_states)?;
2970 let axes = self.assemble_all_axis_directional_derivatives(eta.view());
2971 let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2976 if axes.len() != p {
2977 return Err(format!(
2978 "multinomial all-axes Jeffreys derivative produced {} axes but the block specs \
2979 describe p={p} joint coefficients",
2980 axes.len(),
2981 ));
2982 }
2983 Ok(Some(axes))
2984 }
2985
2986 fn joint_jeffreys_information_second_directional_all_axes_with_specs(
2987 &self,
2988 block_states: &[ParameterBlockState],
2989 specs: &[ParameterBlockSpec],
2990 d_beta_u_flat: &Array1<f64>,
2991 ) -> Result<Option<Vec<Array2<f64>>>, String> {
2992 let eta = self.collect_eta_matrix(block_states)?;
3001 let axes =
3002 self.assemble_all_axis_second_directional_derivatives(eta.view(), d_beta_u_flat)?;
3003 let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
3007 if axes.len() != p {
3008 return Err(format!(
3009 "multinomial all-axes second Jeffreys derivative produced {} axes but the block \
3010 specs describe p={p} joint coefficients",
3011 axes.len(),
3012 ));
3013 }
3014 Ok(Some(axes))
3015 }
3016
3017 fn joint_jeffreys_information_contracted_trace_hessian_with_specs(
3018 &self,
3019 block_states: &[ParameterBlockState],
3020 specs: &[ParameterBlockSpec],
3021 weight: &Array2<f64>,
3022 ) -> Result<Option<Array2<f64>>, String> {
3023 self.check_spec_coefficient_width(specs, "contracted Jeffreys-information trace Hessian")?;
3024 let eta = self.collect_eta_matrix(block_states)?;
3025 self.contracted_fisher_trace_hessian(eta.view(), weight)
3026 .map(Some)
3027 }
3028
3029 fn joint_jeffreys_information_contracted_trace_hessian_available(&self) -> bool {
3030 true
3031 }
3032
3033 fn exact_newton_joint_hessiansecond_directional_derivative(
3034 &self,
3035 block_states: &[ParameterBlockState],
3036 d_beta_u_flat: &Array1<f64>,
3037 d_beta_v_flat: &Array1<f64>,
3038 ) -> Result<Option<Array2<f64>>, String> {
3039 let eta = self.collect_eta_matrix(block_states)?;
3040 let d2h_fisher =
3041 self.second_directional_fisher_jet(eta.view(), d_beta_u_flat, d_beta_v_flat)?;
3042 let d2h = dense_block_xtwx(self.design.view(), d2h_fisher.view(), None)
3043 .map_err(|e| format!("MultinomialFamily second directional H assembly: {e}"))?;
3044 Ok(Some(d2h))
3045 }
3046}
3047
3048struct MultinomialHessianWorkspace {
3057 family: MultinomialFamily,
3058 block_states: Vec<ParameterBlockState>,
3059 eta: Array2<f64>,
3063 probs: Array2<f64>,
3068 projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
3073}
3074
3075impl ExactNewtonJointHessianWorkspace for MultinomialHessianWorkspace {
3076 fn warm_up_outer_caches_for_mode(
3077 &self,
3078 eval_mode: gam_problem::EvalMode,
3079 ) -> Result<(), String> {
3080 match eval_mode {
3081 gam_problem::EvalMode::ValueOnly
3082 | gam_problem::EvalMode::ValueAndGradient
3083 | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
3084 }
3085 }
3086
3087 fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
3088 self.family.exact_newton_joint_hessian(&self.block_states)
3089 }
3090
3091 fn hessian_source_preference(&self) -> JointHessianSourcePreference {
3092 JointHessianSourcePreference::Operator
3099 }
3100
3101 fn joint_log_likelihood_evaluation(&self) -> Result<Option<f64>, String> {
3102 let (log_lik, _) = self
3103 .family
3104 .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
3105 Ok(Some(log_lik))
3106 }
3107
3108 fn joint_gradient_evaluation(
3109 &self,
3110 ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
3111 let (log_likelihood, gradient) = self
3112 .family
3113 .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
3114 Ok(Some(ExactNewtonJointGradientEvaluation {
3115 log_likelihood,
3116 gradient,
3117 }))
3118 }
3119
3120 fn hessian_matvec_available(&self) -> bool {
3121 true
3122 }
3123
3124 fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
3125 let mut out = Array1::<f64>::zeros(self.family.beta_flat_dim());
3126 self.family
3127 .hessian_matvec_into_with_probs(self.probs.view(), v, &mut out)?;
3128 Ok(Some(out))
3129 }
3130
3131 fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
3132 self.family
3133 .hessian_matvec_into_with_probs(self.probs.view(), v, out)?;
3134 Ok(true)
3135 }
3136
3137 fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
3138 Ok(Some(
3139 self.family.hessian_diagonal_with_probs(self.probs.view()),
3140 ))
3141 }
3142
3143 fn directional_derivative(
3144 &self,
3145 d_beta_flat: &Array1<f64>,
3146 ) -> Result<Option<Array2<f64>>, String> {
3147 self.family
3148 .exact_newton_joint_hessian_directional_derivative(&self.block_states, d_beta_flat)
3149 }
3150
3151 fn directional_derivative_operators(
3152 &self,
3153 d_beta_flats: &[Array1<f64>],
3154 ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
3155 let probs = self.probs.view();
3162 d_beta_flats
3163 .par_iter()
3164 .map(|direction| {
3165 self.family
3166 .directional_hyper_operator(
3167 probs,
3168 direction,
3169 Arc::clone(&self.projection_cache),
3170 )
3171 .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
3172 })
3173 .collect()
3174 }
3175
3176 fn second_directional_derivative(
3177 &self,
3178 d_beta_u: &Array1<f64>,
3179 d_beta_v: &Array1<f64>,
3180 ) -> Result<Option<Array2<f64>>, String> {
3181 self.family
3182 .exact_newton_joint_hessiansecond_directional_derivative(
3183 &self.block_states,
3184 d_beta_u,
3185 d_beta_v,
3186 )
3187 }
3188
3189 fn second_directional_derivative_operators(
3190 &self,
3191 d_beta_pairs: &[(Array1<f64>, Array1<f64>)],
3192 ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
3193 let probs = self.probs.view();
3196 d_beta_pairs
3197 .par_iter()
3198 .map(|(u, v)| {
3199 self.family
3200 .second_directional_hyper_operator(
3201 probs,
3202 u,
3203 v,
3204 Arc::clone(&self.projection_cache),
3205 )
3206 .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
3207 })
3208 .collect()
3209 }
3210}
3211
3212struct MultinomialClassProjection {
3219 factor: Array2<f64>,
3220 projected: Arc<Array2<f64>>,
3221}
3222
3223impl MultinomialClassProjection {
3224 fn matches(&self, factor: &Array2<f64>) -> bool {
3225 self.factor.dim() == factor.dim()
3226 && self
3227 .factor
3228 .iter()
3229 .zip(factor.iter())
3230 .all(|(&cached, &requested)| cached.to_bits() == requested.to_bits())
3231 }
3232}
3233
3234struct MultinomialDirectionalHyperOperator {
3264 design: Arc<Array2<f64>>,
3266 jet: Array3<f64>,
3268 m: usize,
3270 p: usize,
3272 projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
3276}
3277
3278impl MultinomialDirectionalHyperOperator {
3279 fn compute_projected_design_by_class(&self, factor: &Array2<f64>) -> Array2<f64> {
3286 let dim = self.m * self.p;
3287 assert_eq!(factor.nrows(), dim);
3288 let n = self.design.nrows();
3289 let rank = factor.ncols();
3290 let mut projected = Array2::<f64>::zeros((self.m * n, rank));
3291 for class in 0..self.m {
3292 let factor_block = factor.slice(ndarray::s![class * self.p..(class + 1) * self.p, ..]);
3293 let class_projection = fast_ab(self.design.as_ref(), &factor_block);
3294 projected
3295 .slice_mut(ndarray::s![class * n..(class + 1) * n, ..])
3296 .assign(&class_projection);
3297 }
3298 projected
3299 }
3300
3301 fn projected_design_by_class(&self, factor: &Array2<f64>) -> Arc<Array2<f64>> {
3307 if let Some(cached) = self.projection_cache.get() {
3308 return if cached.matches(factor) {
3309 Arc::clone(&cached.projected)
3310 } else {
3311 Arc::new(self.compute_projected_design_by_class(factor))
3312 };
3313 }
3314
3315 let cached = self
3316 .projection_cache
3317 .get_or_compute(|| MultinomialClassProjection {
3318 factor: factor.clone(),
3319 projected: Arc::new(self.compute_projected_design_by_class(factor)),
3320 });
3321 if cached.matches(factor) {
3322 Arc::clone(&cached.projected)
3323 } else {
3324 Arc::new(self.compute_projected_design_by_class(factor))
3325 }
3326 }
3327
3328 fn apply_jet_to_projected_design(&self, projected: &Array2<f64>) -> Array2<f64> {
3331 let n = self.design.nrows();
3332 let rank = projected.ncols();
3333 assert_eq!(projected.nrows(), self.m * n);
3334 let projected_values = projected
3335 .as_slice()
3336 .expect("class-projected design is standard-layout");
3337 let jet_values = self
3338 .jet
3339 .as_slice()
3340 .expect("directional Fisher jet is standard-layout");
3341 let mut weighted = Array2::<f64>::zeros(projected.raw_dim());
3342 let weighted_values = weighted
3343 .as_slice_mut()
3344 .expect("weighted class-projected design is standard-layout");
3345
3346 for class in 0..self.m {
3347 for row in 0..n {
3348 let target = (class * n + row) * rank;
3349 for source_class in 0..self.m {
3350 let weight = jet_values[(row * self.m + class) * self.m + source_class];
3351 let source = (source_class * n + row) * rank;
3352 for column in 0..rank {
3353 weighted_values[target + column] +=
3354 weight * projected_values[source + column];
3355 }
3356 }
3357 }
3358 }
3359 weighted
3360 }
3361}
3362
3363impl HyperOperator for MultinomialDirectionalHyperOperator {
3364 fn dim(&self) -> usize {
3365 self.m * self.p
3366 }
3367
3368 fn as_any(&self) -> &(dyn std::any::Any + 'static) {
3369 self
3370 }
3371
3372 fn is_implicit(&self) -> bool {
3373 false
3374 }
3375
3376 fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
3377 let dim = self.m * self.p;
3378 assert_eq!(v.len(), dim);
3379 let n = self.design.nrows();
3380 let (m, p) = (self.m, self.p);
3381 let design = self.design.as_standard_layout();
3382 let design_values = design
3383 .as_slice()
3384 .expect("standard-layout multinomial design is contiguous");
3385 let jet = self.jet.as_standard_layout();
3386 let jet_values = jet
3387 .as_slice()
3388 .expect("standard-layout directional Fisher jet is contiguous");
3389 let v = v.as_standard_layout();
3390 let v_values = v
3391 .as_slice()
3392 .expect("standard-layout directional-operator input is contiguous");
3393 let mut out = Array1::<f64>::zeros(dim);
3394 let out_values = out
3395 .as_slice_mut()
3396 .expect("fresh directional-operator output is contiguous");
3397 let mut t = vec![0.0_f64; m];
3398 let mut u = vec![0.0_f64; m];
3399 for row in 0..n {
3400 for b in 0..m {
3402 let base = b * p;
3403 let mut acc = 0.0_f64;
3404 for i in 0..p {
3405 acc += design_values[row * p + i] * v_values[base + i];
3406 }
3407 t[b] = acc;
3408 }
3409 for a in 0..m {
3411 let mut acc = 0.0_f64;
3412 for b in 0..m {
3413 acc += jet_values[(row * m + a) * m + b] * t[b];
3414 }
3415 u[a] = acc;
3416 }
3417 for a in 0..m {
3419 let ua = u[a];
3420 if ua == 0.0 {
3421 continue;
3422 }
3423 let base = a * p;
3424 for i in 0..p {
3425 out_values[base + i] += ua * design_values[row * p + i];
3426 }
3427 }
3428 }
3429 out
3430 }
3431
3432 fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
3433 let dim = self.m * self.p;
3434 assert_eq!(factor.nrows(), dim);
3435 let projected = self.projected_design_by_class(factor);
3444 let weighted = self.apply_jet_to_projected_design(projected.as_ref());
3445 fast_atb(projected.as_ref(), &weighted)
3446 }
3447
3448 fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
3449 let dim = self.m * self.p;
3456 assert_eq!(factor.nrows(), dim);
3457 let projected = self.projected_design_by_class(factor);
3458 let projected_values = projected
3459 .as_slice()
3460 .expect("class-projected design is standard-layout");
3461 let jet_values = self
3462 .jet
3463 .as_slice()
3464 .expect("directional Fisher jet is standard-layout");
3465 let n = self.design.nrows();
3466 let rank = factor.ncols();
3467 let mut trace = 0.0_f64;
3468 for row in 0..n {
3469 for class in 0..self.m {
3470 let left = (class * n + row) * rank;
3471 for source_class in 0..self.m {
3472 let right = (source_class * n + row) * rank;
3473 let mut dot = 0.0_f64;
3474 for column in 0..rank {
3475 dot += projected_values[left + column] * projected_values[right + column];
3476 }
3477 trace += jet_values[(row * self.m + class) * self.m + source_class] * dot;
3478 }
3479 }
3480 }
3481 trace
3482 }
3483
3484 fn to_dense(&self) -> Array2<f64> {
3485 let dim = self.m * self.p;
3487 let design = self.design.view();
3488 let n = design.nrows();
3489 let (m, p) = (self.m, self.p);
3490 let mut out = Array2::<f64>::zeros((dim, dim));
3491 for row in 0..n {
3492 for a in 0..m {
3493 for b in 0..m {
3494 let jab = self.jet[[row, a, b]];
3495 if jab == 0.0 {
3496 continue;
3497 }
3498 let ra = a * p;
3499 let rb = b * p;
3500 for i in 0..p {
3501 let xi = design[[row, i]];
3502 if xi == 0.0 {
3503 continue;
3504 }
3505 let scaled = jab * xi;
3506 for j in 0..p {
3507 out[[ra + i, rb + j]] += scaled * design[[row, j]];
3508 }
3509 }
3510 }
3511 }
3512 }
3513 out
3514 }
3515}
3516
3517#[cfg(test)]
3518mod tests {
3519 use super::*;
3534 use gam_problem::DenseMatrixHyperOperator;
3535 use ndarray::array;
3536
3537 mod jet_single_source_932 {
3550 use super::*;
3551 use gam_math::jet_tower::{
3552 program_fourth_contracted, program_row_kernel, program_third_contracted,
3553 };
3554 use std::sync::Arc;
3555
3556 fn single_row_family(obs: usize, w: f64, k: usize) -> MultinomialFamily {
3562 let mut y = Array2::<f64>::zeros((1, k));
3563 y[[0, obs]] = 1.0;
3564 let design = Arc::new(array![[1.0_f64]]);
3565 MultinomialFamily::new(y, array![w], k, design, Arc::new(Vec::new()))
3566 .expect("single-row multinomial family")
3567 }
3568
3569 fn single_row_family_response(response: &[f64], w: f64) -> MultinomialFamily {
3570 let y = Array2::from_shape_vec((1, response.len()), response.to_vec())
3571 .expect("single-row simplex response");
3572 MultinomialFamily::new(
3573 y,
3574 array![w],
3575 response.len(),
3576 Arc::new(array![[1.0_f64]]),
3577 Arc::new(Vec::new()),
3578 )
3579 .expect("single-row multinomial family with simplex response")
3580 }
3581
3582 struct Lcg(u64);
3584 impl Lcg {
3585 fn f64(&mut self) -> f64 {
3586 self.0 = self
3587 .0
3588 .wrapping_mul(6364136223846793005)
3589 .wrapping_add(1442695040888963407);
3590 ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
3591 }
3592 fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
3593 lo + (hi - lo) * self.f64()
3594 }
3595 }
3596
3597 const JET_TOL: f64 = 1e-9;
3598
3599 fn close(a: f64, b: f64, tol: f64, label: &str) {
3600 let band = tol + tol * a.abs().max(b.abs());
3601 assert!(
3602 (a - b).abs() <= band,
3603 "{label}: {a:+.15e} vs {b:+.15e} (|Δ|={:.3e} band {band:.3e})",
3604 (a - b).abs()
3605 );
3606 }
3607
3608 fn active_probs<const M: usize>(
3611 family: &MultinomialFamily,
3612 eta: &[f64; M],
3613 ) -> ndarray::Array2<f64> {
3614 let eta2 = Array2::<f64>::from_shape_vec((1, M), eta.to_vec()).expect("eta (1,M)");
3615 family.row_probabilities(eta2.view())
3616 }
3617
3618 fn prod_third<const M: usize>(
3621 family: &MultinomialFamily,
3622 eta: &[f64; M],
3623 dir: &[f64; M],
3624 ) -> [[f64; M]; M] {
3625 let probs = active_probs(family, eta);
3626 let d = Array1::from(dir.to_vec());
3627 let j = family.directional_fisher_jet_rows(probs.view(), &d);
3628 std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
3629 }
3630
3631 fn prod_fourth<const M: usize>(
3634 family: &MultinomialFamily,
3635 eta: &[f64; M],
3636 u: &[f64; M],
3637 v: &[f64; M],
3638 ) -> [[f64; M]; M] {
3639 let probs = active_probs(family, eta);
3640 let ua = Array1::from(u.to_vec());
3641 let va = Array1::from(v.to_vec());
3642 let j = family.second_directional_fisher_jet_rows(probs.view(), &ua, &va);
3643 std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
3644 }
3645
3646 fn prod_hessian<const M: usize>(
3649 family: &MultinomialFamily,
3650 eta: &[f64; M],
3651 ) -> [[f64; M]; M] {
3652 let probs = active_probs(family, eta);
3653 let mut h = [[0.0_f64; M]; M];
3654 for col in 0..M {
3655 let mut e = Array1::<f64>::zeros(M);
3656 e[col] = 1.0;
3657 let mut out = Array1::<f64>::zeros(M);
3658 family
3659 .hessian_matvec_into_with_probs(probs.view(), &e, &mut out)
3660 .expect("prod hessian matvec");
3661 for row in 0..M {
3662 h[row][col] = out[row];
3663 }
3664 }
3665 h
3666 }
3667
3668 fn run_parity<const M: usize>(seed: u64) {
3669 let mut rng = Lcg(seed);
3670 for trial in 0..24 {
3671 let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.0, 2.0));
3672 let obs = trial % (M + 1);
3673 let w = rng.uniform(0.25, 2.5);
3674 let family = single_row_family(obs, w, M + 1);
3675 let mut response = vec![0.0; M + 1];
3676 response[obs] = 1.0;
3677 let prog =
3678 crate::multinomial_reml::MultinomialLogitRowProgram::new(&eta, &response, w)
3679 .expect("valid multinomial row program");
3680
3681 let (jet_v, jet_g, jet_h) =
3683 program_row_kernel::<M, _>(&prog, 0).expect("jet row kernel");
3684
3685 let probs = active_probs(&family, &eta);
3688 let eta_matrix = Array2::from_shape_vec((1, M), eta.to_vec()).expect("eta matrix");
3689 let (log_lik, grad_ll) = family
3690 .joint_loglik_and_gradient_from_probs(eta_matrix.view(), probs.view())
3691 .expect("valid frozen multinomial row");
3692 close(
3693 jet_v,
3694 -log_lik,
3695 JET_TOL,
3696 &format!("M={M} trial {trial} value"),
3697 );
3698 for a in 0..M {
3699 close(
3700 jet_g[a],
3701 -grad_ll[a],
3702 JET_TOL,
3703 &format!("M={M} trial {trial} grad[{a}]"),
3704 );
3705 }
3706
3707 let prod_h = prod_hessian(&family, &eta);
3709 for a in 0..M {
3710 for b in 0..M {
3711 close(
3712 jet_h[a][b],
3713 prod_h[a][b],
3714 JET_TOL,
3715 &format!("M={M} trial {trial} H[{a}][{b}]"),
3716 );
3717 }
3718 }
3719
3720 let dir: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
3722 let u: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
3723 let jet_third = program_third_contracted(&prog, 0, &dir).expect("jet third");
3724 let prod_t3 = prod_third(&family, &eta, &dir);
3725 let jet_fourth = program_fourth_contracted(&prog, 0, &u, &dir).expect("jet fourth");
3726 let prod_t4 = prod_fourth(&family, &eta, &u, &dir);
3727 for a in 0..M {
3728 for b in 0..M {
3729 close(
3730 jet_third[a][b],
3731 prod_t3[a][b],
3732 JET_TOL,
3733 &format!("M={M} trial {trial} third[{a}][{b}]"),
3734 );
3735 close(
3736 jet_fourth[a][b],
3737 prod_t4[a][b],
3738 JET_TOL,
3739 &format!("M={M} trial {trial} fourth[{a}][{b}]"),
3740 );
3741 }
3742 }
3743
3744 let h_fd = 1e-4;
3747 let eta_p: [f64; M] = std::array::from_fn(|a| eta[a] + h_fd * dir[a]);
3748 let eta_m: [f64; M] = std::array::from_fn(|a| eta[a] - h_fd * dir[a]);
3749 let hp = prod_hessian(&family, &eta_p);
3750 let hm = prod_hessian(&family, &eta_m);
3751 for a in 0..M {
3752 for b in 0..M {
3753 let fd = (hp[a][b] - hm[a][b]) / (2.0 * h_fd);
3754 close(
3755 prod_t3[a][b],
3756 fd,
3757 1e-6,
3758 &format!("M={M} trial {trial} FD third[{a}][{b}]"),
3759 );
3760 }
3761 }
3762 let t3_up = prod_third(&family, &eta_p_along(&eta, &u, h_fd), &dir);
3765 let t3_um = prod_third(&family, &eta_m_along(&eta, &u, h_fd), &dir);
3766 for a in 0..M {
3767 for b in 0..M {
3768 let fd = (t3_up[a][b] - t3_um[a][b]) / (2.0 * h_fd);
3769 close(
3770 prod_t4[a][b],
3771 fd,
3772 1e-6,
3773 &format!("M={M} trial {trial} FD fourth[{a}][{b}]"),
3774 );
3775 }
3776 }
3777 }
3778 }
3779
3780 fn eta_p_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3781 std::array::from_fn(|a| eta[a] + h * u[a])
3782 }
3783 fn eta_m_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3784 std::array::from_fn(|a| eta[a] - h * u[a])
3785 }
3786
3787 #[test]
3792 fn multinomial_live_tower_matches_jet_and_fd() {
3793 run_parity::<2>(0x9322_2020_0710_face);
3794 run_parity::<3>(0x0bad_c0de_0710_2020);
3795 }
3796
3797 #[test]
3803 fn multinomial_extreme_tails_share_one_stable_row_program_932() {
3804 const M: usize = 3;
3805 let cases = [
3806 ([1_000.0, -1_000.0, -750.0], [0.0, 0.0, 0.0, 1.0], 1.25),
3807 ([-1_000.0, -900.0, -800.0], [0.0, 0.0, 1.0, 0.0], 0.75),
3808 ([1_000.0, 1_000.0, -1_000.0], [0.2, 0.3, 0.1, 0.4], 2.0),
3809 ([f64::MAX, -f64::MAX, 0.0], [1.0, 0.0, 0.0, 0.0], 1.0),
3810 ([f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0, 1.0], 0.0),
3811 ];
3812 let direction = [0.7, -0.4, 1.1];
3813 let direction_u = [-0.3, 0.9, 0.2];
3814
3815 for (case, (eta, response, weight)) in cases.into_iter().enumerate() {
3816 let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3817 .expect("valid extreme-tail row program");
3818 let (canonical_value, canonical_gradient, canonical_hessian) =
3819 program_row_kernel::<3, _>(&program, 0).expect("canonical extreme-tail V/G/H");
3820 let canonical_third = program_third_contracted(&program, 0, &direction)
3821 .expect("canonical extreme-tail third");
3822 let canonical_fourth =
3823 program_fourth_contracted(&program, 0, &direction_u, &direction)
3824 .expect("canonical extreme-tail fourth");
3825
3826 assert!(canonical_value.is_finite(), "case {case} value");
3827 assert!(
3828 canonical_gradient.iter().all(|value| value.is_finite()),
3829 "case {case} gradient"
3830 );
3831 assert!(
3832 canonical_hessian
3833 .iter()
3834 .flatten()
3835 .all(|value| value.is_finite()),
3836 "case {case} Hessian"
3837 );
3838 assert!(
3839 canonical_third
3840 .iter()
3841 .flatten()
3842 .all(|value| value.is_finite()),
3843 "case {case} third"
3844 );
3845 assert!(
3846 canonical_fourth
3847 .iter()
3848 .flatten()
3849 .all(|value| value.is_finite()),
3850 "case {case} fourth"
3851 );
3852
3853 let family = single_row_family_response(&response, weight);
3854 let eta_matrix =
3855 Array2::from_shape_vec((1, M), eta.to_vec()).expect("tail eta matrix");
3856 let response_matrix = Array2::from_shape_vec((1, M + 1), response.to_vec())
3857 .expect("tail response matrix");
3858 let (live_log_likelihood, live_gradient, live_hessian) = family
3859 .likelihood
3860 .value_gradient_hessian(eta_matrix.view(), response_matrix.view())
3861 .expect("valid multinomial tail row");
3862 close(
3863 canonical_value,
3864 -live_log_likelihood,
3865 1.0e-12,
3866 &format!("tail case {case} value"),
3867 );
3868 for row in 0..M {
3869 close(
3870 canonical_gradient[row],
3871 -live_gradient[[0, row]],
3872 1.0e-12,
3873 &format!("tail case {case} gradient[{row}]"),
3874 );
3875 for column in 0..M {
3876 close(
3877 canonical_hessian[row][column],
3878 live_hessian[[0, row, column]],
3879 1.0e-12,
3880 &format!("tail case {case} Hessian[{row}][{column}]"),
3881 );
3882 }
3883 }
3884
3885 let live_third = prod_third(&family, &eta, &direction);
3886 let live_fourth = prod_fourth(&family, &eta, &direction_u, &direction);
3887 for row in 0..M {
3888 for column in 0..M {
3889 close(
3890 canonical_third[row][column],
3891 live_third[row][column],
3892 1.0e-12,
3893 &format!("tail case {case} third[{row}][{column}]"),
3894 );
3895 close(
3896 canonical_fourth[row][column],
3897 live_fourth[row][column],
3898 1.0e-12,
3899 &format!("tail case {case} fourth[{row}][{column}]"),
3900 );
3901 }
3902 }
3903 }
3904 }
3905
3906 #[test]
3917 fn multinomial_m32_production_directional_routes_match_canonical_jet_932() {
3918 const REGRESSION_STACK_BYTES: usize = 1024 * 1024;
3919 let worker = std::thread::Builder::new()
3920 .name("multinomial-m32-canonical-stack-bound".to_string())
3921 .stack_size(REGRESSION_STACK_BYTES)
3922 .spawn(|| {
3923 const M: usize = 32;
3924 assert_eq!(
3925 M * std::mem::size_of::<gam_math::jet_scalar::TwoSeed<M>>(),
3926 1_082_368,
3927 "M=32 canonical fourth-order seed footprint changed"
3928 );
3929 let first_schedule = fisher_output_schedule::<OneSeed<0>>(M);
3930 let expected_first = if AVX2_WITHOUT_AVX512 {
3931 FisherOutputSchedule::ContiguousFull
3932 } else {
3933 FisherOutputSchedule::SymmetricTriangle
3934 };
3935 assert!(
3936 first_schedule == expected_first,
3937 "M=32 first-directional Fisher schedule does not match the target ISA"
3938 );
3939 assert!(
3940 fisher_output_schedule::<TwoSeed<0>>(M)
3941 == FisherOutputSchedule::SymmetricTriangle,
3942 "M=32 second-directional Fisher schedule must retain symmetric output"
3943 );
3944
3945 for trial in 0..4 {
3946 let eta: [f64; M] = std::array::from_fn(|axis| {
3947 0.9 * ((axis * 7 + trial * 3 + 1) as f64 * 0.17).sin()
3948 - 0.35 * ((axis + trial + 2) as f64 * 0.11).cos()
3949 });
3950 let direction: [f64; M] = std::array::from_fn(|axis| {
3951 0.7 * ((axis * 5 + trial + 3) as f64 * 0.13).cos()
3952 - 0.2 * ((axis + 2 * trial + 1) as f64 * 0.19).sin()
3953 });
3954 let direction_u: [f64; M] = std::array::from_fn(|axis| {
3955 -0.6 * ((axis * 3 + trial + 4) as f64 * 0.09).sin()
3956 + 0.25 * ((axis + trial + 5) as f64 * 0.23).cos()
3957 });
3958 let observed_class = if trial % 2 == 0 { trial } else { M };
3959 let weight = 0.8 + 0.3 * trial as f64;
3960 let family = single_row_family(observed_class, weight, M + 1);
3961 let mut response = vec![0.0; M + 1];
3962 response[observed_class] = 1.0;
3963 let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3964 .expect("valid M=32 multinomial row program");
3965
3966 let production_first = prod_third(&family, &eta, &direction);
3967 let canonical_first = program_third_contracted(&program, 0, &direction)
3968 .expect("canonical M=32 first-directional Fisher contraction");
3969 let production_second =
3970 prod_fourth(&family, &eta, &direction_u, &direction);
3971 let canonical_second =
3972 program_fourth_contracted(&program, 0, &direction_u, &direction)
3973 .expect("canonical M=32 second-directional Fisher contraction");
3974
3975 for row in 0..M {
3976 for column in 0..M {
3977 close(
3978 production_first[row][column],
3979 canonical_first[row][column],
3980 JET_TOL,
3981 &format!(
3982 "M=32 trial {trial} first-directional[{row}][{column}]"
3983 ),
3984 );
3985 close(
3986 production_second[row][column],
3987 canonical_second[row][column],
3988 JET_TOL,
3989 &format!(
3990 "M=32 trial {trial} second-directional[{row}][{column}]"
3991 ),
3992 );
3993 }
3994 }
3995 }
3996 })
3997 .expect("spawn bounded-stack M=32 parity worker");
3998 if let Err(payload) = worker.join() {
3999 std::panic::resume_unwind(payload);
4000 }
4001 }
4002
4003 struct FirstFisherBuffers {
4004 normalized: Vec<f64>,
4005 derivative: Vec<f64>,
4006 fisher: Vec<f64>,
4007 }
4008
4009 impl FirstFisherBuffers {
4010 fn new(m: usize) -> Self {
4011 Self {
4012 normalized: vec![0.0; m],
4013 derivative: vec![0.0; m],
4014 fisher: vec![0.0; m * m],
4015 }
4016 }
4017 }
4018
4019 struct SecondFisherBuffers {
4020 normalized: Vec<[f64; 3]>,
4021 derivative_u: Vec<f64>,
4022 derivative_v: Vec<f64>,
4023 mixed_derivative: Vec<f64>,
4024 fisher: Vec<f64>,
4025 }
4026
4027 impl SecondFisherBuffers {
4028 fn new(m: usize) -> Self {
4029 Self {
4030 normalized: vec![[0.0; 3]; m],
4031 derivative_u: vec![0.0; m],
4032 derivative_v: vec![0.0; m],
4033 mixed_derivative: vec![0.0; m],
4034 fisher: vec![0.0; m * m],
4035 }
4036 }
4037 }
4038
4039 #[inline(never)]
4040 fn compiled_first_fisher<const M: usize>(
4041 probability: &[f64; M],
4042 direction: &[f64; M],
4043 weight: f64,
4044 buffers: &mut FirstFisherBuffers,
4045 ) {
4046 softmax_fisher_perturbation::<OneSeed<0>>(
4047 M,
4048 weight,
4049 |axis| probability[axis],
4050 |axis| direction[axis],
4051 |_| 0.0,
4052 &mut buffers.normalized,
4053 &mut buffers.fisher,
4054 );
4055 }
4056
4057 #[inline(never)]
4063 fn strongest_hand_first_fisher<const M: usize>(
4064 probability: &[f64; M],
4065 direction: &[f64; M],
4066 weight: f64,
4067 buffers: &mut FirstFisherBuffers,
4068 ) {
4069 let mut mean = 0.0;
4070 for axis in 0..M {
4071 mean += probability[axis] * direction[axis];
4072 }
4073 for axis in 0..M {
4074 buffers.derivative[axis] = weight * probability[axis] * (direction[axis] - mean);
4075 }
4076 if fisher_output_schedule::<OneSeed<0>>(M) == FisherOutputSchedule::ContiguousFull {
4077 for row in 0..M {
4078 let probability_row = probability[row];
4079 let derivative_row = buffers.derivative[row];
4080 for column in 0..M {
4081 buffers.fisher[row * M + column] = -(derivative_row * probability[column]
4082 + probability_row * buffers.derivative[column]);
4083 }
4084 buffers.fisher[row * M + row] += derivative_row;
4085 }
4086 return;
4087 }
4088 for row in 0..M {
4089 let probability_row = probability[row];
4090 let derivative_row = buffers.derivative[row];
4091 buffers.fisher[row * M + row] =
4092 derivative_row - 2.0 * derivative_row * probability_row;
4093 for column in (row + 1)..M {
4094 let coefficient = -(derivative_row * probability[column]
4095 + probability_row * buffers.derivative[column]);
4096 buffers.fisher[row * M + column] = coefficient;
4097 buffers.fisher[column * M + row] = coefficient;
4098 }
4099 }
4100 }
4101
4102 #[inline(never)]
4103 fn compiled_second_fisher<const M: usize>(
4104 probability: &[f64; M],
4105 direction_u: &[f64; M],
4106 direction_v: &[f64; M],
4107 weight: f64,
4108 buffers: &mut SecondFisherBuffers,
4109 ) {
4110 softmax_fisher_perturbation::<TwoSeed<0>>(
4111 M,
4112 weight,
4113 |axis| probability[axis],
4114 |axis| direction_u[axis],
4115 |axis| direction_v[axis],
4116 &mut buffers.normalized,
4117 &mut buffers.fisher,
4118 );
4119 }
4120
4121 #[inline(never)]
4125 fn strongest_hand_second_fisher<const M: usize>(
4126 probability: &[f64; M],
4127 direction_u: &[f64; M],
4128 direction_v: &[f64; M],
4129 weight: f64,
4130 buffers: &mut SecondFisherBuffers,
4131 ) {
4132 let mut mean_u = 0.0;
4133 let mut mean_v = 0.0;
4134 for axis in 0..M {
4135 mean_u += probability[axis] * direction_u[axis];
4136 mean_v += probability[axis] * direction_v[axis];
4137 }
4138 for axis in 0..M {
4139 buffers.derivative_u[axis] = probability[axis] * (direction_u[axis] - mean_u);
4140 buffers.derivative_v[axis] = probability[axis] * (direction_v[axis] - mean_v);
4141 }
4142 let mut mixed_mean = 0.0;
4143 for axis in 0..M {
4144 mixed_mean += buffers.derivative_v[axis] * direction_u[axis];
4145 }
4146 for axis in 0..M {
4147 buffers.mixed_derivative[axis] = buffers.derivative_v[axis]
4148 * (direction_u[axis] - mean_u)
4149 - probability[axis] * mixed_mean;
4150 }
4151 for row in 0..M {
4152 let probability_row = probability[row];
4153 let derivative_u_row = buffers.derivative_u[row];
4154 let derivative_v_row = buffers.derivative_v[row];
4155 let mixed_row = buffers.mixed_derivative[row];
4156 buffers.fisher[row * M + row] = weight
4157 * (mixed_row
4158 - 2.0 * mixed_row * probability_row
4159 - 2.0 * derivative_u_row * derivative_v_row);
4160 for column in (row + 1)..M {
4161 let coefficient = weight
4162 * (-(mixed_row * probability[column]
4163 + derivative_u_row * buffers.derivative_v[column]
4164 + derivative_v_row * buffers.derivative_u[column]
4165 + probability_row * buffers.mixed_derivative[column]));
4166 buffers.fisher[row * M + column] = coefficient;
4167 buffers.fisher[column * M + row] = coefficient;
4168 }
4169 }
4170 }
4171
4172 fn fisher_checksum(values: &[f64]) -> f64 {
4173 values
4174 .iter()
4175 .enumerate()
4176 .map(|(index, value)| value * (1 + index % 17) as f64)
4177 .sum()
4178 }
4179
4180 #[test]
4203 fn release_measure_multinomial_fisher_vs_strongest_hand_932() {
4204 use gam_math::paired_timing::paired_interleaved;
4205
4206 fn measure<const M: usize>(seed: u64, repetitions: usize) {
4207 const ROWS: usize = 256;
4208 let mut rng = Lcg(seed);
4209 let probability: Vec<[f64; M]> = (0..ROWS)
4210 .map(|_| {
4211 let raw: [f64; M] = std::array::from_fn(|_| rng.uniform(0.1, 1.0));
4212 let scale = rng.uniform(0.35, 0.95) / raw.iter().sum::<f64>();
4213 raw.map(|mass| mass * scale)
4214 })
4215 .collect();
4216 let direction_u: Vec<[f64; M]> = (0..ROWS)
4217 .map(|_| std::array::from_fn(|_| rng.uniform(-0.8, 0.8)))
4218 .collect();
4219 let direction_v: Vec<[f64; M]> = (0..ROWS)
4220 .map(|_| std::array::from_fn(|_| rng.uniform(-0.8, 0.8)))
4221 .collect();
4222 let weights: Vec<f64> = (0..ROWS).map(|_| rng.uniform(0.25, 2.5)).collect();
4223
4224 let mut compiled_first = FirstFisherBuffers::new(M);
4225 let mut hand_first = FirstFisherBuffers::new(M);
4226 let mut compiled_second = SecondFisherBuffers::new(M);
4227 let mut hand_second = SecondFisherBuffers::new(M);
4228
4229 for row in 0..ROWS {
4230 compiled_first_fisher(
4231 &probability[row],
4232 &direction_u[row],
4233 weights[row],
4234 &mut compiled_first,
4235 );
4236 strongest_hand_first_fisher(
4237 &probability[row],
4238 &direction_u[row],
4239 weights[row],
4240 &mut hand_first,
4241 );
4242 compiled_second_fisher(
4243 &probability[row],
4244 &direction_u[row],
4245 &direction_v[row],
4246 weights[row],
4247 &mut compiled_second,
4248 );
4249 strongest_hand_second_fisher(
4250 &probability[row],
4251 &direction_u[row],
4252 &direction_v[row],
4253 weights[row],
4254 &mut hand_second,
4255 );
4256 for index in 0..M * M {
4257 close(
4258 compiled_first.fisher[index],
4259 hand_first.fisher[index],
4260 3.0e-15,
4261 &format!("M={M} first strongest-hand parity[{row},{index}]"),
4262 );
4263 close(
4264 compiled_second.fisher[index],
4265 hand_second.fisher[index],
4266 5.0e-15,
4267 &format!("M={M} second strongest-hand parity[{row},{index}]"),
4268 );
4269 }
4270 }
4271
4272 if cfg!(debug_assertions) {
4278 return;
4279 }
4280
4281 let compiled_first_sweep = |nudge: f64, buffers: &mut FirstFisherBuffers| {
4282 let mut checksum = nudge;
4283 for row in 0..ROWS {
4284 compiled_first_fisher(
4285 &probability[row],
4286 &direction_u[row],
4287 weights[row] + checksum * 1.0e-18,
4288 buffers,
4289 );
4290 checksum += fisher_checksum(&buffers.fisher);
4291 }
4292 checksum
4293 };
4294 let hand_first_sweep = |nudge: f64, buffers: &mut FirstFisherBuffers| {
4295 let mut checksum = nudge;
4296 for row in 0..ROWS {
4297 strongest_hand_first_fisher(
4298 &probability[row],
4299 &direction_u[row],
4300 weights[row] + checksum * 1.0e-18,
4301 buffers,
4302 );
4303 checksum += fisher_checksum(&buffers.fisher);
4304 }
4305 checksum
4306 };
4307 let compiled_second_sweep = |nudge: f64, buffers: &mut SecondFisherBuffers| {
4308 let mut checksum = nudge;
4309 for row in 0..ROWS {
4310 compiled_second_fisher(
4311 &probability[row],
4312 &direction_u[row],
4313 &direction_v[row],
4314 weights[row] + checksum * 1.0e-18,
4315 buffers,
4316 );
4317 checksum += fisher_checksum(&buffers.fisher);
4318 }
4319 checksum
4320 };
4321 let hand_second_sweep = |nudge: f64, buffers: &mut SecondFisherBuffers| {
4322 let mut checksum = nudge;
4323 for row in 0..ROWS {
4324 strongest_hand_second_fisher(
4325 &probability[row],
4326 &direction_u[row],
4327 &direction_v[row],
4328 weights[row] + checksum * 1.0e-18,
4329 buffers,
4330 );
4331 checksum += fisher_checksum(&buffers.fisher);
4332 }
4333 checksum
4334 };
4335
4336 let sweeps = (repetitions / 2).max(1);
4344 let first = paired_interleaved(
4345 15,
4346 sweeps,
4347 seed ^ 0x1111_1111,
4348 |nudge| compiled_first_sweep(nudge, &mut compiled_first),
4349 |nudge| hand_first_sweep(nudge, &mut hand_first),
4350 );
4351 let second = paired_interleaved(
4352 15,
4353 sweeps,
4354 seed ^ 0x2222_2222,
4355 |nudge| compiled_second_sweep(nudge, &mut compiled_second),
4356 |nudge| hand_second_sweep(nudge, &mut hand_second),
4357 );
4358 eprintln!(
4364 "MULTINOMIAL-HAND-932 M={M} rows={ROWS} first {}",
4365 first.summary("compiled", "strongest_hand"),
4366 );
4367 eprintln!(
4368 "MULTINOMIAL-HAND-932 M={M} rows={ROWS} second {}",
4369 second.summary("compiled", "strongest_hand"),
4370 );
4371 assert!(
4375 first.median_ratio() > 1.0 && first.wins_fraction() >= 0.75,
4376 "M={M} first canonical lowering must beat strongest hand: {}",
4377 first.summary("compiled", "strongest_hand"),
4378 );
4379 assert!(
4380 second.median_ratio() > 1.0 && second.wins_fraction() >= 0.75,
4381 "M={M} second canonical lowering must beat strongest hand: {}",
4382 second.summary("compiled", "strongest_hand"),
4383 );
4384 }
4385
4386 measure::<2>(0x9322_0002_face_cafe, 2_000);
4387 measure::<3>(0x9323_0003_face_cafe, 2_000);
4388 measure::<8>(0x9328_0008_face_cafe, 600);
4389 measure::<32>(0x9332_0032_face_cafe, 80);
4390 measure::<64>(0x9364_0064_face_cafe, 24);
4391 }
4392
4393 #[test]
4411 fn release_measure_multinomial_specialized_vs_generic_tower_932() {
4412 fn measure<const M: usize>(seed: u64) {
4413 use std::time::Instant;
4414
4415 const ROWS: usize = 512;
4416 let mut rng = Lcg(seed);
4417 let mut etas: Vec<[f64; M]> = Vec::with_capacity(ROWS);
4418 let mut responses: Vec<Vec<f64>> = Vec::with_capacity(ROWS);
4419 let mut weights: Vec<f64> = Vec::with_capacity(ROWS);
4420 for row in 0..ROWS {
4421 let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.5, 2.5));
4422 let observed = row % (M + 1);
4423 let mut response = vec![0.0; M + 1];
4424 response[observed] = 1.0;
4425 etas.push(eta);
4426 responses.push(response);
4427 weights.push(rng.uniform(0.25, 2.5));
4428 }
4429 let programs: Vec<MultinomialLogitRowProgram> = (0..ROWS)
4430 .map(|row| {
4431 MultinomialLogitRowProgram::new(&etas[row], &responses[row], weights[row])
4432 .expect("valid multinomial batch row")
4433 })
4434 .collect();
4435
4436 let mut probabilities = vec![0.0_f64; M + 1];
4437 let mut gradient = vec![0.0_f64; M];
4438 let mut hessian = vec![0.0_f64; M * M];
4439
4440 for program in &programs {
4444 let (tower_value, tower_gradient, tower_hessian) =
4445 program_row_kernel::<M, _>(program, 0).expect("tower warm kernel");
4446 let production_value = program.value_gradient_hessian_into(
4447 &mut probabilities,
4448 &mut gradient,
4449 &mut hessian,
4450 );
4451 close(
4452 tower_value,
4453 production_value,
4454 JET_TOL,
4455 &format!("M={M} release-measure value parity"),
4456 );
4457 for a in 0..M {
4458 close(
4459 tower_gradient[a],
4460 gradient[a],
4461 JET_TOL,
4462 &format!("M={M} release-measure gradient[{a}] parity"),
4463 );
4464 for b in 0..M {
4465 close(
4466 tower_hessian[a][b],
4467 hessian[a * M + b],
4468 JET_TOL,
4469 &format!("M={M} release-measure hessian[{a}][{b}] parity"),
4470 );
4471 }
4472 }
4473 }
4474
4475 let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
4476 let mut best = f64::INFINITY;
4477 for _ in 0..5 {
4478 let started = Instant::now();
4479 let checksum = sweep();
4480 assert!(
4481 checksum.is_finite(),
4482 "multinomial release-measure checksum must stay finite"
4483 );
4484 best = best.min(started.elapsed().as_secs_f64());
4485 }
4486 best
4487 };
4488
4489 let mut production_sweep = || {
4490 let mut checksum = 0.0_f64;
4491 for program in &programs {
4492 let value = program.value_gradient_hessian_into(
4493 &mut probabilities,
4494 &mut gradient,
4495 &mut hessian,
4496 );
4497 checksum += value + gradient[0] + hessian[0];
4498 }
4499 checksum
4500 };
4501 let production_secs = best_secs(&mut production_sweep);
4502
4503 let mut tower_sweep = || {
4504 let mut checksum = 0.0_f64;
4505 for program in &programs {
4506 let (value, tower_gradient, tower_hessian) =
4507 program_row_kernel::<M, _>(program, 0).expect("tower kernel");
4508 checksum += value + tower_gradient[0] + tower_hessian[0][0];
4509 }
4510 checksum
4511 };
4512 let tower_secs = best_secs(&mut tower_sweep);
4513
4514 let production_ns = production_secs * 1e9 / ROWS as f64;
4515 let tower_ns = tower_secs * 1e9 / ROWS as f64;
4516 eprintln!(
4517 "MULTINOMIAL-RELEASE-932 M={M} rows={ROWS} production_ns={production_ns:.3} \
4518 generic_tower_ns={tower_ns:.3} generic_tower_over_production={:.6}",
4519 tower_ns / production_ns,
4520 );
4521 }
4522
4523 measure::<2>(0x9322_2020_0715_face);
4524 measure::<3>(0x0bad_c0de_0715_2020);
4525 measure::<4>(0x5eed_4444_0722_beef);
4526 measure::<8>(0x1234_5678_0715_abcd);
4527 }
4528 }
4529
4530 impl MultinomialFamily {
4531 fn assemble_directional_derivatives(
4537 &self,
4538 eta: ArrayView2<'_, f64>,
4539 directions: &[Array1<f64>],
4540 ) -> Result<Vec<Array2<f64>>, String> {
4541 let probs = self.row_probabilities(eta);
4542 self.assemble_directional_derivatives_from_probs(probs.view(), directions)
4543 }
4544
4545 fn assemble_directional_derivatives_from_probs(
4560 &self,
4561 probs_full: ArrayView2<'_, f64>,
4562 directions: &[Array1<f64>],
4563 ) -> Result<Vec<Array2<f64>>, String> {
4564 use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
4565
4566 let n_dirs = directions.len();
4567 if n_dirs == 0 {
4568 return Ok(Vec::new());
4569 }
4570 let n = self.weights.len();
4571 let p = self.design.ncols();
4572 let m = self.active_classes();
4573 let dim = m * p;
4574 for (idx, direction) in directions.iter().enumerate() {
4575 if direction.len() != dim {
4576 return Err(format!(
4577 "MultinomialFamily batched direction {idx} length {} != (K-1)·P = {dim}",
4578 direction.len()
4579 ));
4580 }
4581 }
4582 let design = self.design.view();
4583 let out: Vec<Array2<f64>> = directions
4590 .par_iter()
4591 .map(|direction| {
4592 let mut mat = vec![0.0_f64; dim * dim];
4593 let mut d_eta = vec![0.0_f64; m];
4594 let mut dp = vec![0.0_f64; m];
4595 for row in 0..n {
4596 let w = self.weights[row];
4597 if w == 0.0 {
4598 continue;
4599 }
4600 let mut s = 0.0_f64;
4601 for a in 0..m {
4602 let base = a * p;
4603 let mut eta_dir = 0.0_f64;
4604 for i in 0..p {
4605 eta_dir += design[[row, i]] * direction[base + i];
4606 }
4607 d_eta[a] = eta_dir;
4608 s += probs_full[[row, a]] * eta_dir;
4609 }
4610 for a in 0..m {
4611 dp[a] = probs_full[[row, a]] * (d_eta[a] - s);
4612 }
4613
4614 for a in 0..m {
4615 let pa = probs_full[[row, a]];
4616 let row_a = a * p;
4617 let jaa = w * (dp[a] - 2.0 * dp[a] * pa);
4618 if jaa != 0.0 {
4619 for i in 0..p {
4620 let xi = design[[row, i]];
4621 if xi == 0.0 {
4622 continue;
4623 }
4624 let scaled = jaa * xi;
4625 let out_row = (row_a + i) * dim;
4626 for j in 0..p {
4627 mat[out_row + row_a + j] += scaled * design[[row, j]];
4628 }
4629 }
4630 }
4631 for b in (a + 1)..m {
4632 let pb = probs_full[[row, b]];
4633 let jab = w * (-(dp[a] * pb + pa * dp[b]));
4634 if jab == 0.0 {
4635 continue;
4636 }
4637 let row_b = b * p;
4638 for i in 0..p {
4639 let xi = design[[row, i]];
4640 if xi == 0.0 {
4641 continue;
4642 }
4643 let scaled = jab * xi;
4644 let out_a = (row_a + i) * dim;
4645 let out_b = (row_b + i) * dim;
4646 for j in 0..p {
4647 let xj = design[[row, j]];
4648 let value = scaled * xj;
4649 mat[out_a + row_b + j] += value;
4650 mat[out_b + row_a + j] += value;
4651 }
4652 }
4653 }
4654 }
4655 }
4656 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
4657 .expect("batched direction derivative buffer is dim·dim");
4658 for i in 0..dim {
4659 for j in (i + 1)..dim {
4660 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
4661 mat[[i, j]] = avg;
4662 mat[[j, i]] = avg;
4663 }
4664 }
4665 mat
4666 })
4667 .collect();
4668 Ok(out)
4669 }
4670
4671 fn assemble_second_directional_derivatives_from_probs(
4685 &self,
4686 probs_full: ArrayView2<'_, f64>,
4687 pairs: &[(Array1<f64>, Array1<f64>)],
4688 ) -> Result<Vec<Array2<f64>>, String> {
4689 use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
4690
4691 let n_pairs = pairs.len();
4692 if n_pairs == 0 {
4693 return Ok(Vec::new());
4694 }
4695 let n = self.weights.len();
4696 let p = self.design.ncols();
4697 let m = self.active_classes();
4698 let dim = m * p;
4699 for (idx, (u, v)) in pairs.iter().enumerate() {
4700 if u.len() != dim || v.len() != dim {
4701 return Err(format!(
4702 "MultinomialFamily batched second-directional pair {idx} lengths {} and {} != (K-1)·P = {dim}",
4703 u.len(),
4704 v.len()
4705 ));
4706 }
4707 }
4708
4709 let design = self.design.view();
4710 let out: Vec<Array2<f64>> = pairs
4718 .par_iter()
4719 .map(|(u, v)| {
4720 let mut mat = vec![0.0_f64; dim * dim];
4721 let mut d_eta_u = vec![0.0_f64; m];
4722 let mut d_eta_v = vec![0.0_f64; m];
4723 let mut dp_u = vec![0.0_f64; m];
4724 let mut dp_v = vec![0.0_f64; m];
4725 let mut ddp = vec![0.0_f64; m];
4726 for row in 0..n {
4727 let w = self.weights[row];
4728 if w == 0.0 {
4729 continue;
4730 }
4731 let mut s_u = 0.0_f64;
4732 let mut s_v = 0.0_f64;
4733 for a in 0..m {
4734 let base = a * p;
4735 let mut eta_u = 0.0_f64;
4736 let mut eta_v = 0.0_f64;
4737 for i in 0..p {
4738 let x = design[[row, i]];
4739 eta_u += x * u[base + i];
4740 eta_v += x * v[base + i];
4741 }
4742 d_eta_u[a] = eta_u;
4743 d_eta_v[a] = eta_v;
4744 s_u += probs_full[[row, a]] * eta_u;
4745 s_v += probs_full[[row, a]] * eta_v;
4746 }
4747
4748 for a in 0..m {
4749 let pa = probs_full[[row, a]];
4750 dp_u[a] = pa * (d_eta_u[a] - s_u);
4751 dp_v[a] = pa * (d_eta_v[a] - s_v);
4752 }
4753
4754 let mut ds_u_dv = 0.0_f64;
4755 for a in 0..m {
4756 ds_u_dv += dp_v[a] * d_eta_u[a];
4757 }
4758 for a in 0..m {
4759 let pa = probs_full[[row, a]];
4760 ddp[a] = dp_v[a] * (d_eta_u[a] - s_u) - pa * ds_u_dv;
4761 }
4762
4763 for a in 0..m {
4764 let pa = probs_full[[row, a]];
4765 let row_a = a * p;
4766 let jaa = w * (ddp[a] - 2.0 * ddp[a] * pa - 2.0 * dp_u[a] * dp_v[a]);
4767 if jaa != 0.0 {
4768 for i in 0..p {
4769 let xi = design[[row, i]];
4770 if xi == 0.0 {
4771 continue;
4772 }
4773 let scaled = jaa * xi;
4774 let out_row = (row_a + i) * dim;
4775 for j in 0..p {
4776 mat[out_row + row_a + j] += scaled * design[[row, j]];
4777 }
4778 }
4779 }
4780
4781 for b in (a + 1)..m {
4782 let pb = probs_full[[row, b]];
4783 let jab = -w
4784 * (ddp[a] * pb
4785 + dp_u[a] * dp_v[b]
4786 + dp_v[a] * dp_u[b]
4787 + pa * ddp[b]);
4788 if jab == 0.0 {
4789 continue;
4790 }
4791 let row_b = b * p;
4792 for i in 0..p {
4793 let xi = design[[row, i]];
4794 if xi == 0.0 {
4795 continue;
4796 }
4797 let scaled = jab * xi;
4798 let out_a = (row_a + i) * dim;
4799 let out_b = (row_b + i) * dim;
4800 for j in 0..p {
4801 let xj = design[[row, j]];
4802 let value = scaled * xj;
4803 mat[out_a + row_b + j] += value;
4804 mat[out_b + row_a + j] += value;
4805 }
4806 }
4807 }
4808 }
4809 }
4810 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
4811 .expect("batched second-directional buffer is dim·dim");
4812 for i in 0..dim {
4813 for j in (i + 1)..dim {
4814 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
4815 mat[[i, j]] = avg;
4816 mat[[j, i]] = avg;
4817 }
4818 }
4819 mat
4820 })
4821 .collect();
4822 Ok(out)
4823 }
4824 }
4825
4826 fn toy_family_with_penalties(
4827 n_obs: usize,
4828 p: usize,
4829 k: usize,
4830 n_penalties: usize,
4831 ) -> MultinomialFamily {
4832 let y = {
4833 let mut y = Array2::<f64>::zeros((n_obs, k));
4834 for i in 0..n_obs {
4835 y[[i, i % k]] = 1.0;
4836 }
4837 y
4838 };
4839 let weights = Array1::<f64>::ones(n_obs);
4840 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4841 ((i + j + 1) as f64).sin()
4842 }));
4843 let penalties = Arc::new(
4844 (0..n_penalties)
4845 .map(|t| {
4846 crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
4847 (p, p),
4848 |(i, j)| {
4849 if i == j && i >= t.min(p.saturating_sub(1)) {
4850 1.0
4851 } else {
4852 0.0
4853 }
4854 },
4855 ))
4856 })
4857 .collect::<Vec<_>>(),
4858 );
4859 MultinomialFamily::new(y, weights, k, design, penalties)
4860 .expect("toy MultinomialFamily must construct")
4861 }
4862
4863 #[test]
4874 fn joint_smoothing_dimension_equals_the_specs_emitted_2612() {
4875 for (k, n_penalties) in [(3usize, 8usize), (3, 1), (2, 8), (4, 3)] {
4876 let family = toy_family_with_penalties(24, k, 5, n_penalties);
4877 let emitted = family
4878 .equivariant_class_penalty_specs()
4879 .expect("equivariant specs")
4880 .len();
4881 assert_eq!(
4882 family.joint_smoothing_dimension(),
4883 emitted,
4884 "K={k}, {n_penalties} penalty components: declared dimension must equal the \
4885 number of joint specs the carrier emits"
4886 );
4887 let pre_1587_product = (k - 1) * n_penalties;
4888 if k > 2 {
4889 assert_ne!(
4890 emitted, pre_1587_product,
4891 "K={k} is exactly where the pre-#1587 product and the real coordinate \
4892 count differ; if they agree here this test has stopped discriminating"
4893 );
4894 }
4895 }
4896 }
4897
4898 fn toy_family(n_obs: usize, p: usize, k: usize) -> MultinomialFamily {
4899 let y = {
4900 let mut y = Array2::<f64>::zeros((n_obs, k));
4901 for i in 0..n_obs {
4902 y[[i, i % k]] = 1.0;
4903 }
4904 y
4905 };
4906 let weights = Array1::<f64>::ones(n_obs);
4907 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4908 ((i + j + 1) as f64).sin()
4909 }));
4910 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
4911 Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
4912 )]);
4913 MultinomialFamily::new(y, weights, k, design, penalties)
4914 .expect("toy MultinomialFamily must construct")
4915 }
4916
4917 #[test]
4928 fn class_blocks_lock_the_raw_coefficient_width_2744() {
4929 let family = toy_family(9, 4, 3);
4930 let specs = family.build_block_specs();
4931 assert_eq!(specs.len(), family.active_classes(), "one block per class");
4932 for spec in &specs {
4933 let callback = spec
4934 .jacobian_callback
4935 .as_ref()
4936 .unwrap_or_else(|| panic!("block '{}' must declare its output channel", spec.name));
4937 assert!(
4938 callback.locks_raw_width_reduction(),
4939 "block '{}' must lock the raw width: the family assembles from its own \
4940 captured design, so a reduced block width desynchronises the flat layout",
4941 spec.name,
4942 );
4943 assert_eq!(
4944 spec.design.ncols(),
4945 family.design.ncols(),
4946 "block '{}' width must be the family's raw P",
4947 spec.name,
4948 );
4949 }
4950 family
4953 .check_spec_coefficient_width(&specs, "raw-width self-check")
4954 .expect("the family's own specs must satisfy its flat-layout guard");
4955 }
4956
4957 #[test]
4958 fn convexity_certificate_tracks_the_complete_multinomial_objective() {
4959 let unbiased = toy_family(8, 3, 4).with_joint_jeffreys_term(false);
4960 assert!(unbiased.exact_newton_joint_hessian_beta_dependent());
4961 assert!(
4962 unbiased.inner_coefficient_objective_is_globally_convex(),
4963 "softmax Fisher curvature varies with beta but remains PSD"
4964 );
4965 assert_eq!(
4966 unbiased.pseudo_logdet_mode(),
4967 PseudoLogdetMode::PositiveDefinite,
4968 "reference coding removes the softmax gauge, so an accepted Laplace mode is SPD"
4969 );
4970
4971 let firth = unbiased.with_joint_jeffreys_term(true);
4972 assert!(
4973 !firth.inner_coefficient_objective_is_globally_convex(),
4974 "the conditioning-gated Jeffreys correction is outside the convexity proof"
4975 );
4976 let anchor = firth
4977 .coefficient_mode_homotopy_member(0.0)
4978 .expect("Jeffreys homotopy anchor")
4979 .expect("armed multinomial supplies a coefficient-mode homotopy");
4980 let midpoint = firth
4981 .coefficient_mode_homotopy_member(0.5)
4982 .expect("Jeffreys homotopy midpoint")
4983 .expect("armed multinomial supplies a coefficient-mode homotopy");
4984 let endpoint = firth
4985 .coefficient_mode_homotopy_member(1.0)
4986 .expect("Jeffreys homotopy endpoint")
4987 .expect("armed multinomial supplies a coefficient-mode homotopy");
4988 assert_eq!(anchor.joint_jeffreys_term_strength(), 0.0);
4989 assert!(
4990 anchor.inner_coefficient_objective_is_globally_convex(),
4991 "the homotopy anchor is exactly the unique unbiased softmax objective"
4992 );
4993 assert_eq!(midpoint.joint_jeffreys_term_strength(), 0.5);
4994 assert_eq!(endpoint.joint_jeffreys_term_strength(), 1.0);
4995 }
4996
4997 #[test]
4998 fn block_specs_have_one_per_active_class_in_order() {
4999 let family = toy_family(8, 3, 4);
5000 let specs = family.build_block_specs();
5001 assert_eq!(specs.len(), 3, "expected K-1 = 3 active blocks for K=4");
5002 for (a, spec) in specs.iter().enumerate() {
5003 assert_eq!(spec.name, format!("class_{a}"));
5004 }
5005 }
5006
5007 #[test]
5008 fn gauge_priority_is_strictly_decreasing_in_class_index() {
5009 let family = toy_family(8, 3, 5);
5010 let specs = family.build_block_specs();
5011 for window in specs.windows(2) {
5012 assert!(
5013 window[0].gauge_priority > window[1].gauge_priority,
5014 "class_{} priority {} must exceed class_{} priority {}",
5015 window[0].name,
5016 window[0].gauge_priority,
5017 window[1].name,
5018 window[1].gauge_priority,
5019 );
5020 }
5021 }
5022
5023 #[test]
5034 fn canonicalisation_keeps_multinomial_blocks_at_raw_width_2744() {
5035 let (n, p, k) = (48, 4, 3);
5036 let mut family = toy_family(n, p, k);
5037 let deficient = {
5040 let mut design = (*family.design).clone();
5041 let combo = &design.column(0).to_owned() * 0.75 + &design.column(1).to_owned() * 0.5;
5042 design.column_mut(p - 1).assign(&combo);
5043 design
5044 };
5045 family.design = Arc::new(deficient);
5046 let specs = family.build_block_specs();
5047
5048 let canonical = gam_identifiability::canonical::canonicalize_for_identifiability(
5049 &specs,
5050 &vec![gam_problem::CoefficientCoordinate::Spanning; specs.len()],
5051 )
5052 .expect("a rank-deficient shared design must canonicalise, not fail closed");
5053
5054 assert!(
5058 !canonical.audit.dropped_columns.is_empty(),
5059 "the fixture must present the audit with a real rank deficiency to attribute; \
5060 it reported none, so the raw-width assertion would be vacuous"
5061 );
5062 for (raw, reduced) in specs.iter().zip(canonical.reduced_specs.iter()) {
5063 assert_eq!(
5064 reduced.design.ncols(),
5065 raw.design.ncols(),
5066 "block '{}' was column-reduced despite locking its raw width",
5067 raw.name,
5068 );
5069 }
5070 family
5071 .check_spec_coefficient_width(&canonical.reduced_specs, "canonicalised specs")
5072 .expect("the canonicalised specs must still match the family's flat layout");
5073 }
5074
5075 #[test]
5076 fn block_specs_share_design_shape_with_family() {
5077 let family = toy_family(8, 3, 4);
5078 let specs = family.build_block_specs();
5079 let (n, p) = (family.design.nrows(), family.design.ncols());
5080 for spec in &specs {
5081 assert_eq!(spec.design.nrows(), n);
5082 assert_eq!(spec.design.ncols(), p);
5083 }
5084 }
5085
5086 #[test]
5087 fn per_term_smoothing_is_carried_by_equivariant_class_penalties() {
5088 let single = toy_family(6, 4, 3);
5089 for spec in &single.build_block_specs() {
5090 assert!(
5091 spec.penalties.is_empty()
5092 && spec.initial_log_lambdas.is_empty()
5093 && spec.nullspace_dims.is_empty(),
5094 "per-class blocks must attach no smooth penalty — the ALR-anchored \
5095 per-block carrier is reference-dependent (#1587); the equivariant \
5096 per-class centered joint family is the sole carrier"
5097 );
5098 }
5099 let joint = single.joint_penalty_specs().expect("joint specs");
5100 assert_eq!(
5101 joint.len(),
5102 3, "one per-class centered penalty per (term, class), reference included"
5104 );
5105
5106 let p = 5;
5107 let k = 4;
5108 let n_terms = 3;
5109 let n_obs = 9;
5110 let y = {
5111 let mut y = Array2::<f64>::zeros((n_obs, k));
5112 for i in 0..n_obs {
5113 y[[i, i % k]] = 1.0;
5114 }
5115 y
5116 };
5117 let weights = Array1::<f64>::ones(n_obs);
5118 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5119 ((i + j + 1) as f64).cos()
5120 }));
5121 let penalties = Arc::new(
5122 (0..n_terms)
5123 .map(|t| {
5124 crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
5125 (p, p),
5126 |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
5127 ))
5128 })
5129 .collect::<Vec<_>>(),
5130 );
5131 let multi = MultinomialFamily::new(y, weights, k, design, penalties)
5132 .expect("multi-term MultinomialFamily must construct");
5133 let specs = multi.build_block_specs();
5134 assert_eq!(specs.len(), k - 1, "one block per active class");
5135 for spec in &specs {
5136 assert!(spec.penalties.is_empty());
5137 assert!(spec.initial_log_lambdas.is_empty());
5138 assert!(spec.nullspace_dims.is_empty());
5139 }
5140 let joint = multi.joint_penalty_specs().expect("joint specs");
5141 assert_eq!(
5142 joint.len(),
5143 n_terms * k,
5144 "K per-class centered penalties per term, term-major"
5145 );
5146 let m = k - 1;
5147 let raw_total = m * p;
5148 for (t_idx, term_specs) in joint.chunks(k).enumerate() {
5149 let mut sum = Array2::<f64>::zeros((raw_total, raw_total));
5152 for (c, spec) in term_specs.iter().enumerate() {
5153 assert_eq!(
5154 spec.label.as_deref(),
5155 Some(format!("multinomial_term_{t_idx}_class_{c}").as_str())
5156 );
5157 assert_eq!(spec.nullspace_dim, raw_total - p);
5159 sum += &spec.matrix;
5160 }
5161 let centered = multi
5162 .centered_joint_penalty_specs()
5163 .expect("centered specs");
5164 let target = ¢ered[t_idx].matrix;
5165 let max_err = sum
5166 .iter()
5167 .zip(target.iter())
5168 .map(|(a, b)| (a - b).abs())
5169 .fold(0.0_f64, f64::max);
5170 assert!(
5171 max_err < 1e-14,
5172 "Σ_c C_cᵀC_c ⊗ S_t must equal M ⊗ S_t (max err {max_err:.2e})"
5173 );
5174 }
5175 }
5176
5177 #[test]
5178 fn block_specs_keep_independent_lambda_per_class_and_term() {
5179 let p = 5;
5180 let k = 4;
5181 let n_terms = 3;
5182 let n_obs = 9;
5183 let y = {
5184 let mut y = Array2::<f64>::zeros((n_obs, k));
5185 for i in 0..n_obs {
5186 y[[i, i % k]] = 1.0;
5187 }
5188 y
5189 };
5190 let weights = Array1::<f64>::ones(n_obs);
5191 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5192 ((i + j + 1) as f64).cos()
5193 }));
5194 let penalties = Arc::new(
5195 (0..n_terms)
5196 .map(|t| {
5197 crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
5198 (p, p),
5199 |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
5200 ))
5201 })
5202 .collect::<Vec<_>>(),
5203 );
5204 let multi = MultinomialFamily::new(y, weights, k, design, penalties)
5205 .expect("multi-term MultinomialFamily must construct");
5206 let specs = multi.build_block_specs();
5207 assert_eq!(specs.len(), k - 1);
5208 let joint = multi.joint_penalty_specs().expect("joint specs");
5212 assert_eq!(joint.len(), n_terms * k);
5213 let labels: Vec<&str> = joint.iter().filter_map(|s| s.label.as_deref()).collect();
5214 assert_eq!(
5215 labels.len(),
5216 n_terms * k,
5217 "every spec carries its own label"
5218 );
5219 let unique: std::collections::HashSet<&str> = labels.iter().copied().collect();
5220 assert_eq!(
5221 unique.len(),
5222 labels.len(),
5223 "distinct labels ⇒ one independent outer λ per (term, class)"
5224 );
5225 for spec in &specs {
5226 assert!(spec.penalties.is_empty());
5227 }
5228 }
5229
5230 #[test]
5231 fn collect_eta_matrix_rejects_wrong_block_count() {
5232 let family = toy_family(4, 2, 3);
5233 let single = vec![ParameterBlockState {
5234 beta: Array1::<f64>::zeros(2),
5235 eta: Array1::<f64>::zeros(4),
5236 }];
5237 assert!(family.collect_eta_matrix(&single).is_err());
5238 }
5239
5240 #[test]
5241 fn evaluate_uniform_eta_zero_matches_uniform_softmax() {
5242 let family = toy_family(5, 2, 3);
5243 let p = family.design.ncols();
5244 let m = family.active_classes();
5245 let n = family.weights.len();
5246 let block_states: Vec<ParameterBlockState> = (0..m)
5247 .map(|_| ParameterBlockState {
5248 beta: Array1::<f64>::zeros(p),
5249 eta: Array1::<f64>::zeros(n),
5250 })
5251 .collect();
5252 let eval = family
5253 .evaluate(&block_states)
5254 .expect("baseline evaluate must succeed at β = 0");
5255 let expected = (n as f64) * (1.0 / (family.total_classes as f64)).ln();
5256 let diff = (eval.log_likelihood - expected).abs();
5257 assert!(
5258 diff < 1.0e-10,
5259 "baseline log-lik {} != {}",
5260 eval.log_likelihood,
5261 expected,
5262 );
5263 assert_eq!(eval.blockworking_sets.len(), m);
5264 }
5265
5266 #[test]
5267 fn directional_fisher_jet_along_zero_vanishes() {
5268 let family = toy_family(4, 2, 3);
5269 let p = family.design.ncols();
5270 let m = family.active_classes();
5271 let n = family.weights.len();
5272 let eta = Array2::<f64>::zeros((n, m));
5273 let d_beta = Array1::<f64>::zeros(m * p);
5274 let jet = family
5275 .directional_fisher_jet(eta.view(), &d_beta)
5276 .expect("zero direction must be valid");
5277 for &v in jet.iter() {
5278 assert!(v.abs() < 1.0e-14, "expected zero kernel, got {v}");
5279 }
5280 }
5281
5282 #[test]
5283 fn beta_flat_dim_equals_active_classes_times_p() {
5284 let family = toy_family(3, 5, 4);
5285 assert_eq!(family.beta_flat_dim(), 3 * 5);
5286 }
5287
5288 #[test]
5289 fn matrix_free_matvec_matches_dense_hessian_dot() {
5290 let family = toy_family(7, 3, 4);
5294 let p = family.design.ncols();
5295 let m = family.active_classes();
5296 let n = family.weights.len();
5297 let design = family.design.view();
5298 let block_states: Vec<ParameterBlockState> = (0..m)
5300 .map(|a| {
5301 let beta =
5302 Array1::<f64>::from_shape_fn(p, |i| 0.3 * ((a + 1) as f64) - 0.1 * (i as f64));
5303 let eta = Array1::<f64>::from_shape_fn(n, |row| {
5304 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5305 });
5306 ParameterBlockState { beta, eta }
5307 })
5308 .collect();
5309 let specs = family.build_block_specs();
5310 let ws = family
5311 .exact_newton_joint_hessian_workspace(&block_states, &specs)
5312 .expect("workspace build must succeed")
5313 .expect("workspace must be present");
5314 let dense = family
5315 .exact_newton_joint_hessian(&block_states)
5316 .expect("dense Hessian must build")
5317 .expect("dense Hessian must be present");
5318 for seed in 0..(m * p) {
5320 let v = Array1::<f64>::from_shape_fn(m * p, |i| {
5321 if i == seed {
5322 1.0
5323 } else {
5324 0.07 * ((i + 1) as f64).cos()
5325 }
5326 });
5327 let mf = ws
5328 .hessian_matvec(&v)
5329 .expect("matvec must succeed")
5330 .expect("matvec must be present");
5331 let dv = dense.dot(&v);
5332 for (a, b) in mf.iter().zip(dv.iter()) {
5333 assert!(
5334 (a - b).abs() < 1.0e-9,
5335 "matrix-free matvec {a} != dense {b}"
5336 );
5337 }
5338 let mut into = Array1::<f64>::from_elem(m * p, f64::NAN);
5340 let wrote = ws
5341 .hessian_matvec_into(&v, &mut into)
5342 .expect("matvec_into must succeed");
5343 assert!(wrote, "matvec_into must report it wrote");
5344 for (a, b) in into.iter().zip(mf.iter()) {
5345 assert!((a - b).abs() < 1.0e-12, "matvec_into {a} != matvec {b}");
5346 }
5347 }
5348 let mf_diag = ws
5350 .hessian_diagonal()
5351 .expect("diagonal must succeed")
5352 .expect("diagonal must be present");
5353 let dense_diag = dense.diag();
5354 for (a, b) in mf_diag.iter().zip(dense_diag.iter()) {
5355 assert!((a - b).abs() < 1.0e-9, "matrix-free diag {a} != dense {b}");
5356 }
5357 }
5358
5359 #[test]
5360 fn batched_second_directional_all_axes_matches_per_axis() {
5361 let family = toy_family(9, 3, 4);
5366 let p = family.design.ncols();
5367 let m = family.active_classes();
5368 let n = family.weights.len();
5369 let design = family.design.view();
5370 let block_states: Vec<ParameterBlockState> = (0..m)
5371 .map(|a| {
5372 let beta = Array1::<f64>::from_shape_fn(p, |i| {
5373 0.25 * ((a + 1) as f64) - 0.13 * (i as f64)
5374 });
5375 let eta = Array1::<f64>::from_shape_fn(n, |row| {
5376 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5377 });
5378 ParameterBlockState { beta, eta }
5379 })
5380 .collect();
5381 let specs = family.build_block_specs();
5382 let dim = m * p;
5383
5384 let delta = Array1::<f64>::from_shape_fn(dim, |i| {
5386 0.4 - 0.07 * (i as f64) + 0.03 * ((i * i) as f64).cos()
5387 });
5388
5389 let batched = family
5391 .joint_jeffreys_information_second_directional_all_axes_with_specs(
5392 &block_states,
5393 &specs,
5394 &delta,
5395 )
5396 .expect("batched second-directional must succeed")
5397 .expect("batched second-directional must be present");
5398 assert_eq!(batched.len(), dim, "one matrix per canonical axis");
5399
5400 for axis in 0..dim {
5402 let mut e_a = Array1::<f64>::zeros(dim);
5403 e_a[axis] = 1.0;
5404 let per_axis = family
5405 .exact_newton_joint_hessiansecond_directional_derivative(
5406 &block_states,
5407 &delta,
5408 &e_a,
5409 )
5410 .expect("per-axis second-directional must succeed")
5411 .expect("per-axis second-directional must be present");
5412 assert_eq!(batched[axis].dim(), (dim, dim));
5413 for r in 0..dim {
5414 for c in 0..dim {
5415 let a = batched[axis][[r, c]];
5416 let b = per_axis[[r, c]];
5417 assert!(
5418 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5419 "axis {axis} entry ({r},{c}): batched {a} != per-axis {b}"
5420 );
5421 }
5422 }
5423 }
5424 }
5425
5426 #[test]
5427 fn batched_general_directional_derivatives_match_per_direction() {
5428 let family = toy_family(11, 4, 3);
5433 let p = family.design.ncols();
5434 let m = family.active_classes();
5435 let n = family.weights.len();
5436 let dim = m * p;
5437 let design = family.design.view();
5438 let block_states: Vec<ParameterBlockState> = (0..m)
5439 .map(|a| {
5440 let beta = Array1::<f64>::from_shape_fn(p, |i| {
5441 0.18 * ((a + 2) as f64) + 0.09 * ((i + 1) as f64).sin()
5442 });
5443 let eta = Array1::<f64>::from_shape_fn(n, |row| {
5444 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5445 });
5446 ParameterBlockState { beta, eta }
5447 })
5448 .collect();
5449 let eta = family
5450 .collect_eta_matrix(&block_states)
5451 .expect("eta collection must succeed");
5452 let directions: Vec<Array1<f64>> = (0..5)
5453 .map(|seed| {
5454 Array1::<f64>::from_shape_fn(dim, |idx| {
5455 0.31 * ((seed + 1 + idx) as f64).sin()
5456 - 0.07 * ((seed * 3 + idx + 2) as f64).cos()
5457 })
5458 })
5459 .collect();
5460
5461 let batched = family
5462 .assemble_directional_derivatives(eta.view(), &directions)
5463 .expect("batched first directional derivatives must succeed");
5464 assert_eq!(batched.len(), directions.len());
5465 for (dir_idx, direction) in directions.iter().enumerate() {
5466 let per_direction = family
5467 .exact_newton_joint_hessian_directional_derivative(&block_states, direction)
5468 .expect("per-direction derivative must succeed")
5469 .expect("per-direction derivative must be present");
5470 for r in 0..dim {
5471 for c in 0..dim {
5472 let a = batched[dir_idx][[r, c]];
5473 let b = per_direction[[r, c]];
5474 assert!(
5475 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5476 "direction {dir_idx} entry ({r},{c}): batched {a} != per-direction {b}"
5477 );
5478 }
5479 }
5480 }
5481
5482 let specs = family.build_block_specs();
5483 let workspace = family
5484 .exact_newton_joint_hessian_workspace(&block_states, &specs)
5485 .expect("workspace build must succeed")
5486 .expect("workspace must be present");
5487 let operators = workspace
5488 .directional_derivative_operators(&directions)
5489 .expect("workspace batched operators must succeed");
5490 assert_eq!(operators.len(), directions.len());
5491 for (dir_idx, maybe_operator) in operators.into_iter().enumerate() {
5492 let dense = maybe_operator
5493 .expect("workspace must return a derivative operator")
5494 .to_dense();
5495 for r in 0..dim {
5496 for c in 0..dim {
5497 let a = dense[[r, c]];
5498 let b = batched[dir_idx][[r, c]];
5499 assert!(
5500 (a - b).abs() <= 1e-12 * (1.0 + b.abs()),
5501 "operator direction {dir_idx} entry ({r},{c}): {a} != {b}"
5502 );
5503 }
5504 }
5505 }
5506 }
5507
5508 #[test]
5509 fn workspace_batched_second_directional_pairs_match_per_pair() {
5510 let family = toy_family(10, 4, 4);
5515 let p = family.design.ncols();
5516 let m = family.active_classes();
5517 let n = family.weights.len();
5518 let dim = m * p;
5519 let design = family.design.view();
5520 let block_states: Vec<ParameterBlockState> = (0..m)
5521 .map(|a| {
5522 let beta = Array1::<f64>::from_shape_fn(p, |i| {
5523 0.11 * ((a + 3) as f64) - 0.06 * ((i + 2) as f64).cos()
5524 });
5525 let eta = Array1::<f64>::from_shape_fn(n, |row| {
5526 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5527 });
5528 ParameterBlockState { beta, eta }
5529 })
5530 .collect();
5531 let specs = family.build_block_specs();
5532 let workspace = family
5533 .exact_newton_joint_hessian_workspace(&block_states, &specs)
5534 .expect("workspace build must succeed")
5535 .expect("workspace must be present");
5536 let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..7)
5537 .map(|seed| {
5538 let u = Array1::<f64>::from_shape_fn(dim, |idx| {
5539 0.19 * ((seed + idx + 1) as f64).sin()
5540 + 0.05 * ((2 * seed + idx + 3) as f64).cos()
5541 });
5542 let v = Array1::<f64>::from_shape_fn(dim, |idx| {
5543 -0.17 * ((seed + 2 * idx + 5) as f64).cos()
5544 + 0.04 * ((seed + idx + 7) as f64).sin()
5545 });
5546 (u, v)
5547 })
5548 .collect();
5549
5550 let batched = workspace
5551 .second_directional_derivative_operators(&pairs)
5552 .expect("workspace batched second-directional operators must succeed");
5553 assert_eq!(batched.len(), pairs.len());
5554
5555 for (pair_idx, ((u, v), maybe_operator)) in
5556 pairs.iter().zip(batched.into_iter()).enumerate()
5557 {
5558 let dense = maybe_operator
5559 .expect("workspace must return a second-directional operator")
5560 .to_dense();
5561 let per_pair = family
5562 .exact_newton_joint_hessiansecond_directional_derivative(&block_states, u, v)
5563 .expect("per-pair second-directional must succeed")
5564 .expect("per-pair second-directional must be present");
5565 for r in 0..dim {
5566 for c in 0..dim {
5567 let a = dense[[r, c]];
5568 let b = per_pair[[r, c]];
5569 assert!(
5570 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5571 "pair {pair_idx} entry ({r},{c}): batched {a} != per-pair {b}"
5572 );
5573 }
5574 }
5575 }
5576 }
5577
5578 #[test]
5587 fn matrix_free_directional_operator_matches_dense_oracle() {
5588 for &(n, p, k, rank) in &[
5593 (11, 4, 3, 2),
5594 (9, 5, 4, 3),
5595 (13, 3, 5, 4),
5596 (7, 6, 3, 1),
5597 (17, 10, 3, 20),
5598 (17, 10, 3, 19),
5599 ] {
5600 let family = toy_family(n, p, k);
5601 let m = family.active_classes();
5602 let dim = m * p;
5603 let design = family.design.view();
5604 let block_states: Vec<ParameterBlockState> = (0..m)
5605 .map(|a| {
5606 let beta = Array1::<f64>::from_shape_fn(p, |i| {
5607 0.13 * ((a + 2) as f64) - 0.08 * ((i + 1) as f64).cos()
5608 });
5609 let eta = Array1::<f64>::from_shape_fn(n, |row| {
5610 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5611 });
5612 ParameterBlockState { beta, eta }
5613 })
5614 .collect();
5615 let eta = family
5616 .collect_eta_matrix(&block_states)
5617 .expect("eta collection must succeed");
5618 let probs = family.row_probabilities(eta.view());
5619
5620 let factor = Array2::<f64>::from_shape_fn((dim, rank), |(r, c)| {
5622 0.41 * ((r + 2 * c + 1) as f64).sin() - 0.12 * ((3 * r + c + 2) as f64).cos()
5623 });
5624 let probe = Array1::<f64>::from_shape_fn(dim, |idx| {
5625 0.27 * ((idx + 1) as f64).sin() + 0.05 * ((idx + 3) as f64).cos()
5626 });
5627
5628 let directions: Vec<Array1<f64>> = (0..4)
5629 .map(|seed| {
5630 Array1::<f64>::from_shape_fn(dim, |idx| {
5631 0.29 * ((seed + idx + 1) as f64).sin()
5632 - 0.06 * ((2 * seed + idx + 2) as f64).cos()
5633 })
5634 })
5635 .collect();
5636
5637 let dense_mats = family
5639 .assemble_directional_derivatives_from_probs(probs.view(), &directions)
5640 .expect("dense directional assembly must succeed");
5641 for (idx, direction) in directions.iter().enumerate() {
5642 let dense = DenseMatrixHyperOperator {
5643 matrix: dense_mats[idx].clone(),
5644 };
5645 let mf = family
5646 .directional_hyper_operator(
5647 probs.view(),
5648 direction,
5649 Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
5650 )
5651 .expect("matrix-free directional operator must build");
5652 assert_oracle_parity(
5653 &dense,
5654 &mf,
5655 &factor,
5656 &probe,
5657 &format!("dir {idx} n={n} p={p} k={k}"),
5658 );
5659 }
5660
5661 let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..3)
5663 .map(|seed| {
5664 let u = Array1::<f64>::from_shape_fn(dim, |idx| {
5665 0.21 * ((seed + idx + 1) as f64).sin()
5666 });
5667 let v = Array1::<f64>::from_shape_fn(dim, |idx| {
5668 -0.18 * ((seed + 2 * idx + 4) as f64).cos()
5669 });
5670 (u, v)
5671 })
5672 .collect();
5673 let dense_pairs = family
5674 .assemble_second_directional_derivatives_from_probs(probs.view(), &pairs)
5675 .expect("dense second-directional assembly must succeed");
5676 for (idx, (u, v)) in pairs.iter().enumerate() {
5677 let dense = DenseMatrixHyperOperator {
5678 matrix: dense_pairs[idx].clone(),
5679 };
5680 let mf = family
5681 .second_directional_hyper_operator(
5682 probs.view(),
5683 u,
5684 v,
5685 Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
5686 )
5687 .expect("matrix-free second-directional operator must build");
5688 assert_oracle_parity(
5689 &dense,
5690 &mf,
5691 &factor,
5692 &probe,
5693 &format!("pair {idx} n={n} p={p} k={k}"),
5694 );
5695 }
5696 }
5697 }
5698
5699 fn assert_oracle_parity(
5701 dense: &DenseMatrixHyperOperator,
5702 mf: &MultinomialDirectionalHyperOperator,
5703 factor: &Array2<f64>,
5704 probe: &Array1<f64>,
5705 ctx: &str,
5706 ) {
5707 assert_eq!(dense.dim(), mf.dim(), "{ctx}: dim mismatch");
5708
5709 let pd = dense.projected_matrix(factor);
5711 let pm = mf.projected_matrix(factor);
5712 for ((r, c), &a) in pd.indexed_iter() {
5713 let b = pm[[r, c]];
5714 assert!(
5715 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5716 "{ctx}: projected_matrix[{r},{c}] dense {a} != matrix-free {b}"
5717 );
5718 }
5719
5720 let td = dense.trace_projected_factor(factor);
5722 let tm = mf.trace_projected_factor(factor);
5723 assert!(
5724 (td - tm).abs() <= 1e-10 * (1.0 + td.abs()),
5725 "{ctx}: trace dense {td} != matrix-free {tm}"
5726 );
5727 let tm_from_projection = pm.diag().sum();
5728 assert!(
5729 (tm - tm_from_projection).abs() <= 1e-10 * (1.0 + tm.abs()),
5730 "{ctx}: direct trace {tm} != projected-matrix trace {tm_from_projection}"
5731 );
5732
5733 let bvd = dense.mul_vec(probe);
5735 let bvm = mf.mul_vec(probe);
5736 for (idx, (&a, &b)) in bvd.iter().zip(bvm.iter()).enumerate() {
5737 assert!(
5738 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5739 "{ctx}: mul_vec[{idx}] dense {a} != matrix-free {b}"
5740 );
5741 }
5742
5743 let dd = dense.to_dense();
5745 let dm = mf.to_dense();
5746 for ((r, c), &a) in dd.indexed_iter() {
5747 let b = dm[[r, c]];
5748 assert!(
5749 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5750 "{ctx}: to_dense[{r},{c}] dense {a} != matrix-free {b}"
5751 );
5752 }
5753 }
5754
5755 #[test]
5756 fn new_rejects_k_less_than_two() {
5757 let n = 3;
5758 let y = array![[1.0], [1.0], [1.0]];
5759 let w = Array1::<f64>::ones(n);
5760 let x = Arc::new(Array2::<f64>::ones((n, 1)));
5761 let zero = Array2::<f64>::zeros((1, 1));
5762 let s = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(zero)]);
5763 let err = MultinomialFamily::new(y, w, 1, x, s).expect_err("K = 1 must be rejected");
5764 assert!(err.contains("K"));
5765 }
5766
5767 fn family_with_weights(
5784 n_obs: usize,
5785 p: usize,
5786 k: usize,
5787 weights: Array1<f64>,
5788 ) -> MultinomialFamily {
5789 let y = {
5790 let mut y = Array2::<f64>::zeros((n_obs, k));
5791 for i in 0..n_obs {
5792 y[[i, (3 * i + 1) % k]] = 1.0;
5793 }
5794 y
5795 };
5796 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5797 0.7 * ((i as f64 + 1.0) * 0.31 + (j as f64) * 0.53).sin() - 0.2 * (j as f64)
5798 }));
5799 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
5800 Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
5801 )]);
5802 MultinomialFamily::new(y, weights, k, design, penalties)
5803 .expect("family_with_weights must construct")
5804 }
5805
5806 fn states_at_betas(
5809 family: &MultinomialFamily,
5810 betas: &[Array1<f64>],
5811 ) -> Vec<ParameterBlockState> {
5812 let x = family.design.view();
5813 betas
5814 .iter()
5815 .map(|b| ParameterBlockState {
5816 beta: b.clone(),
5817 eta: x.dot(b),
5818 })
5819 .collect()
5820 }
5821
5822 fn sample_betas(m: usize, p: usize, scale: f64) -> Vec<Array1<f64>> {
5824 (0..m)
5825 .map(|a| {
5826 Array1::from_shape_fn(p, |i| {
5827 scale * (0.41 * (a as f64 + 1.0) - 0.23 * (i as f64) + 0.13).sin()
5828 })
5829 })
5830 .collect()
5831 }
5832
5833 fn neglogl_grad(family: &MultinomialFamily, states: &[ParameterBlockState]) -> Array1<f64> {
5837 let eta = family.collect_eta_matrix(states).expect("eta collect");
5838 let probs = family.row_probabilities(eta.view());
5839 let x = family.design.view();
5840 let n = family.weights.len();
5841 let p = family.design.ncols();
5842 let m = family.active_classes();
5843 let mut g = Array1::<f64>::zeros(m * p);
5844 for a in 0..m {
5845 for i in 0..p {
5846 let mut acc = 0.0_f64;
5847 for row in 0..n {
5848 acc += x[[row, i]]
5849 * family.weights[row]
5850 * (probs[[row, a]] - family.y_one_hot[[row, a]]);
5851 }
5852 g[a * p + i] = acc;
5853 }
5854 }
5855 g
5856 }
5857
5858 fn perturb(betas: &[Array1<f64>], v: &Array1<f64>, factor: f64) -> Vec<Array1<f64>> {
5859 let p = betas[0].len();
5860 betas
5861 .iter()
5862 .enumerate()
5863 .map(|(a, b)| Array1::from_shape_fn(p, |i| b[i] + factor * v[a * p + i]))
5864 .collect()
5865 }
5866
5867 #[test]
5868 fn matrix_free_matvec_matches_dense_across_directions() {
5869 let n = 13;
5871 let p = 4;
5872 let k = 4;
5873 let family = family_with_weights(
5874 n,
5875 p,
5876 k,
5877 Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.37).cos().abs()),
5878 );
5879 let m = family.active_classes();
5880 let total = m * p;
5881 let states = states_at_betas(&family, &sample_betas(m, p, 0.8));
5882 let specs = family.build_block_specs();
5883 let ws = family
5884 .exact_newton_joint_hessian_workspace(&states, &specs)
5885 .expect("workspace build")
5886 .expect("workspace present");
5887 let dense = ws.hessian_dense().expect("dense").expect("dense present");
5888
5889 for seed in 0..8usize {
5890 let v = Array1::from_shape_fn(total, |idx| {
5891 ((seed * 31 + idx * 17 + 5) as f64 * 0.123).cos()
5892 });
5893 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5894 let dv = dense.dot(&v);
5895 let mut max_abs = 0.0_f64;
5896 let mut scale = 1.0e-300_f64;
5897 for idx in 0..total {
5898 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5899 scale = scale.max(dv[idx].abs());
5900 }
5901 assert!(
5902 max_abs <= 1.0e-10 * scale + 1.0e-13,
5903 "seed {seed}: matrix-free matvec deviates from dense by {max_abs} (scale {scale})"
5904 );
5905 }
5906 }
5907
5908 #[test]
5909 fn matrix_free_matvec_does_not_allocate_dense_but_matches_at_extreme_eta() {
5910 let n = 9;
5914 let p = 3;
5915 let k = 5;
5916 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
5917 let m = family.active_classes();
5918 let total = m * p;
5919 let states = states_at_betas(&family, &sample_betas(m, p, 12.0));
5920 let specs = family.build_block_specs();
5921 let ws = family
5922 .exact_newton_joint_hessian_workspace(&states, &specs)
5923 .expect("workspace build")
5924 .expect("workspace present");
5925 let dense = ws.hessian_dense().expect("dense").expect("dense present");
5926 let v = Array1::from_shape_fn(total, |idx| ((idx as f64) * 0.91 - 1.0).sin());
5927 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5928 let dv = dense.dot(&v);
5929 let mut max_abs = 0.0_f64;
5930 let mut scale = 1.0e-300_f64;
5931 for idx in 0..total {
5932 assert!(mf[idx].is_finite(), "matvec entry {idx} not finite");
5933 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5934 scale = scale.max(dv[idx].abs());
5935 }
5936 assert!(
5937 max_abs <= 1.0e-10 * scale + 1.0e-13,
5938 "extreme-η matvec deviates from dense by {max_abs} (scale {scale})"
5939 );
5940 }
5941
5942 #[test]
5943 fn matrix_free_matvec_handles_zero_weight_rows() {
5944 let n = 10;
5946 let p = 3;
5947 let k = 3;
5948 let mut w = Array1::<f64>::ones(n);
5949 w[2] = 0.0;
5950 w[5] = 0.0;
5951 w[9] = 0.0;
5952 let family = family_with_weights(n, p, k, w);
5953 let m = family.active_classes();
5954 let total = m * p;
5955 let states = states_at_betas(&family, &sample_betas(m, p, 0.6));
5956 let specs = family.build_block_specs();
5957 let ws = family
5958 .exact_newton_joint_hessian_workspace(&states, &specs)
5959 .expect("workspace build")
5960 .expect("workspace present");
5961 let dense = ws.hessian_dense().expect("dense").expect("dense present");
5962 let v = Array1::from_shape_fn(total, |idx| (idx as f64 + 0.5).cos());
5963 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5964 let dv = dense.dot(&v);
5965 let mut max_abs = 0.0_f64;
5966 let mut scale = 1.0e-300_f64;
5967 for idx in 0..total {
5968 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5969 scale = scale.max(dv[idx].abs());
5970 }
5971 assert!(
5972 max_abs <= 1.0e-10 * scale + 1.0e-13,
5973 "zero-weight matvec deviates from dense by {max_abs} (scale {scale})"
5974 );
5975 }
5976
5977 #[test]
5978 fn workspace_gradient_and_loglik_match_family_evaluation_and_prefer_operator() {
5979 let n = 11;
5987 let p = 4;
5988 let k = 3;
5989 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
5990 let m = family.active_classes();
5991 let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
5992 let specs = family.build_block_specs();
5993
5994 let family_eval = family
5995 .exact_newton_joint_gradient_evaluation(&states, &specs)
5996 .expect("family joint gradient eval")
5997 .expect("family joint gradient present");
5998
5999 let ws = family
6000 .exact_newton_joint_hessian_workspace(&states, &specs)
6001 .expect("workspace build")
6002 .expect("workspace present");
6003
6004 assert_eq!(
6005 ws.hessian_source_preference(),
6006 JointHessianSourcePreference::Operator,
6007 "multinomial workspace must prefer the operator (matrix-free) source"
6008 );
6009
6010 let ws_loglik = ws
6011 .joint_log_likelihood_evaluation()
6012 .expect("workspace loglik")
6013 .expect("workspace loglik present");
6014 assert!(
6015 (ws_loglik - family_eval.log_likelihood).abs()
6016 <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
6017 "workspace loglik {ws_loglik} != family loglik {}",
6018 family_eval.log_likelihood
6019 );
6020
6021 let ws_grad_eval = ws
6022 .joint_gradient_evaluation()
6023 .expect("workspace gradient eval")
6024 .expect("workspace gradient present");
6025 assert!(
6026 (ws_grad_eval.log_likelihood - family_eval.log_likelihood).abs()
6027 <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
6028 "workspace gradient-eval loglik mismatch"
6029 );
6030 assert_eq!(ws_grad_eval.gradient.len(), family_eval.gradient.len());
6031 let mut max_abs = 0.0_f64;
6032 let mut scale = 1.0e-300_f64;
6033 for idx in 0..family_eval.gradient.len() {
6034 max_abs = max_abs.max((ws_grad_eval.gradient[idx] - family_eval.gradient[idx]).abs());
6035 scale = scale.max(family_eval.gradient[idx].abs());
6036 }
6037 assert!(
6038 max_abs <= 1e-10 * scale + 1e-13,
6039 "workspace gradient deviates from family gradient by {max_abs} (scale {scale})"
6040 );
6041 }
6042
6043 #[test]
6044 fn matrix_free_matvec_binary_k_equals_two() {
6045 let n = 7;
6048 let p = 3;
6049 let k = 2;
6050 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
6051 let m = family.active_classes();
6052 assert_eq!(m, 1);
6053 let total = m * p;
6054 let states = states_at_betas(&family, &sample_betas(m, p, 1.1));
6055 let specs = family.build_block_specs();
6056 let ws = family
6057 .exact_newton_joint_hessian_workspace(&states, &specs)
6058 .expect("workspace build")
6059 .expect("workspace present");
6060 let dense = ws.hessian_dense().expect("dense").expect("dense present");
6061 let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 0.7 + 0.2).sin());
6062 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6063 let dv = dense.dot(&v);
6064 for idx in 0..total {
6065 assert!(
6066 (mf[idx] - dv[idx]).abs() <= 1.0e-12 * (1.0 + dv[idx].abs()),
6067 "binary matvec entry {idx}: {} vs {}",
6068 mf[idx],
6069 dv[idx]
6070 );
6071 }
6072 }
6073
6074 #[test]
6075 fn matrix_free_matvec_into_matches_owned_return() {
6076 let n = 8;
6077 let p = 3;
6078 let k = 4;
6079 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
6080 let m = family.active_classes();
6081 let total = m * p;
6082 let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
6083 let specs = family.build_block_specs();
6084 let ws = family
6085 .exact_newton_joint_hessian_workspace(&states, &specs)
6086 .expect("workspace build")
6087 .expect("workspace present");
6088 let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 1.7 - 0.3).cos());
6089 let owned = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6090 let mut out = Array1::from_elem(total, 7.0_f64);
6092 let wrote = ws.hessian_matvec_into(&v, &mut out).expect("matvec_into");
6093 assert!(wrote, "matvec_into must report it wrote a result");
6094 assert_eq!(out, owned, "into-variant must match owned return bitwise");
6095 }
6096
6097 #[test]
6098 fn matrix_free_diagonal_is_bit_identical_to_dense_diag() {
6099 let n = 11;
6100 let p = 4;
6101 let k = 4;
6102 let family = family_with_weights(
6103 n,
6104 p,
6105 k,
6106 Array1::from_shape_fn(n, |i| 0.25 + (i as f64 % 3.0)),
6107 );
6108 let m = family.active_classes();
6109 let total = m * p;
6110 let states = states_at_betas(&family, &sample_betas(m, p, 0.7));
6111 let specs = family.build_block_specs();
6112 let ws = family
6113 .exact_newton_joint_hessian_workspace(&states, &specs)
6114 .expect("workspace build")
6115 .expect("workspace present");
6116 let dense = ws.hessian_dense().expect("dense").expect("dense present");
6117 let diag = ws
6118 .hessian_diagonal()
6119 .expect("diagonal")
6120 .expect("diagonal some");
6121 for idx in 0..total {
6122 let got = diag[idx];
6130 let expected = dense[[idx, idx]];
6131 let tol = 1e-12 * (1.0 + expected.abs());
6132 assert!(
6133 (got - expected).abs() <= tol,
6134 "matrix-free diagonal entry {idx} must equal dense diagonal to a few ULP: \
6135 got={got} dense={expected} (tol={tol})"
6136 );
6137 }
6138 }
6139
6140 #[test]
6141 fn matrix_free_matvec_matches_gradient_finite_difference() {
6142 let n = 12;
6147 let p = 3;
6148 let k = 4;
6149 let family = family_with_weights(
6150 n,
6151 p,
6152 k,
6153 Array1::from_shape_fn(n, |i| 0.4 + 0.3 * ((i as f64) * 0.6).sin().abs()),
6154 );
6155 let m = family.active_classes();
6156 let total = m * p;
6157 let betas = sample_betas(m, p, 0.5);
6158 let states = states_at_betas(&family, &betas);
6159 let specs = family.build_block_specs();
6160 let ws = family
6161 .exact_newton_joint_hessian_workspace(&states, &specs)
6162 .expect("workspace build")
6163 .expect("workspace present");
6164
6165 let v = Array1::from_shape_fn(total, |idx| 0.5 * ((idx as f64 * 1.3 + 0.7).sin()));
6166 let hv = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6167
6168 let eps = 1.0e-6;
6169 let g_plus = neglogl_grad(
6170 &family,
6171 &states_at_betas(&family, &perturb(&betas, &v, eps)),
6172 );
6173 let g_minus = neglogl_grad(
6174 &family,
6175 &states_at_betas(&family, &perturb(&betas, &v, -eps)),
6176 );
6177 let mut max_abs = 0.0_f64;
6178 let mut scale = 1.0e-300_f64;
6179 for idx in 0..total {
6180 let fd = (g_plus[idx] - g_minus[idx]) / (2.0 * eps);
6181 max_abs = max_abs.max((hv[idx] - fd).abs());
6182 scale = scale.max(fd.abs());
6183 }
6184 assert!(
6185 max_abs <= 1.0e-5 * scale + 1.0e-7,
6186 "matvec vs gradient finite-difference deviates by {max_abs} (scale {scale})"
6187 );
6188 }
6189
6190 fn perturb_axis(
6221 family: &MultinomialFamily,
6222 betas: &[Array1<f64>],
6223 a0: usize,
6224 i0: usize,
6225 factor: f64,
6226 ) -> Vec<ParameterBlockState> {
6227 let mut shifted = betas.to_vec();
6228 shifted[a0][i0] += factor;
6229 states_at_betas(family, &shifted)
6230 }
6231
6232 #[test]
6233 fn all_axis_directional_derivatives_match_static_hessian_finite_difference() {
6234 let n = 11;
6237 let p = 3;
6238 let k = 4;
6239 let family = family_with_weights(
6240 n,
6241 p,
6242 k,
6243 Array1::from_shape_fn(n, |i| 0.5 + 0.4 * ((i as f64) * 0.41).sin().abs()),
6244 );
6245 let m = family.active_classes();
6246 let total = m * p;
6247 let betas = sample_betas(m, p, 0.6);
6248 let states = states_at_betas(&family, &betas);
6249 let eta = family.collect_eta_matrix(&states).expect("eta collect");
6250
6251 let hand = family.assemble_all_axis_directional_derivatives(eta.view());
6252 assert_eq!(
6253 hand.len(),
6254 total,
6255 "one directional matrix per canonical axis"
6256 );
6257
6258 let eps = 1.0e-6;
6259 let mut max_rel = 0.0_f64;
6260 for a0 in 0..m {
6261 for i0 in 0..p {
6262 let axis = a0 * p + i0;
6263 let h_plus = family
6264 .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, eps))
6265 .expect("H+")
6266 .expect("H+ some");
6267 let h_minus = family
6268 .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, -eps))
6269 .expect("H-")
6270 .expect("H- some");
6271 let hand_axis = &hand[axis];
6272 for r in 0..total {
6273 for c in 0..total {
6274 let fd = (h_plus[[r, c]] - h_minus[[r, c]]) / (2.0 * eps);
6275 let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
6276 max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
6277 }
6278 }
6279 }
6280 }
6281 assert!(
6282 max_rel <= 1.0e-6,
6283 "softmax all-axis directional assembly drifted from the static-Hessian \
6284 finite difference by relative {max_rel:.3e}"
6285 );
6286 }
6287
6288 #[test]
6289 fn all_axis_second_directional_derivatives_match_directional_finite_difference() {
6290 let n = 10;
6291 let p = 3;
6292 let k = 4;
6293 let family = family_with_weights(
6294 n,
6295 p,
6296 k,
6297 Array1::from_shape_fn(n, |i| 0.6 + 0.3 * ((i as f64) * 0.53).cos().abs()),
6298 );
6299 let m = family.active_classes();
6300 let total = m * p;
6301 let betas = sample_betas(m, p, 0.5);
6302 let states = states_at_betas(&family, &betas);
6303 let eta = family.collect_eta_matrix(&states).expect("eta collect");
6304
6305 let delta = Array1::from_shape_fn(total, |idx| 0.4 * ((idx as f64 * 1.7 + 0.3).sin()));
6308
6309 let hand = family
6310 .assemble_all_axis_second_directional_derivatives(eta.view(), &delta)
6311 .expect("second-directional assembly");
6312 assert_eq!(hand.len(), total, "one second-directional matrix per axis");
6313
6314 let hdot_at = |st: &[ParameterBlockState]| -> Array2<f64> {
6318 family
6319 .exact_newton_joint_hessian_directional_derivative(st, &delta)
6320 .expect("Hdot")
6321 .expect("Hdot some")
6322 };
6323
6324 let eps = 1.0e-6;
6325 let mut max_rel = 0.0_f64;
6326 for a0 in 0..m {
6327 for i0 in 0..p {
6328 let axis = a0 * p + i0;
6329 let hd_plus = hdot_at(&perturb_axis(&family, &betas, a0, i0, eps));
6330 let hd_minus = hdot_at(&perturb_axis(&family, &betas, a0, i0, -eps));
6331 let hand_axis = &hand[axis];
6332 for r in 0..total {
6333 for c in 0..total {
6334 let fd = (hd_plus[[r, c]] - hd_minus[[r, c]]) / (2.0 * eps);
6335 let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
6336 max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
6337 }
6338 }
6339 }
6340 }
6341 assert!(
6342 max_rel <= 1.0e-5,
6343 "softmax all-axis second-directional assembly drifted from the directional \
6344 finite difference by relative {max_rel:.3e}"
6345 );
6346 }
6347
6348 #[test]
6376 fn separating_multinomial_arms_universal_jeffreys_firth_term() {
6377 use gam_linalg::faer_ndarray::FaerEigh;
6378 use gam_solve::estimate::reml::jeffreys_subspace::{
6379 jeffreys_subspace_from_penalty, joint_jeffreys_term,
6380 };
6381
6382 let n = 60usize;
6386 let k = 3usize;
6387 let p = 2usize; let design = Arc::new(Array2::<f64>::from_shape_fn(
6389 (n, p),
6390 |(row, col)| match col {
6391 0 => 1.0,
6392 _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
6393 },
6394 ));
6395 let mut y = Array2::<f64>::zeros((n, k));
6396 for row in 0..n {
6397 let x = design[[row, 1]];
6398 let class = if x < -1.0 {
6399 0
6400 } else if x > 1.0 {
6401 1
6402 } else {
6403 2 };
6405 y[[row, class]] = 1.0;
6406 }
6407 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(Array2::<
6410 f64,
6411 >::zeros(
6412 (
6413 p, p,
6414 )
6415 ))]);
6416 let weights = Array1::<f64>::ones(n);
6417 let family = MultinomialFamily::new(y, weights, k, design, penalties)
6418 .expect("separated multinomial family must construct");
6419
6420 let m = family.active_classes();
6421 let total = m * p;
6422
6423 let betas: Vec<Array1<f64>> = (0..m)
6427 .map(|a| Array1::from_vec(vec![-300.0, 600.0 * ((a as f64) - 0.5)]))
6428 .collect();
6429 let states = states_at_betas(&family, &betas);
6430
6431 let h_joint = family
6434 .exact_newton_joint_hessian(&states)
6435 .expect("joint Hessian eval")
6436 .expect("multinomial exposes an explicit joint Hessian");
6437 assert_eq!(h_joint.dim(), (total, total));
6438
6439 let (evals, _) = h_joint
6443 .eigh(faer::Side::Lower)
6444 .expect("information eigendecomposition");
6445 let lambda_max = evals.iter().cloned().fold(0.0_f64, f64::max);
6446 let lambda_min = evals.iter().cloned().fold(f64::INFINITY, f64::min);
6447 assert!(
6448 lambda_max > 0.0 && lambda_min / lambda_max < 1.0e-6,
6449 "fixture must be near-separating: λ_min/λ_max = {} (λ_min={lambda_min}, λ_max={lambda_max})",
6450 lambda_min / lambda_max
6451 );
6452
6453 let aggregate = Array2::<f64>::zeros((p, p));
6456 let block_span = jeffreys_subspace_from_penalty(aggregate.view())
6457 .expect("block Jeffreys span")
6458 .columns;
6459 assert_eq!(block_span.dim(), (p, p));
6460 let mut z_joint = Array2::<f64>::zeros((total, total));
6461 for b in 0..m {
6462 for i in 0..p {
6463 for j in 0..p {
6464 z_joint[[b * p + i, b * p + j]] = block_span[[i, j]];
6465 }
6466 }
6467 }
6468
6469 let (phi, grad_phi, hphi) =
6473 joint_jeffreys_term(h_joint.view(), z_joint.view(), |direction: &Array1<f64>| {
6474 family.exact_newton_joint_hessian_directional_derivative(&states, direction)
6475 })
6476 .expect("multinomial joint Jeffreys term must evaluate");
6477
6478 let term_active =
6481 phi != 0.0 || grad_phi.iter().any(|v| *v != 0.0) || hphi.iter().any(|v| *v != 0.0);
6482 assert!(
6483 term_active,
6484 "Jeffreys/Firth term must fire on a separating multinomial fit (φ={phi})"
6485 );
6486
6487 assert!(
6490 phi.is_finite() && grad_phi.iter().all(|v| v.is_finite()),
6491 "Jeffreys φ/∇φ must be finite (φ={phi})"
6492 );
6493 for v in hphi.iter() {
6494 assert!(v.is_finite(), "H_Φ entry must be finite, got {v}");
6495 }
6496
6497 let (_, evecs) = h_joint
6502 .eigh(faer::Side::Lower)
6503 .expect("eig for separating direction");
6504 let sep_dir = evecs.column(0).to_owned(); let curv_h = sep_dir.dot(&h_joint.dot(&sep_dir));
6506 let curv_hphi = sep_dir.dot(&hphi.dot(&sep_dir));
6507 assert!(
6508 curv_hphi > 0.0,
6509 "H_Φ must supply positive curvature on the separating direction (got {curv_hphi}; bare H curvature there is {curv_h})"
6510 );
6511 assert!(
6512 curv_hphi.is_finite() && curv_hphi >= curv_h,
6513 "augmented curvature {curv_hphi} must dominate the near-zero bare curvature {curv_h}"
6514 );
6515 }
6516
6517 fn second_difference_penalty(p: usize) -> Array2<f64> {
6521 let mut s = Array2::<f64>::zeros((p, p));
6522 for r in 0..p.saturating_sub(2) {
6523 let d = [1.0_f64, -2.0, 1.0];
6525 for (a, &da) in d.iter().enumerate() {
6526 for (b, &db) in d.iter().enumerate() {
6527 s[[r + a, r + b]] += da * db;
6528 }
6529 }
6530 }
6531 s
6532 }
6533
6534 #[test]
6541 fn centered_penalty_is_reference_class_invariant_1587() {
6542 let p = 5usize;
6543 let s = second_difference_penalty(p);
6544 let gamma: [Array1<f64>; 3] = [
6548 array![0.4, -0.1, 0.7, 0.2, -0.5],
6549 array![-0.3, 0.8, 0.1, -0.6, 0.25],
6550 array![0.15, 0.05, -0.4, 0.9, -0.2],
6551 ];
6552 let k = 3usize;
6553 let m = k - 1;
6554 let metric = centered_class_metric(m, k);
6555
6556 let centered_value = |r: usize| -> f64 {
6560 let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
6561 let mut beta = Array1::<f64>::zeros(m * p);
6562 for (a, &cls) in actives.iter().enumerate() {
6563 let diff = &gamma[cls] - &gamma[r];
6564 beta.slice_mut(ndarray::s![a * p..(a + 1) * p])
6565 .assign(&diff);
6566 }
6567 let mut acc = 0.0;
6569 for a in 0..m {
6570 for b in 0..m {
6571 let ba = beta.slice(ndarray::s![a * p..(a + 1) * p]);
6572 let bb = beta.slice(ndarray::s![b * p..(b + 1) * p]);
6573 acc += metric[[a, b]] * ba.dot(&s.dot(&bb));
6574 }
6575 }
6576 acc
6577 };
6578 let diagonal_value = |r: usize| -> f64 {
6579 let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
6580 actives
6581 .iter()
6582 .map(|&cls| {
6583 let diff = &gamma[cls] - &gamma[r];
6584 diff.dot(&s.dot(&diff))
6585 })
6586 .sum()
6587 };
6588
6589 let c0 = centered_value(0);
6590 let c1 = centered_value(1);
6591 let c2 = centered_value(2);
6592 assert!(
6593 (c0 - c1).abs() < 1e-12 && (c0 - c2).abs() < 1e-12,
6594 "centered penalty must be reference-invariant: {c0} {c1} {c2}"
6595 );
6596 let mean: Array1<f64> = (&gamma[0] + &gamma[1] + &gamma[2]) / 3.0;
6598 let clr: f64 = gamma
6599 .iter()
6600 .map(|g| {
6601 let c = g - &mean;
6602 c.dot(&s.dot(&c))
6603 })
6604 .sum();
6605 assert!(
6606 (c0 - clr).abs() < 1e-10,
6607 "centered penalty {c0} must equal the CLR form {clr}"
6608 );
6609
6610 let d0 = diagonal_value(0);
6612 let d1 = diagonal_value(1);
6613 let d2 = diagonal_value(2);
6614 let diag_spread = (d0 - d1).abs().max((d0 - d2).abs()).max((d1 - d2).abs());
6615 assert!(
6616 diag_spread > 1e-6,
6617 "reference-anchored penalty should differ across references (reproducing the bug); spread {diag_spread}"
6618 );
6619 }
6620
6621 #[test]
6624 fn centered_joint_penalty_spec_is_psd_with_declared_nullspace_1587() {
6625 use gam_linalg::faer_ndarray::FaerEigh;
6626 let p = 5usize;
6627 let s = second_difference_penalty(p); let k = 4usize; let m = k - 1;
6630 let metric = centered_class_metric(m, k);
6631 let raw_total = m * p;
6632 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
6633 for a in 0..m {
6634 for b in 0..m {
6635 for i in 0..p {
6636 for j in 0..p {
6637 matrix[[a * p + i, b * p + j]] = metric[[a, b]] * s[[i, j]];
6638 }
6639 }
6640 }
6641 }
6642 for i in 0..raw_total {
6644 for j in 0..raw_total {
6645 assert!((matrix[[i, j]] - matrix[[j, i]]).abs() < 1e-14);
6646 }
6647 }
6648 let (evals, _) = FaerEigh::eigh(&matrix, faer::Side::Lower).expect("eigh");
6649 let mut sorted: Vec<f64> = evals.iter().copied().collect();
6650 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6651 assert!(sorted[0] > -1e-10, "M⊗S must be PSD; min eig {}", sorted[0]);
6653 let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-9).count();
6655 assert_eq!(
6656 zeros,
6657 m * 2,
6658 "nullspace dim must be (K-1)·ns(S); spectrum {sorted:?}"
6659 );
6660 }
6661}